From 523e74d0a970ca1763aae08c8827c1e2a1a6b3c5 Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Mon, 2 Feb 2026 18:43:04 -0800 Subject: [PATCH 001/161] Implement Unpack of TypeVars for **kwargs inference Allow `**kwargs: Unpack[K]` where K is a TypeVar bound to TypedDict. This enables inferring TypedDict types from keyword arguments at call sites. Example: def f[K: BaseTypedDict](**kwargs: Unpack[K]) -> K: ... result = f(x=1, y="hello") # K inferred as TypedDict({'x': int, 'y': str}) Changes: - semanal.py: Accept TypeVar with TypedDict bound in remove_unpack_kwargs(), keep UnpackType for constraint inference - semanal_typeargs.py: Allow TypeVar with TypedDict bound in visit_unpack_type() - constraints.py: Generate TypedDict constraints from actual kwargs - types.py: Handle TypeVar in with_unpacked_kwargs() - checkexpr.py: Re-expand kwargs after TypeVar inference Co-Authored-By: Claude Opus 4.5 --- mypy/checkexpr.py | 21 +- mypy/constraints.py | 86 +++++- mypy/semanal.py | 37 ++- mypy/semanal_typeargs.py | 11 + mypy/types.py | 13 + mypy/types_utils.py | 14 + .../unit/check-kwargs-unpack-typevar.test | 266 ++++++++++++++++++ test-data/unit/check-typevar-tuple.test | 4 +- test-data/unit/check-varargs.test | 2 +- test-data/unit/fixtures/typing-full.pyi | 3 + test-data/unit/semanal-errors.test | 2 +- 11 files changed, 444 insertions(+), 15 deletions(-) create mode 100644 test-data/unit/check-kwargs-unpack-typevar.test diff --git a/mypy/checkexpr.py b/mypy/checkexpr.py index 49fc1159856f7..159cb6303dcc9 100644 --- a/mypy/checkexpr.py +++ b/mypy/checkexpr.py @@ -211,6 +211,7 @@ is_overlapping_none, is_self_type_like, remove_optional, + try_getting_literal, ) from mypy.typestate import type_state from mypy.typevars import fill_typevars @@ -1760,9 +1761,21 @@ def check_callable_call( need_refresh = any( isinstance(v, (ParamSpecType, TypeVarTupleType)) for v in callee.variables ) + # Check if we have TypeVar-based kwargs that need expansion after inference + has_typevar_kwargs = ( + callee.unpack_kwargs + and callee.arg_types + and isinstance(callee.arg_types[-1], UnpackType) + and isinstance(get_proper_type(callee.arg_types[-1].type), TypeVarType) + ) callee = self.infer_function_type_arguments( callee, args, arg_kinds, arg_names, formal_to_actual, need_refresh, context ) + if has_typevar_kwargs: + # After inference, the TypeVar in **kwargs should be replaced with + # an inferred TypedDict. Re-expand the kwargs now. + callee = callee.with_unpacked_kwargs().with_normalized_var_args() + need_refresh = True if need_refresh: # Argument kinds etc. may have changed due to # ParamSpec or TypeVarTuple variables being replaced with an arbitrary @@ -6815,14 +6828,6 @@ def merge_typevars_in_callables_by_name( return output, variables -def try_getting_literal(typ: Type) -> ProperType: - """If possible, get a more precise literal type for a given type.""" - typ = get_proper_type(typ) - if isinstance(typ, Instance) and typ.last_known_value is not None: - return typ.last_known_value - return typ - - def is_expr_literal_type(node: Expression) -> bool: """Returns 'true' if the given node is a Literal""" if isinstance(node, IndexExpr): diff --git a/mypy/constraints.py b/mypy/constraints.py index df79fdae5456c..f95f140aeeac6 100644 --- a/mypy/constraints.py +++ b/mypy/constraints.py @@ -58,7 +58,7 @@ is_named_instance, split_with_prefix_and_suffix, ) -from mypy.types_utils import is_union_with_any +from mypy.types_utils import is_union_with_any, try_getting_literal from mypy.typestate import type_state if TYPE_CHECKING: @@ -135,7 +135,7 @@ def infer_constraints_for_callable( break for i, actuals in enumerate(formal_to_actual): - if isinstance(callee.arg_types[i], UnpackType): + if isinstance(callee.arg_types[i], UnpackType) and callee.arg_kinds[i] == ARG_STAR: unpack_type = callee.arg_types[i] assert isinstance(unpack_type, UnpackType) @@ -218,6 +218,88 @@ def infer_constraints_for_callable( constraints.extend(infer_constraints(tt, at, SUPERTYPE_OF)) else: assert False, "mypy bug: unhandled constraint inference case" + + elif isinstance(callee.arg_types[i], UnpackType) and callee.arg_kinds[i] == ARG_STAR2: + # Handle **kwargs: Unpack[K] where K is TypeVar bound to TypedDict. + # Collect actual kwargs and build a TypedDict constraint. + + unpack_type = callee.arg_types[i] + assert isinstance(unpack_type, UnpackType) + + unpacked_type = get_proper_type(unpack_type.type) + assert isinstance(unpacked_type, TypeVarType) + + other_named = { + name + for name, kind in zip(callee.arg_names, callee.arg_kinds) + if name is not None and not kind.is_star() + } + + # Collect all the arguments that will go to **kwargs + kwargs_items: dict[str, Type] = {} + for actual in actuals: + actual_arg_type = arg_types[actual] + if actual_arg_type is None: + continue + actual_name = arg_names[actual] if arg_names is not None else None + if actual_name is not None: + # Named argument going to **kwargs + kwargs_items[actual_name] = actual_arg_type + elif arg_kinds[actual] == ARG_STAR2: + # **kwargs being passed through - try to extract TypedDict items + p_actual = get_proper_type(actual_arg_type) + if isinstance(p_actual, TypedDictType): + for sname, styp in p_actual.items.items(): + # But we need to filter out names that + # will go to other parameters + if sname not in other_named: + kwargs_items[sname] = styp + + # Build a TypedDict from the collected kwargs. + bound = get_proper_type(unpacked_type.upper_bound) + if isinstance(bound, Instance) and bound.type.typeddict_type is not None: + bound = bound.type.typeddict_type + + # This should be an error from an earlier level, but don't compound it + if not isinstance(bound, TypedDictType): + continue + + # Start with the actual kwargs passed, with literal types + # inferred for read-only and unbound items + items = { + key: ( + try_getting_literal(typ) + if key not in bound.items or key in bound.readonly_keys + else typ + ) + for key, typ in kwargs_items.items() + } + # Add any NotRequired keys from the bound that weren't passed + # (they need to be present for TypedDict subtyping to work) + for key, value_type in bound.items.items(): + if key not in items and key not in bound.required_keys: + # If the key is missing and it is ReadOnly, + # then we can replace the type with Never to + # indicate that it is definitely not + # present. We can't do that if it is mutable, + # though (because that violates the subtyping + # rules.) + items[key] = ( + value_type if key not in bound.readonly_keys else UninhabitedType() + ) + # Keys are required if they're required in the bound, or if they're + # extra keys not in the bound (explicitly passed, so required). + required_keys = { + key for key in items if key in bound.required_keys or key not in bound.items + } + inferred_td = TypedDictType( + items=items, + required_keys=required_keys, + readonly_keys=bound.readonly_keys, + fallback=bound.fallback, + ) + constraints.append(Constraint(unpacked_type, SUPERTYPE_OF, inferred_td)) + else: for actual in actuals: actual_arg_type = arg_types[actual] diff --git a/mypy/semanal.py b/mypy/semanal.py index bf21e057345fa..8c7703af7c0db 100644 --- a/mypy/semanal.py +++ b/mypy/semanal.py @@ -1116,8 +1116,26 @@ def remove_unpack_kwargs(self, defn: FuncDef, typ: CallableType) -> CallableType if not isinstance(last_type, UnpackType): return typ p_last_type = get_proper_type(last_type.type) + + # Handle TypeVar bound to TypedDict - allows inferring TypedDict from kwargs + if isinstance(p_last_type, TypeVarType): + bound = get_proper_type(p_last_type.upper_bound) + if not self.is_typeddict_like(bound): + self.fail( + "Unpack item in ** parameter must be a TypedDict or a TypeVar with TypedDict bound", + last_type, + ) + new_arg_types = typ.arg_types[:-1] + [AnyType(TypeOfAny.from_error)] + return typ.copy_modified(arg_types=new_arg_types) + # For TypeVar, we can't check overlap statically since the actual TypedDict + # will be inferred at call sites. Keep the TypeVar for constraint inference. + return typ.copy_modified(unpack_kwargs=True) + if not isinstance(p_last_type, TypedDictType): - self.fail("Unpack item in ** parameter must be a TypedDict", last_type) + self.fail( + "Unpack item in ** parameter must be a TypedDict or a TypeVar with TypedDict bound", + 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(p_last_type.items) @@ -1134,6 +1152,23 @@ def remove_unpack_kwargs(self, defn: FuncDef, typ: CallableType) -> CallableType new_arg_types = typ.arg_types[:-1] + [p_last_type] return typ.copy_modified(arg_types=new_arg_types, unpack_kwargs=True) + def is_typeddict_like(self, typ: ProperType) -> bool: + """Check if type is TypedDict or inherits from BaseTypedDict.""" + if isinstance(typ, TypedDictType): + return True + if isinstance(typ, Instance): + # Check if it's a TypedDict class or inherits from BaseTypedDict + if typ.type.typeddict_type is not None: + return True + for base in typ.type.mro: + if base.fullname in ( + "typing.TypedDict", + "typing.BaseTypedDict", + "_typeshed.typemap.BaseTypedDict", + ): + return True + return False + def prepare_method_signature(self, func: FuncDef, info: TypeInfo, has_self_type: bool) -> None: """Check basic signature validity and tweak annotation of self/cls argument.""" # Only non-static methods are special, as well as __new__. diff --git a/mypy/semanal_typeargs.py b/mypy/semanal_typeargs.py index 0f62a4aa8b1a2..6e13c60244849 100644 --- a/mypy/semanal_typeargs.py +++ b/mypy/semanal_typeargs.py @@ -28,6 +28,7 @@ TupleType, Type, TypeAliasType, + TypedDictType, TypeOfAny, TypeVarLikeType, TypeVarTupleType, @@ -255,6 +256,16 @@ def visit_unpack_type(self, typ: UnpackType) -> None: # tricky however, since this needs map_instance_to_supertype() available in many places. if isinstance(proper_type, Instance) and proper_type.type.fullname == "builtins.tuple": return + # TypeVar with TypedDict bound is allowed for **kwargs unpacking with inference. + # Note: for concrete TypedDict, semanal.py's remove_unpack_kwargs() unwraps the Unpack, + # so this check won't be reached. For TypeVar, we keep the Unpack for constraint inference. + if isinstance(proper_type, TypeVarType): + bound = get_proper_type(proper_type.upper_bound) + if isinstance(bound, TypedDictType): + return + # Also allow Instance bounds that are TypedDict-like + if isinstance(bound, Instance) and bound.type.typeddict_type is not None: + return if not isinstance(proper_type, (UnboundType, AnyType)): # Avoid extra errors if there were some errors already. Also interpret plain Any # as tuple[Any, ...] (this is better for the code in type checker). diff --git a/mypy/types.py b/mypy/types.py index d4ed728f4c9b8..9cb63d58ba89d 100644 --- a/mypy/types.py +++ b/mypy/types.py @@ -2454,6 +2454,19 @@ def with_unpacked_kwargs(self) -> NormalizedCallableType: if not self.unpack_kwargs: return cast(NormalizedCallableType, self) last_type = get_proper_type(self.arg_types[-1]) + # Handle Unpack[K] where K is TypeVar bound to TypedDict + if isinstance(last_type, UnpackType): + unpacked = get_proper_type(last_type.type) + if isinstance(unpacked, TypeVarType): + # TypeVar with TypedDict bound - can't expand until after inference. + # Return unchanged for now; expansion happens after type var substitution. + return cast(NormalizedCallableType, self) + # For TypedDict inside UnpackType, unwrap it + if isinstance(unpacked, TypedDictType): + last_type = unpacked + if isinstance(last_type, TypeVarType): + # Direct TypeVar (shouldn't happen normally but handle it) + return cast(NormalizedCallableType, self) assert isinstance(last_type, TypedDictType) extra_kinds = [ ArgKind.ARG_NAMED if name in last_type.required_keys else ArgKind.ARG_NAMED_OPT diff --git a/mypy/types_utils.py b/mypy/types_utils.py index 160f6c0365d63..86caa3e2e42e6 100644 --- a/mypy/types_utils.py +++ b/mypy/types_utils.py @@ -180,4 +180,18 @@ def store_argument_type( elif typ.arg_kinds[i] == ARG_STAR2: if not isinstance(arg_type, ParamSpecType) and not typ.unpack_kwargs: arg_type = named_type("builtins.dict", [named_type("builtins.str", []), arg_type]) + # Strip the Unpack from Unpack[K], since it isn't part of the + # type inside the function + elif isinstance(arg_type, UnpackType): + unpacked_type = get_proper_type(arg_type.type) + assert isinstance(unpacked_type, TypeVarType) + arg_type = unpacked_type defn.arguments[i].variable.type = arg_type + + +def try_getting_literal(typ: Type) -> ProperType: + """If possible, get a more precise literal type for a given type.""" + typ = get_proper_type(typ) + if isinstance(typ, Instance) and typ.last_known_value is not None: + return typ.last_known_value + return typ diff --git a/test-data/unit/check-kwargs-unpack-typevar.test b/test-data/unit/check-kwargs-unpack-typevar.test new file mode 100644 index 0000000000000..f26803795bde0 --- /dev/null +++ b/test-data/unit/check-kwargs-unpack-typevar.test @@ -0,0 +1,266 @@ +[case testUnpackTypeVarKwargsBasicAccepted] +# flags: --python-version 3.12 +# Test that TypeVar with TypedDict bound is accepted in **kwargs +from typing import TypedDict, Unpack + +class BaseTypedDict(TypedDict): + pass + +def f[K: BaseTypedDict](**kwargs: Unpack[K]) -> K: + return kwargs + +[builtins fixtures/dict.pyi] +[typing fixtures/typing-full.pyi] + +[case testUnpackTypeVarKwargsInvalidBoundInt] +from typing import TypeVar, Unpack + +T = TypeVar('T', bound=int) + +def f(**kwargs: Unpack[T]) -> None: # E: Unpack item in ** parameter must be a TypedDict or a TypeVar with TypedDict bound + pass + +[builtins fixtures/dict.pyi] +[typing fixtures/typing-full.pyi] + +[case testUnpackTypeVarKwargsNoBound] +from typing import TypeVar, Unpack + +T = TypeVar('T') + +def f(**kwargs: Unpack[T]) -> None: # E: Unpack item in ** parameter must be a TypedDict or a TypeVar with TypedDict bound + pass + +[builtins fixtures/dict.pyi] +[typing fixtures/typing-full.pyi] + +[case testUnpackTypeVarKwargsConcreteTypedDictStillWorks] +# Test that concrete TypedDict still works as before +from typing import TypedDict, Unpack + +class TD(TypedDict): + x: int + y: str + +def f(**kwargs: Unpack[TD]) -> None: + pass + +f(x=1, y="hello") +f(x=1) # E: Missing named argument "y" for "f" + +[builtins fixtures/dict.pyi] +[typing fixtures/typing-full.pyi] + +[case testUnpackTypeVarKwargsInferBasic] +# flags: --python-version 3.12 +# Test that kwargs TypeVar inference works +from typing import TypedDict, Unpack + +class BaseTypedDict(TypedDict): + pass + +def f[K: BaseTypedDict](**kwargs: Unpack[K]) -> K: + return kwargs + +result = f(x=1, y="hello") +reveal_type(result) # N: Revealed type is "TypedDict('__main__.BaseTypedDict', {'x': Literal[1], 'y': Literal['hello']})" + +[builtins fixtures/dict.pyi] +[typing fixtures/typing-full.pyi] + +[case testUnpackTypeVarKwargsInferEmpty] +# flags: --python-version 3.12 +# Test empty kwargs infers empty TypedDict +from typing import TypedDict, Unpack + +class BaseTypedDict(TypedDict): + pass + +def f[K: BaseTypedDict](**kwargs: Unpack[K]) -> K: + return kwargs + +result = f() +reveal_type(result) # N: Revealed type is "TypedDict('__main__.BaseTypedDict', {})" + +[builtins fixtures/dict.pyi] +[typing fixtures/typing-full.pyi] + +[case testUnpackTypeVarKwargsWithPositionalParam] +# flags: --python-version 3.12 +# Test with positional parameter +from typing import TypedDict, Unpack + +class BaseTypedDict(TypedDict): + pass + +def g[K: BaseTypedDict](a: int, **kwargs: Unpack[K]) -> tuple[int, K]: + return (a, kwargs) + +result = g(1, name="test", count=42) +reveal_type(result) # N: Revealed type is "tuple[builtins.int, TypedDict('__main__.BaseTypedDict', {'name': Literal['test'], 'count': Literal[42]})]" + +[builtins fixtures/dict.pyi] +[typing fixtures/typing-full.pyi] + +[case testUnpackTypeVarKwargsNotGoingToKwargs] +# flags: --python-version 3.12 +# Test that explicit keyword params don't go to kwargs TypeVar +from typing import TypedDict, Unpack + +class BaseTypedDict(TypedDict): + pass + +def h[K: BaseTypedDict](*, required: str, **kwargs: Unpack[K]) -> K: + return kwargs + +# 'required' goes to explicit param, only 'extra' goes to kwargs +result = h(required="yes", extra=42) +reveal_type(result) # N: Revealed type is "TypedDict('__main__.BaseTypedDict', {'extra': Literal[42]})" + +# Only explicit params, no extra kwargs +result2 = h(required="yes") +reveal_type(result2) # N: Revealed type is "TypedDict('__main__.BaseTypedDict', {})" + +[builtins fixtures/dict.pyi] +[typing fixtures/typing-full.pyi] + +[case testUnpackTypeVarKwargsBoundWithRequiredFields] +# flags: --python-version 3.12 +# Test that bound TypedDict fields are required +from typing import TypedDict, Unpack + +class BaseTD(TypedDict): + x: int + +def f[K: BaseTD](**kwargs: Unpack[K]) -> K: + return kwargs + +# Missing required field 'x' from the bound - inferred TypedDict doesn't satisfy bound +f() # E: Value of type variable "K" of "f" cannot be "BaseTD" +f(y="hello") # E: Value of type variable "K" of "f" cannot be "BaseTD" + +# Providing 'x' satisfies the bound +result1 = f(x=1) +reveal_type(result1) # N: Revealed type is "TypedDict('__main__.BaseTD', {'x': builtins.int})" + +# Extra fields are allowed and inferred +result2 = f(x=1, y="hello", z=True) +reveal_type(result2) # N: Revealed type is "TypedDict('__main__.BaseTD', {'x': builtins.int, 'y': Literal['hello'], 'z': Literal[True]})" + +[builtins fixtures/dict.pyi] +[typing fixtures/typing-full.pyi] + +[case testUnpackTypeVarKwargsBoundWithNotRequired1] +# flags: --python-version 3.12 +# Test that NotRequired fields from bound can be omitted +from typing import TypedDict, NotRequired, Unpack + +class BaseTDWithOptional(TypedDict): + x: int + y: NotRequired[str] + +def g[K: BaseTDWithOptional](**kwargs: Unpack[K]) -> K: + return kwargs + +# Can omit NotRequired field 'y' +result1 = g(x=1) +reveal_type(result1) # N: Revealed type is "TypedDict('__main__.BaseTDWithOptional', {'x': builtins.int, 'y'?: builtins.str})" + +# Can provide NotRequired field 'y' +result2 = g(x=1, y="hello") +reveal_type(result2) # N: Revealed type is "TypedDict('__main__.BaseTDWithOptional', {'x': builtins.int, 'y'?: builtins.str})" + +# Still need required field 'x' +g(y="hello") # E: Value of type variable "K" of "g" cannot be "BaseTDWithOptional" + +[builtins fixtures/dict.pyi] +[typing fixtures/typing-full.pyi] + +[case testUnpackTypeVarKwargsBoundWithNotRequired2] +# flags: --python-version 3.12 +# Test that NotRequired fields from bound can be omitted +from typing import TypedDict, NotRequired, ReadOnly, Unpack + +class BaseTDWithOptional(TypedDict): + x: ReadOnly[int] + y: ReadOnly[NotRequired[str]] + +def g[K: BaseTDWithOptional](**kwargs: Unpack[K]) -> K: + return kwargs + +# Can omit NotRequired field 'y' +result1 = g(x=1) +reveal_type(result1) # N: Revealed type is "TypedDict('__main__.BaseTDWithOptional', {'x'=: Literal[1], 'y'?=: Never})" + +# Can provide NotRequired field 'y' +result2 = g(x=1, y="hello") +reveal_type(result2) # N: Revealed type is "TypedDict('__main__.BaseTDWithOptional', {'x'=: Literal[1], 'y'?=: Literal['hello']})" + +# Still need required field 'x' +g(y="hello") # E: Value of type variable "K" of "g" cannot be "BaseTDWithOptional" + +[builtins fixtures/dict.pyi] +[typing fixtures/typing-full.pyi] + + +[case testUnpackTypeVarKwargsBasicMixed] +# flags: --python-version 3.12 +# Test that TypeVar with TypedDict bound is accepted in **kwargs +from typing import TypedDict, Unpack + +class BaseTypedDict(TypedDict): + pass + +class Args(TypedDict): + x: int + y: str + + +def f[K: BaseTypedDict](x: int, **kwargs: Unpack[K]) -> K: + return kwargs + + +kwargs: Args +reveal_type(f(**kwargs)) # N: Revealed type is "TypedDict('__main__.BaseTypedDict', {'y': builtins.str})" + +[builtins fixtures/dict.pyi] +[typing fixtures/typing-full.pyi] + + +[case testUnpackTypeVarKwargsInitField] +# flags: --python-version 3.12 +# Test that TypeVar with TypedDict bound is accepted in **kwargs +from typing import TypedDict, Unpack + +class BaseTypedDict(TypedDict): + pass + +class Args(TypedDict): + x: int + + +class InitField[KwargDict: BaseTypedDict]: + def __init__(self, **kwargs: Unpack[KwargDict]) -> None: + ... + + +class Field[KwargDict: Args](InitField[KwargDict]): + pass + +# XXX: mypy produces instances with last_known_values displayed with +# ?s if not assigned to a value?? +# Though, +# TODO: Do this on purpose?? +x = InitField(x=10, y='lol') +reveal_type(x) # N: Revealed type is "__main__.InitField[TypedDict('__main__.BaseTypedDict', {'x': Literal[10], 'y': Literal['lol']})]" + +a = Field(x=10, y='lol') +reveal_type(a) # N: Revealed type is "__main__.Field[TypedDict('__main__.Args', {'x': builtins.int, 'y': Literal['lol']})]" + +# TODO: These error messages are terrible and also wrong +Field(y='lol') # E: Value of type variable "KwargDict" of "Field" cannot be "Args" +Field(x='asdf') # E: Value of type variable "KwargDict" of "Field" cannot be "Args" + + +[builtins fixtures/dict.pyi] +[typing fixtures/typing-full.pyi] diff --git a/test-data/unit/check-typevar-tuple.test b/test-data/unit/check-typevar-tuple.test index 703653227e200..455e9a98ed825 100644 --- a/test-data/unit/check-typevar-tuple.test +++ b/test-data/unit/check-typevar-tuple.test @@ -2191,7 +2191,7 @@ g(1, 2, 3) # E: Missing named argument "a" for "g" \ def bad( *args: Unpack[Keywords], # E: "Keywords" cannot be unpacked (must be tuple or TypeVarTuple) - **kwargs: Unpack[Ints], # E: Unpack item in ** parameter must be a TypedDict + **kwargs: Unpack[Ints], # E: Unpack item in ** parameter must be a TypedDict or a TypeVar with TypedDict bound ) -> None: ... reveal_type(bad) # N: Revealed type is "def (*args: Any, **kwargs: Any)" @@ -2199,7 +2199,7 @@ def bad2( one: int, *args: Unpack[Keywords], # E: "Keywords" cannot be unpacked (must be tuple or TypeVarTuple) other: str = "no", - **kwargs: Unpack[Ints], # E: Unpack item in ** parameter must be a TypedDict + **kwargs: Unpack[Ints], # E: Unpack item in ** parameter must be a TypedDict or a TypeVar with TypedDict bound ) -> None: ... reveal_type(bad2) # N: Revealed type is "def (one: builtins.int, *args: Any, other: builtins.str =, **kwargs: Any)" [builtins fixtures/dict.pyi] diff --git a/test-data/unit/check-varargs.test b/test-data/unit/check-varargs.test index 172e57cf1a4b3..89b88e8167b53 100644 --- a/test-data/unit/check-varargs.test +++ b/test-data/unit/check-varargs.test @@ -792,7 +792,7 @@ def baz(**kwargs: Unpack[Person]) -> None: # OK [case testUnpackWithoutTypedDict] from typing_extensions import Unpack -def foo(**kwargs: Unpack[dict]) -> None: # E: Unpack item in ** parameter must be a TypedDict +def foo(**kwargs: Unpack[dict]) -> None: # E: Unpack item in ** parameter must be a TypedDict or a TypeVar with TypedDict bound ... [builtins fixtures/dict.pyi] diff --git a/test-data/unit/fixtures/typing-full.pyi b/test-data/unit/fixtures/typing-full.pyi index 59e2f9de9929d..886bad7312a0d 100644 --- a/test-data/unit/fixtures/typing-full.pyi +++ b/test-data/unit/fixtures/typing-full.pyi @@ -38,6 +38,9 @@ TypedDict = 0 TypeGuard = 0 NoReturn = 0 NewType = 0 +Required = 0 +NotRequired = 0 +ReadOnly = 0 Self = 0 Unpack = 0 Callable: _SpecialForm diff --git a/test-data/unit/semanal-errors.test b/test-data/unit/semanal-errors.test index 40db0537c413e..3459774395fa6 100644 --- a/test-data/unit/semanal-errors.test +++ b/test-data/unit/semanal-errors.test @@ -1471,7 +1471,7 @@ class Variadic(Generic[Unpack[TVariadic], Unpack[TVariadic2]]): # E: Can only u def bad_args(*args: TVariadic): # E: TypeVarTuple "TVariadic" is only valid with an unpack pass -def bad_kwargs(**kwargs: Unpack[TVariadic]): # E: Unpack item in ** parameter must be a TypedDict +def bad_kwargs(**kwargs: Unpack[TVariadic]): # E: Unpack item in ** parameter must be a TypedDict or a TypeVar with TypedDict bound pass [builtins fixtures/dict.pyi] From ce8cf3334f7d8d6738b999e1dbbec6a9f2202d7e Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Wed, 4 Mar 2026 17:59:06 -0800 Subject: [PATCH 002/161] kwargs Unpack: handle unpacking inside check_arguments --- mypy/checkexpr.py | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/mypy/checkexpr.py b/mypy/checkexpr.py index 159cb6303dcc9..c276329bc02f0 100644 --- a/mypy/checkexpr.py +++ b/mypy/checkexpr.py @@ -1819,7 +1819,14 @@ def check_callable_call( ) self.check_argument_types( - arg_types, arg_kinds, args, callee, formal_to_actual, context, object_type=object_type + arg_types, + arg_kinds, + args, + callee, + formal_to_actual, + context, + object_type=object_type, + arg_names=arg_names, ) if ( @@ -2528,6 +2535,7 @@ def check_argument_types( context: Context, check_arg: ArgChecker | None = None, object_type: Type | None = None, + arg_names: Sequence[str | None] | None = None, ) -> None: """Check argument types against a callable type. @@ -2607,6 +2615,22 @@ def check_argument_types( elif isinstance(unpacked_type, TypeVarTupleType): callee_arg_types = [orig_callee_arg_type] callee_arg_kinds = [ARG_STAR] + elif isinstance(unpacked_type, TypedDictType): + # Unpack[TypedDict] for **kwargs — each kwarg gets its + # corresponding item type from the TypedDict. + # + # TODO: This I think is duplicating some + # handling of something somewhere. + # TODO: special handling of kwargs + callee_arg_types = list[Type]() + callee_arg_kinds = list[ArgKind]() + for a in actuals: + name = arg_names[a] if arg_names else None + if name is not None and name in unpacked_type.items: + callee_arg_types.append(unpacked_type.items[name]) + else: + callee_arg_types.append(orig_callee_arg_type) + callee_arg_kinds.append(ARG_NAMED) else: assert isinstance(unpacked_type, Instance) assert unpacked_type.type.fullname == "builtins.tuple" @@ -3291,6 +3315,7 @@ def check_arg( formal_to_actual, context=context, check_arg=check_arg, + arg_names=arg_names, ) return True except Finished: From f406e7aa479cdd614a1628b987db2f90291e59e4 Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Wed, 21 Jan 2026 22:27:40 -0800 Subject: [PATCH 003/161] Add .envrc to .gitignore --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index 9c325f3e29f8a..7693576a1c1b2 100644 --- a/.gitignore +++ b/.gitignore @@ -60,3 +60,5 @@ test_capi test_capi /mypyc/lib-rt/build/ /mypyc/lib-rt/*.so + +.envrc From 92629a91c704be9fd5a76bb2c1d7567c5bf51ea8 Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Tue, 27 Jan 2026 17:21:24 -0800 Subject: [PATCH 004/161] Add CLAUDE.md for Claude Code guidance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Documents common commands for testing/linting, architecture overview covering the parsing → semantic analysis → type checking pipeline, key data structures, and test data format. Co-Authored-By: Claude Opus 4.5 --- AGENTS.md | 103 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ CLAUDE.md | 7 ++++ 2 files changed, 110 insertions(+) create mode 100644 AGENTS.md create mode 100644 CLAUDE.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000000000..76079d151da23 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,103 @@ +This file provides guidance to coding agents, I guess. +Also to humans some probably. + +## Default virtualenv + +The default virtualenv is ``venv``, so most of the commands below +should be run from ``venv/bin`` if available. + + +## Pre-commit + +Always run ``venv/bin/python runtests.py lint self`` before committing +and make sure that it passes. + + +## Common Commands + +### Running Tests +```bash +# Run a single test by name (uses pytest -k matching) +pytest -n0 -k testNewSyntaxBasics + +# Run all tests in a specific test file +pytest mypy/test/testcheck.py::TypeCheckSuite::check-dataclasses.test + +# Run tests matching a pattern +pytest -q -k "MethodCall" + +# Run the full test suite (slow) +python runtests.py + +# Run with debugging (disables parallelization) +pytest -n0 --pdb -k testName +``` + +### Linting and Type Checking +```bash +# Run formatters and linters +python runtests.py lint + +# Type check mypy's own code +python -m mypy --config-file mypy_self_check.ini -p mypy +``` + +### Manual Testing +```bash +# Run mypy directly on a file +python -m mypy PROGRAM.py + +# Run mypy on a module +python -m mypy -m MODULE +``` + +## Architecture Overview + +Mypy is a static type checker that processes Python code through multiple passes: + +### Core Pipeline +1. **Parsing** (`fastparse.py`) - Converts source to AST using Python's `ast` module +2. **Semantic Analysis** (`semanal.py`, `semanal_main.py`) - Resolves names, builds symbol tables, analyzes imports +3. **Type Checking** (`checker.py`, `checkexpr.py`) - Verifies type correctness + +### Key Data Structures + +**AST Nodes** (`nodes.py`): +- `MypyFile` - Root of a parsed module +- `FuncDef`, `ClassDef` - Function/class definitions +- `TypeInfo` - Metadata about classes (bases, MRO, members) +- `SymbolTable`, `SymbolTableNode` - Name resolution + +**Types** (`types.py`): +- `Type` - Base class for all types +- `ProperType` - Concrete types (Instance, CallableType, TupleType, UnionType, etc.) +- `TypeAliasType` - Type aliases that expand to proper types +- `get_proper_type()` - Expands type aliases to proper types + +### Type Operations +- `subtypes.py` - Subtype checking (`is_subtype()`) +- `meet.py`, `join.py` - Type meets (intersection) and joins (union) +- `expandtype.py` - Type variable substitution +- `typeops.py` - Type utilities and transformations + +### Build System +- `build.py` - Orchestrates the entire type checking process +- `State` - Represents a module being processed +- Handles incremental checking and caching + +## Test Data Format + +Tests in `test-data/unit/check-*.test` use a declarative format: +``` +[case testName] +# flags: --some-flag +x: int = "wrong" # E: Incompatible types... + +[builtins fixtures/tuple.pyi] +[typing fixtures/typing-full.pyi] +``` + +- `# E:` marks expected errors, `# N:` for notes, `# W:` for warnings +- `[builtins fixtures/...]` specifies stub files for builtins +- `[typing fixtures/typing-full.pyi]` uses extended typing stubs +- Tests use minimal stubs by default; define needed classes in test or use fixtures diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000000000..e195f119f88cf --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,7 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +Claude ought to support AGENTS.md but doesn't seem to yet. + +@AGENTS.md From 3ef180510781e214f592e6227bdf4885017c214f Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Thu, 22 Jan 2026 14:38:31 -0800 Subject: [PATCH 005/161] Call analyze_type_expr when checking a python_3_12 alias --- mypy/semanal.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/mypy/semanal.py b/mypy/semanal.py index 8c7703af7c0db..9bc6d00446388 100644 --- a/mypy/semanal.py +++ b/mypy/semanal.py @@ -4043,8 +4043,7 @@ def analyze_alias( tvar_defs = self.tvar_defs_from_tvars(alias_type_vars, typ) if python_3_12_type_alias: - with self.allow_unbound_tvars_set(): - rvalue.accept(self) + self.analyze_type_expr(rvalue) analyzed, depends_on = analyze_type_alias( typ, From a51d2fe859bce917546f27e9732cbfcc5c173791 Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Tue, 20 Jan 2026 11:26:28 -0800 Subject: [PATCH 006/161] [typemap] Add implementation plan for type-level computation PEP Draft a comprehensive plan for implementing the type-level computation proposal (conditional types, type operators, NewProtocol, etc.) covering new type classes, evaluation engine, and integration points. Co-Authored-By: Claude Opus 4.5 --- TYPEMAP_IMPLEMENTATION_PLAN.md | 1003 ++++++++++++++++++++++++++++++++ 1 file changed, 1003 insertions(+) create mode 100644 TYPEMAP_IMPLEMENTATION_PLAN.md diff --git a/TYPEMAP_IMPLEMENTATION_PLAN.md b/TYPEMAP_IMPLEMENTATION_PLAN.md new file mode 100644 index 0000000000000..1b7d1f216d1a2 --- /dev/null +++ b/TYPEMAP_IMPLEMENTATION_PLAN.md @@ -0,0 +1,1003 @@ +# Implementation Plan: Type-Level Computation for Mypy + +This document outlines a plan for implementing the type-level computation proposal described in `../typemap/pre-pep.rst` and `../typemap/spec-draft.rst`. + +## Overview + +The proposal introduces TypeScript-inspired type-level introspection and construction facilities: + +1. **Type Operators**: `GetArg`, `GetArgs`, `FromUnion`, `Sub` (subtype check) +2. **Conditional Types**: `X if Sub[T, Base] else Y` +3. **Type-Level Iteration**: `*[... for t in Iter[...]]` +4. **Object Inspection**: `Members`, `Attrs`, `Member`, `NewProtocol`, `NewTypedDict` +5. **Callable Extension**: `Param` type with qualifiers for extended callable syntax +6. **String Operations**: `Slice`, `Concat`, `Uppercase`, `Lowercase`, etc. +7. **Annotated Operations**: `GetAnnotations`, `DropAnnotations` +8. **TypedDict `**kwargs` Inference**: `Unpack[K]` where K is a TypeVar bounded by TypedDict + +--- + +## Phase 1: Foundation Types and Infrastructure + +### 1.1 Add Core Type Classes (`mypy/types.py`) + +#### New Type Classes + +```python +class MemberType(ProperType): + """Represents Member[Name, Type, Quals, Init, Definer]""" + name: Type # Literal string type + member_type: Type + quals: Type # Literal union of qualifiers + init: Type # Literal type of initializer + definer: Type # The class that defined this member + +class ParamType(ProperType): + """Represents Param[Name, Type, Quals] for extended callables""" + name: Type # Literal string or None + param_type: Type + quals: Type # Literal union of param qualifiers + +class ConditionalType(ProperType): + """Represents `TrueType if Condition else FalseType`""" + condition: TypeCondition + true_type: Type + false_type: Type + +class TypeCondition: + """Base class for type-level boolean conditions""" + pass + +class SubtypeCondition(TypeCondition): + """Represents Sub[T, Base] - a subtype check""" + left: Type + right: Type + +class NotCondition(TypeCondition): + """Represents `not `""" + inner: TypeCondition + +class AndCondition(TypeCondition): + """Represents `cond1 and cond2`""" + left: TypeCondition + right: TypeCondition + +class OrCondition(TypeCondition): + """Represents `cond1 or cond2`""" + left: TypeCondition + right: TypeCondition + +class TypeForComprehension(ProperType): + """Represents *[Expr for var in Iter[T] if Cond]""" + element_expr: Type + iter_var: str + iter_type: Type # The type being iterated + conditions: list[TypeCondition] + +class TypeOperatorType(ProperType): + """Base class for type operators like GetArg, GetAttr, etc.""" + pass + +class GetArgType(TypeOperatorType): + """Represents GetArg[T, Base, Idx]""" + target: Type + base: Type + index: Type # Literal int + +class GetArgsType(TypeOperatorType): + """Represents GetArgs[T, Base]""" + target: Type + base: Type + +class GetAttrType(TypeOperatorType): + """Represents GetAttr[T, AttrName]""" + target: Type + attr_name: Type # Literal string + +class MembersType(TypeOperatorType): + """Represents Members[T]""" + target: Type + +class AttrsType(TypeOperatorType): + """Represents Attrs[T] - annotated attributes only""" + target: Type + +class FromUnionType(TypeOperatorType): + """Represents FromUnion[T]""" + target: Type + +class NewProtocolType(TypeOperatorType): + """Represents NewProtocol[*Members]""" + members: list[Type] + +class NewTypedDictType(TypeOperatorType): + """Represents NewTypedDict[*Members]""" + members: list[Type] + +class IterType(ProperType): + """Represents Iter[T] - marks a type for iteration""" + inner: Type +``` + +#### String Operation Types + +```python +class SliceType(TypeOperatorType): + """Represents Slice[S, Start, End]""" + target: Type # Literal string + start: Type # Literal int or None + end: Type # Literal int or None + +class ConcatType(TypeOperatorType): + """Represents Concat[S1, S2]""" + left: Type + right: Type + +class StringCaseType(TypeOperatorType): + """Base for Uppercase, Lowercase, Capitalize, Uncapitalize""" + target: Type + operation: str # 'upper', 'lower', 'capitalize', 'uncapitalize' +``` + +#### Annotated Operations + +```python +class GetAnnotationsType(TypeOperatorType): + """Represents GetAnnotations[T]""" + target: Type + +class DropAnnotationsType(TypeOperatorType): + """Represents DropAnnotations[T]""" + target: Type +``` + +### 1.2 Update Type Visitor (`mypy/type_visitor.py`) + +Add visitor methods for each new type: + +```python +class TypeVisitor(Generic[T]): + # ... existing methods ... + + def visit_member_type(self, t: MemberType) -> T: ... + def visit_param_type(self, t: ParamType) -> T: ... + def visit_conditional_type(self, t: ConditionalType) -> T: ... + def visit_type_for_comprehension(self, t: TypeForComprehension) -> T: ... + def visit_get_arg_type(self, t: GetArgType) -> T: ... + def visit_get_args_type(self, t: GetArgsType) -> T: ... + def visit_get_attr_type(self, t: GetAttrType) -> T: ... + def visit_members_type(self, t: MembersType) -> T: ... + def visit_attrs_type(self, t: AttrsType) -> T: ... + def visit_from_union_type(self, t: FromUnionType) -> T: ... + def visit_new_protocol_type(self, t: NewProtocolType) -> T: ... + def visit_new_typed_dict_type(self, t: NewTypedDictType) -> T: ... + def visit_iter_type(self, t: IterType) -> T: ... + def visit_slice_type(self, t: SliceType) -> T: ... + def visit_concat_type(self, t: ConcatType) -> T: ... + def visit_string_case_type(self, t: StringCaseType) -> T: ... + def visit_get_annotations_type(self, t: GetAnnotationsType) -> T: ... + def visit_drop_annotations_type(self, t: DropAnnotationsType) -> T: ... +``` + +### 1.3 Register Special Form Names (`mypy/types.py`) + +Add constants for the new special forms: + +```python +TYPE_LEVEL_NAMES: Final = frozenset({ + 'typing.Member', + 'typing.Param', + 'typing.Sub', + 'typing.GetArg', + 'typing.GetArgs', + 'typing.GetAttr', + 'typing.GetName', + 'typing.GetType', + 'typing.GetQuals', + 'typing.GetInit', + 'typing.GetDefiner', + 'typing.Members', + 'typing.Attrs', + 'typing.FromUnion', + 'typing.NewProtocol', + 'typing.NewTypedDict', + 'typing.Iter', + 'typing.Slice', + 'typing.Concat', + 'typing.Uppercase', + 'typing.Lowercase', + 'typing.Capitalize', + 'typing.Uncapitalize', + 'typing.GetAnnotations', + 'typing.DropAnnotations', + 'typing.Length', +}) +``` + +--- + +## Phase 2: Type Analysis (`mypy/typeanal.py`) + +### 2.1 Parse New Special Forms + +Extend `try_analyze_special_unbound_type()` to handle new constructs: + +```python +def try_analyze_special_unbound_type(self, t: UnboundType, fullname: str) -> Type | None: + # ... existing cases ... + + if fullname == 'typing.Member': + return self.analyze_member_type(t) + elif fullname == 'typing.Param': + return self.analyze_param_type(t) + elif fullname == 'typing.Sub': + return self.analyze_sub_condition(t) + elif fullname == 'typing.GetArg': + return self.analyze_get_arg(t) + elif fullname == 'typing.GetArgs': + return self.analyze_get_args(t) + elif fullname == 'typing.GetAttr': + return self.analyze_get_attr(t) + elif fullname in ('typing.GetName', 'typing.GetType', 'typing.GetQuals', + 'typing.GetInit', 'typing.GetDefiner'): + return self.analyze_member_accessor(t, fullname) + elif fullname == 'typing.Members': + return self.analyze_members(t) + elif fullname == 'typing.Attrs': + return self.analyze_attrs(t) + elif fullname == 'typing.FromUnion': + return self.analyze_from_union(t) + elif fullname == 'typing.NewProtocol': + return self.analyze_new_protocol(t) + elif fullname == 'typing.NewTypedDict': + return self.analyze_new_typed_dict(t) + elif fullname == 'typing.Iter': + return self.analyze_iter(t) + elif fullname == 'typing.Slice': + return self.analyze_slice(t) + elif fullname == 'typing.Concat': + return self.analyze_concat(t) + elif fullname in ('typing.Uppercase', 'typing.Lowercase', + 'typing.Capitalize', 'typing.Uncapitalize'): + return self.analyze_string_case(t, fullname) + elif fullname == 'typing.GetAnnotations': + return self.analyze_get_annotations(t) + elif fullname == 'typing.DropAnnotations': + return self.analyze_drop_annotations(t) + elif fullname == 'typing.Length': + return self.analyze_length(t) + + return None +``` + +### 2.2 Parse Conditional Type Syntax + +Need to handle the `X if Cond else Y` syntax in type contexts. This requires modification to how type expressions are parsed. + +**Option A**: Use a special form `Cond[TrueType, Condition, FalseType]` +**Option B**: Extend the parser to handle ternary in type contexts + +For now, pursue Option A as it's less invasive: + +```python +# typing.Cond[TrueType, Sub[T, Base], FalseType] +def analyze_conditional_type(self, t: UnboundType) -> Type: + if len(t.args) != 3: + self.fail('Cond requires 3 arguments', t) + return AnyType(TypeOfAny.from_error) + + true_type = self.anal_type(t.args[0]) + condition = self.analyze_type_condition(t.args[1]) + false_type = self.anal_type(t.args[2]) + + return ConditionalType(condition, true_type, false_type) +``` + +### 2.3 Parse Type Comprehensions + +Handle `*[Expr for var in Iter[T] if Cond]` within type argument lists: + +```python +def analyze_starred_type_comprehension(self, expr: StarExpr) -> TypeForComprehension: + """Analyze *[... for x in Iter[T] if ...]""" + # This requires analyzing the list comprehension expression + # and converting it to a TypeForComprehension + pass +``` + +### 2.4 Extended Callable Parsing + +Modify `analyze_callable_type()` to accept `Param` types in the argument list: + +```python +def analyze_callable_type(self, t: UnboundType) -> Type: + # ... existing logic ... + + # Check if args contain Param types (extended callable) + if self.has_param_types(arg_types): + return self.build_extended_callable(arg_types, ret_type) + + # ... existing logic ... +``` + +--- + +## Phase 3: Type Evaluation Engine (`mypy/typelevel.py` - new file) + +### 3.1 Create Type Evaluator + +A new module for evaluating type-level computations: + +```python +"""Type-level computation evaluation.""" + +from mypy.types import ( + Type, ProperType, Instance, TupleType, UnionType, LiteralType, + TypedDictType, CallableType, NoneType, AnyType, UninhabitedType, + MemberType, ParamType, ConditionalType, TypeForComprehension, + GetArgType, GetArgsType, GetAttrType, MembersType, AttrsType, + FromUnionType, NewProtocolType, NewTypedDictType, IterType, + SliceType, ConcatType, StringCaseType, GetAnnotationsType, + DropAnnotationsType, TypeCondition, SubtypeCondition, +) +from mypy.subtypes import is_subtype +from mypy.typeops import get_proper_type + +class TypeLevelEvaluator: + """Evaluates type-level computations to concrete types.""" + + def __init__(self, api: SemanticAnalyzerInterface): + self.api = api + + def evaluate(self, typ: Type) -> Type: + """Main entry point: evaluate a type to its simplified form.""" + typ = get_proper_type(typ) + + if isinstance(typ, ConditionalType): + return self.eval_conditional(typ) + elif isinstance(typ, TypeForComprehension): + return self.eval_comprehension(typ) + elif isinstance(typ, GetArgType): + return self.eval_get_arg(typ) + elif isinstance(typ, GetArgsType): + return self.eval_get_args(typ) + elif isinstance(typ, GetAttrType): + return self.eval_get_attr(typ) + elif isinstance(typ, MembersType): + return self.eval_members(typ) + elif isinstance(typ, AttrsType): + return self.eval_attrs(typ) + elif isinstance(typ, FromUnionType): + return self.eval_from_union(typ) + elif isinstance(typ, NewProtocolType): + return self.eval_new_protocol(typ) + elif isinstance(typ, NewTypedDictType): + return self.eval_new_typed_dict(typ) + elif isinstance(typ, SliceType): + return self.eval_slice(typ) + elif isinstance(typ, ConcatType): + return self.eval_concat(typ) + elif isinstance(typ, StringCaseType): + return self.eval_string_case(typ) + elif isinstance(typ, GetAnnotationsType): + return self.eval_get_annotations(typ) + elif isinstance(typ, DropAnnotationsType): + return self.eval_drop_annotations(typ) + + return typ # Already a concrete type + + def eval_condition(self, cond: TypeCondition) -> bool | None: + """Evaluate a type condition. Returns None if undecidable.""" + if isinstance(cond, SubtypeCondition): + left = self.evaluate(cond.left) + right = self.evaluate(cond.right) + # Handle type variables - may be undecidable + if self.contains_unresolved_typevar(left) or self.contains_unresolved_typevar(right): + return None + return is_subtype(left, right) + elif isinstance(cond, NotCondition): + inner = self.eval_condition(cond.inner) + return None if inner is None else not inner + elif isinstance(cond, AndCondition): + left = self.eval_condition(cond.left) + right = self.eval_condition(cond.right) + if left is False or right is False: + return False + if left is None or right is None: + return None + return True + elif isinstance(cond, OrCondition): + left = self.eval_condition(cond.left) + right = self.eval_condition(cond.right) + if left is True or right is True: + return True + if left is None or right is None: + return None + return False + return None + + def eval_conditional(self, typ: ConditionalType) -> Type: + """Evaluate X if Cond else Y""" + result = self.eval_condition(typ.condition) + if result is True: + return self.evaluate(typ.true_type) + elif result is False: + return self.evaluate(typ.false_type) + else: + # Undecidable - keep as ConditionalType + return typ + + def eval_get_arg(self, typ: GetArgType) -> Type: + """Evaluate GetArg[T, Base, Idx]""" + target = self.evaluate(typ.target) + base = self.evaluate(typ.base) + idx = self.evaluate(typ.index) + + target = get_proper_type(target) + base = get_proper_type(base) + + # Extract index as int + if not isinstance(idx, LiteralType) or not isinstance(idx.value, int): + return typ # Can't evaluate without literal index + + index = idx.value + + if isinstance(target, Instance) and isinstance(base, Instance): + # Find the type args when target is viewed as base + args = self.get_type_args_for_base(target, base.type) + if args is not None and 0 <= index < len(args): + return args[index] + return UninhabitedType() # Never + + return typ # Can't evaluate + + def eval_get_args(self, typ: GetArgsType) -> Type: + """Evaluate GetArgs[T, Base] -> tuple of args""" + target = self.evaluate(typ.target) + base = self.evaluate(typ.base) + + target = get_proper_type(target) + base = get_proper_type(base) + + if isinstance(target, Instance) and isinstance(base, Instance): + args = self.get_type_args_for_base(target, base.type) + if args is not None: + return TupleType(list(args), self.api.named_type('builtins.tuple')) + return UninhabitedType() + + return typ + + def eval_members(self, typ: MembersType) -> Type: + """Evaluate Members[T] -> tuple of Member types""" + target = self.evaluate(typ.target) + target = get_proper_type(target) + + if isinstance(target, Instance): + members = [] + for name, node in target.type.names.items(): + if node.type is not None: + member = MemberType( + name=LiteralType(name, self.api.named_type('builtins.str')), + member_type=node.type, + quals=self.extract_member_quals(node), + init=self.extract_member_init(node), + definer=Instance(target.type, []) + ) + members.append(member) + return TupleType(members, self.api.named_type('builtins.tuple')) + + return typ + + def eval_attrs(self, typ: AttrsType) -> Type: + """Evaluate Attrs[T] -> tuple of Member types (annotated attrs only)""" + # Similar to members but filters to only annotated attributes + # (excludes methods, class variables without annotations, etc.) + pass + + def eval_from_union(self, typ: FromUnionType) -> Type: + """Evaluate FromUnion[T] -> tuple of union elements""" + target = self.evaluate(typ.target) + target = get_proper_type(target) + + if isinstance(target, UnionType): + return TupleType(list(target.items), self.api.named_type('builtins.tuple')) + else: + # Non-union becomes 1-tuple + return TupleType([target], self.api.named_type('builtins.tuple')) + + def eval_comprehension(self, typ: TypeForComprehension) -> Type: + """Evaluate *[Expr for x in Iter[T] if Cond]""" + iter_type = self.evaluate(typ.iter_type) + iter_type = get_proper_type(iter_type) + + if not isinstance(iter_type, TupleType): + return typ # Can't iterate over non-tuple + + results = [] + for item in iter_type.items: + # Substitute iter_var with item in element_expr + substituted = self.substitute_typevar(typ.element_expr, typ.iter_var, item) + + # Check conditions + all_conditions_true = True + for cond in typ.conditions: + cond_subst = self.substitute_typevar_in_condition(cond, typ.iter_var, item) + result = self.eval_condition(cond_subst) + if result is False: + all_conditions_true = False + break + elif result is None: + # Undecidable - can't fully evaluate + return typ + + if all_conditions_true: + results.append(self.evaluate(substituted)) + + return TupleType(results, self.api.named_type('builtins.tuple')) + + def eval_new_protocol(self, typ: NewProtocolType) -> Type: + """Evaluate NewProtocol[*Members] -> create a new structural type""" + evaluated_members = [self.evaluate(m) for m in typ.members] + + # All members must be MemberType + for m in evaluated_members: + if not isinstance(get_proper_type(m), MemberType): + return typ # Can't evaluate yet + + # Create a new TypeInfo for the protocol + return self.create_protocol_from_members(evaluated_members) + + def eval_new_typed_dict(self, typ: NewTypedDictType) -> Type: + """Evaluate NewTypedDict[*Members] -> create a new TypedDict""" + evaluated_members = [self.evaluate(m) for m in typ.members] + + items = {} + required_keys = set() + + for m in evaluated_members: + m = get_proper_type(m) + if not isinstance(m, MemberType): + return typ # Can't evaluate yet + + name = self.extract_literal_string(m.name) + if name is None: + return typ + + items[name] = m.member_type + # Check quals for Required/NotRequired + if not self.has_not_required_qual(m.quals): + required_keys.add(name) + + return TypedDictType( + items=items, + required_keys=required_keys, + readonly_keys=frozenset(), + fallback=self.api.named_type('typing.TypedDict') + ) + + # String operations + def eval_slice(self, typ: SliceType) -> Type: + """Evaluate Slice[S, Start, End]""" + target = self.evaluate(typ.target) + start = self.evaluate(typ.start) + end = self.evaluate(typ.end) + + s = self.extract_literal_string(target) + start_val = self.extract_literal_int_or_none(start) + end_val = self.extract_literal_int_or_none(end) + + if s is not None and start_val is not ... and end_val is not ...: + result = s[start_val:end_val] + return LiteralType(result, self.api.named_type('builtins.str')) + + return typ + + def eval_concat(self, typ: ConcatType) -> Type: + """Evaluate Concat[S1, S2]""" + left = self.extract_literal_string(self.evaluate(typ.left)) + right = self.extract_literal_string(self.evaluate(typ.right)) + + if left is not None and right is not None: + return LiteralType(left + right, self.api.named_type('builtins.str')) + + return typ + + def eval_string_case(self, typ: StringCaseType) -> Type: + """Evaluate Uppercase, Lowercase, Capitalize, Uncapitalize""" + target = self.extract_literal_string(self.evaluate(typ.target)) + + if target is not None: + if typ.operation == 'upper': + result = target.upper() + elif typ.operation == 'lower': + result = target.lower() + elif typ.operation == 'capitalize': + result = target.capitalize() + elif typ.operation == 'uncapitalize': + result = target[0].lower() + target[1:] if target else target + else: + return typ + return LiteralType(result, self.api.named_type('builtins.str')) + + return typ + + # Helper methods + def get_type_args_for_base(self, instance: Instance, base: TypeInfo) -> list[Type] | None: + """Get type args when viewing instance as base class.""" + # Walk MRO to find base and map type arguments + pass + + def contains_unresolved_typevar(self, typ: Type) -> bool: + """Check if type contains unresolved type variables.""" + pass + + def substitute_typevar(self, typ: Type, var_name: str, replacement: Type) -> Type: + """Substitute a type variable with a concrete type.""" + pass + + def extract_literal_string(self, typ: Type) -> str | None: + """Extract string value from LiteralType.""" + typ = get_proper_type(typ) + if isinstance(typ, LiteralType) and isinstance(typ.value, str): + return typ.value + return None + + def extract_literal_int_or_none(self, typ: Type) -> int | None | ...: + """Extract int or None from LiteralType. Returns ... if not extractable.""" + typ = get_proper_type(typ) + if isinstance(typ, NoneType): + return None + if isinstance(typ, LiteralType) and isinstance(typ.value, int): + return typ.value + return ... # sentinel for "not extractable" + + def create_protocol_from_members(self, members: list[Type]) -> Type: + """Create a new Protocol TypeInfo from Member types.""" + # This needs to create synthetic TypeInfo + pass +``` + +--- + +## Phase 4: Integration Points + +### 4.1 Integrate with Type Alias Expansion (`mypy/types.py`) + +Modify `TypeAliasType._expand_once()` to evaluate type-level computations: + +```python +def _expand_once(self) -> Type: + # ... existing expansion logic ... + + # After substitution, evaluate type-level computations + if self.alias is not None: + result = expand_type(self.alias.target, type_env) + evaluator = TypeLevelEvaluator(...) + result = evaluator.evaluate(result) + return result +``` + +### 4.2 Integrate with `expand_type()` (`mypy/expandtype.py`) + +Extend `ExpandTypeVisitor` to handle new types: + +```python +class ExpandTypeVisitor(TypeTransformVisitor): + # ... existing methods ... + + def visit_conditional_type(self, t: ConditionalType) -> Type: + return ConditionalType( + self.expand_condition(t.condition), + t.true_type.accept(self), + t.false_type.accept(self), + ) + + def visit_type_for_comprehension(self, t: TypeForComprehension) -> Type: + # Don't substitute the iteration variable + return TypeForComprehension( + t.element_expr.accept(self), + t.iter_var, + t.iter_type.accept(self), + [self.expand_condition(c) for c in t.conditions], + ) + + # ... more visit methods for other new types ... +``` + +### 4.3 Subtype Checking (`mypy/subtypes.py`) + +Add subtype rules for new types: + +```python +class SubtypeVisitor(TypeVisitor[bool]): + # ... existing methods ... + + def visit_conditional_type(self, left: ConditionalType) -> bool: + # A conditional type is subtype if both branches are subtypes + # OR if we can evaluate the condition + evaluator = TypeLevelEvaluator(...) + result = evaluator.eval_condition(left.condition) + + if result is True: + return is_subtype(left.true_type, self.right) + elif result is False: + return is_subtype(left.false_type, self.right) + else: + # Must be subtype in both cases + return (is_subtype(left.true_type, self.right) and + is_subtype(left.false_type, self.right)) +``` + +### 4.4 Type Inference with `**kwargs` TypeVar + +Handle `Unpack[K]` where K is bounded by TypedDict: + +In `mypy/checkexpr.py`, extend `check_call_expr_with_callee_type()`: + +```python +def infer_typeddict_from_kwargs( + self, + callee: CallableType, + kwargs: dict[str, Expression], +) -> dict[TypeVarId, Type]: + """Infer TypedDict type from **kwargs when unpacking a TypeVar.""" + # Find if callee has **kwargs: Unpack[K] where K is TypeVar + # Build TypedDict from provided kwargs and their inferred types + pass +``` + +--- + +## Phase 5: Extended Callable Support + +### 5.1 `Param` Type for Callable Arguments + +Support `Callable[[Param[N, T, Q], ...], R]` syntax: + +```python +# In typeanal.py +def build_extended_callable( + self, + params: list[ParamType], + ret_type: Type, +) -> CallableType: + """Build CallableType from Param types.""" + arg_types = [] + arg_kinds = [] + arg_names = [] + + for param in params: + arg_types.append(param.param_type) + arg_names.append(self.extract_param_name(param)) + arg_kinds.append(self.extract_param_kind(param)) + + return CallableType( + arg_types=arg_types, + arg_kinds=arg_kinds, + arg_names=arg_names, + ret_type=ret_type, + fallback=self.api.named_type('builtins.function'), + ) +``` + +### 5.2 Expose Callables as Extended Format + +When introspecting `Callable` via `Members` or similar, expose params as `Param` types: + +```python +def callable_to_param_types(self, callable: CallableType) -> list[ParamType]: + """Convert CallableType to list of ParamType.""" + params = [] + for i, (typ, kind, name) in enumerate(zip( + callable.arg_types, callable.arg_kinds, callable.arg_names + )): + quals = self.kind_to_param_quals(kind) + name_type = LiteralType(name, ...) if name else NoneType() + params.append(ParamType(name_type, typ, quals)) + return params +``` + +--- + +## Phase 6: Annotated Operations + +### 6.1 `GetAnnotations[T]` + +Extract `Annotated` metadata: + +```python +def eval_get_annotations(self, typ: GetAnnotationsType) -> Type: + target = self.evaluate(typ.target) + # Note: This requires changes to how we store Annotated types + # Currently mypy strips annotations - we need to preserve them + + if hasattr(target, '_annotations'): + # Return as union of Literal types + return UnionType.make_union([ + LiteralType(a, ...) for a in target._annotations + ]) + return UninhabitedType() # Never +``` + +### 6.2 Preserve Annotations in Type Representation + +Modify `analyze_annotated_type()` in `typeanal.py` to preserve annotation metadata: + +```python +class AnnotatedType(ProperType): + """Represents Annotated[T, ann1, ann2, ...]""" + inner_type: Type + annotations: tuple[Any, ...] +``` + +--- + +## Phase 7: InitField Support + +### 7.1 `InitField` Type for Field Descriptors + +Support literal type inference for field initializers: + +```python +# In semanal.py, when analyzing class body assignments +def analyze_class_attribute_with_initfield( + self, + name: str, + typ: Type, + init_expr: Expression, +) -> None: + """Handle `attr: T = InitField(...)` patterns.""" + if self.is_initfield_call(init_expr): + # Infer literal types for all kwargs + kwargs_types = self.infer_literal_kwargs(init_expr) + # Store as Member init type + init_type = self.create_initfield_literal_type(kwargs_types) + # ... store in symbol table +``` + +--- + +## Phase 8: Testing Strategy + +### 8.1 Unit Tests + +Create comprehensive tests in `mypy/test/`: + +1. **`test_typelevel_basic.py`** - Basic type operators +2. **`test_typelevel_conditional.py`** - Conditional types +3. **`test_typelevel_comprehension.py`** - Type comprehensions +4. **`test_typelevel_protocol.py`** - NewProtocol creation +5. **`test_typelevel_typeddict.py`** - NewTypedDict creation +6. **`test_typelevel_callable.py`** - Extended callable / Param +7. **`test_typelevel_string.py`** - String operations +8. **`test_typelevel_examples.py`** - Full examples from PEP + +### 8.2 Test Data Files + +Create `.test` files for each feature area: + +``` +test-data/unit/check-typelevel-getarg.test +test-data/unit/check-typelevel-conditional.test +test-data/unit/check-typelevel-members.test +test-data/unit/check-typelevel-newprotocol.test +... +``` + +### 8.3 Integration Tests + +Port examples from the PEP: +- Prisma-style ORM query builder +- FastAPI CRUD model derivation +- Dataclass-style `__init__` generation + +--- + +## Phase 9: Incremental Implementation Order + +### Milestone 1: Core Type Operators (Weeks 1-3) +1. Add `MemberType`, `ParamType` type classes +2. Add `GetArg`, `GetArgs`, `FromUnion` operators +3. Add `Members`, `Attrs` operators +4. Basic type evaluator for these operators +5. Tests for basic operations + +### Milestone 2: Conditional Types (Weeks 4-5) +1. Add `ConditionalType` and condition classes +2. Add `Sub` condition operator +3. Integrate with type evaluator +4. Tests for conditionals + +### Milestone 3: Type Comprehensions (Weeks 6-7) +1. Add `TypeForComprehension`, `IterType` +2. Parser support for comprehension syntax +3. Evaluator support for comprehensions +4. Tests for comprehensions + +### Milestone 4: NewProtocol/NewTypedDict (Weeks 8-10) +1. Add `NewProtocolType`, `NewTypedDictType` +2. Implement synthetic TypeInfo creation +3. Integration with type checking +4. Tests for type construction + +### Milestone 5: Extended Callables (Weeks 11-12) +1. Full `Param` type support +2. Callable introspection +3. Extended callable construction +4. Tests for callables + +### Milestone 6: String Operations (Week 13) +1. Add string operation types +2. Implement string evaluators +3. Tests for string ops + +### Milestone 7: Annotated & InitField (Weeks 14-15) +1. Preserve Annotated metadata +2. GetAnnotations/DropAnnotations +3. InitField support +4. Tests + +### Milestone 8: TypedDict kwargs inference (Week 16) +1. `Unpack[K]` for TypeVar K +2. Inference from kwargs +3. Tests + +### Milestone 9: Integration & Polish (Weeks 17-20) +1. Full PEP examples working +2. Error messages +3. Documentation +4. Performance optimization + +--- + +## Key Design Decisions + +### 1. Lazy vs Eager Evaluation +**Decision**: Lazy evaluation with caching. Type-level computations are evaluated when needed (e.g., during subtype checking) rather than immediately during parsing. + +### 2. Handling Undecidable Conditions +**Decision**: When a condition cannot be evaluated (e.g., involves unbound type variables), preserve the conditional type. It will be evaluated later when more type information is available. + +### 3. Synthetic Type Identity +**Decision**: Types created via `NewProtocol` are structural (protocols), so identity is based on structure, not name. Each creation point may produce a "different" type that is structurally equivalent. + +### 4. Error Handling +**Decision**: Invalid type-level operations (e.g., `GetArg` on non-generic type) return `Never` rather than raising errors, consistent with the spec. + +### 5. Runtime Evaluation +**Decision**: This implementation focuses on static type checking. Runtime evaluation is a separate library concern (as noted in the spec). + +--- + +## Files to Create/Modify + +### New Files +- `mypy/typelevel.py` - Type-level computation evaluator +- `mypy/test/test_typelevel_*.py` - Test files +- `test-data/unit/check-typelevel-*.test` - Test data + +### Modified Files +- `mypy/types.py` - Add new type classes +- `mypy/type_visitor.py` - Add visitor methods +- `mypy/typeanal.py` - Parse new special forms +- `mypy/expandtype.py` - Expand new types +- `mypy/subtypes.py` - Subtype rules +- `mypy/checkexpr.py` - kwargs inference +- `mypy/semanal.py` - InitField handling +- `mypy/nodes.py` - Possibly extend TypeInfo + +--- + +## Open Questions for Discussion + +1. **Syntax for conditionals**: Use `X if Sub[T, Base] else Y` (requires parser changes) or `Cond[X, Sub[T, Base], Y]` (works with existing syntax)? + +2. **Protocol vs TypedDict creation**: Should `NewProtocol` create true protocols (with `is_protocol=True`) or just structural types? + +3. **Type alias recursion**: How to handle recursive type aliases that use type-level computation? + +4. **Error recovery**: What should happen when type-level computation fails? Currently spec says return `Never`. + +5. **Caching strategy**: How aggressively to cache evaluated type-level computations? + +6. **Plugin interaction**: Should plugins be able to define custom type operators? From 877c2328de60918ba208d92445f378a082e4a274 Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Tue, 20 Jan 2026 13:21:22 -0800 Subject: [PATCH 007/161] [typemap] Revise plan: unified TypeOperatorType instead of per-operator classes Key architectural changes: - Single TypeOperatorType class (like TypeAliasType) that references a TypeInfo and contains args, rather than separate classes for each operator - TypeOperatorType is a Type, not ProperType - expanded via get_proper_type() - All type operators declared in typeshed with @_type_operator decorator - Member and Param are regular type operators, not special-cased - Registry-based operator dispatch in the evaluator This approach keeps mypy's core minimal and allows adding new operators in typeshed without modifying mypy's type system code. Co-Authored-By: Claude Opus 4.5 --- TYPEMAP_IMPLEMENTATION_PLAN.md | 1454 ++++++++++++++++++++++---------- 1 file changed, 988 insertions(+), 466 deletions(-) diff --git a/TYPEMAP_IMPLEMENTATION_PLAN.md b/TYPEMAP_IMPLEMENTATION_PLAN.md index 1b7d1f216d1a2..81cc45392e461 100644 --- a/TYPEMAP_IMPLEMENTATION_PLAN.md +++ b/TYPEMAP_IMPLEMENTATION_PLAN.md @@ -19,255 +19,469 @@ The proposal introduces TypeScript-inspired type-level introspection and constru ## Phase 1: Foundation Types and Infrastructure -### 1.1 Add Core Type Classes (`mypy/types.py`) +### 1.1 Core Design: Unified `TypeOperatorType` Class -#### New Type Classes +Rather than creating a separate class for each type operator, we use a single unified +`TypeOperatorType` class modeled after `TypeAliasType` and `Instance`. This approach: -```python -class MemberType(ProperType): - """Represents Member[Name, Type, Quals, Init, Definer]""" - name: Type # Literal string type - member_type: Type - quals: Type # Literal union of qualifiers - init: Type # Literal type of initializer - definer: Type # The class that defined this member - -class ParamType(ProperType): - """Represents Param[Name, Type, Quals] for extended callables""" - name: Type # Literal string or None - param_type: Type - quals: Type # Literal union of param qualifiers - -class ConditionalType(ProperType): - """Represents `TrueType if Condition else FalseType`""" - condition: TypeCondition - true_type: Type - false_type: Type - -class TypeCondition: - """Base class for type-level boolean conditions""" - pass +- Keeps mypy's type system minimal and extensible +- Allows new operators to be added in typeshed without modifying mypy's core +- Treats type operators as "unevaluated" types that expand to concrete types -class SubtypeCondition(TypeCondition): - """Represents Sub[T, Base] - a subtype check""" - left: Type - right: Type - -class NotCondition(TypeCondition): - """Represents `not `""" - inner: TypeCondition - -class AndCondition(TypeCondition): - """Represents `cond1 and cond2`""" - left: TypeCondition - right: TypeCondition - -class OrCondition(TypeCondition): - """Represents `cond1 or cond2`""" - left: TypeCondition - right: TypeCondition - -class TypeForComprehension(ProperType): - """Represents *[Expr for var in Iter[T] if Cond]""" - element_expr: Type - iter_var: str - iter_type: Type # The type being iterated - conditions: list[TypeCondition] - -class TypeOperatorType(ProperType): - """Base class for type operators like GetArg, GetAttr, etc.""" - pass - -class GetArgType(TypeOperatorType): - """Represents GetArg[T, Base, Idx]""" - target: Type - base: Type - index: Type # Literal int - -class GetArgsType(TypeOperatorType): - """Represents GetArgs[T, Base]""" - target: Type - base: Type - -class GetAttrType(TypeOperatorType): - """Represents GetAttr[T, AttrName]""" - target: Type - attr_name: Type # Literal string - -class MembersType(TypeOperatorType): - """Represents Members[T]""" - target: Type - -class AttrsType(TypeOperatorType): - """Represents Attrs[T] - annotated attributes only""" - target: Type - -class FromUnionType(TypeOperatorType): - """Represents FromUnion[T]""" - target: Type - -class NewProtocolType(TypeOperatorType): - """Represents NewProtocol[*Members]""" - members: list[Type] - -class NewTypedDictType(TypeOperatorType): - """Represents NewTypedDict[*Members]""" - members: list[Type] - -class IterType(ProperType): - """Represents Iter[T] - marks a type for iteration""" - inner: Type -``` - -#### String Operation Types +### 1.2 Add `TypeOperatorType` (`mypy/types.py`) ```python -class SliceType(TypeOperatorType): - """Represents Slice[S, Start, End]""" - target: Type # Literal string - start: Type # Literal int or None - end: Type # Literal int or None - -class ConcatType(TypeOperatorType): - """Represents Concat[S1, S2]""" - left: Type - right: Type - -class StringCaseType(TypeOperatorType): - """Base for Uppercase, Lowercase, Capitalize, Uncapitalize""" - target: Type - operation: str # 'upper', 'lower', 'capitalize', 'uncapitalize' +class TypeOperatorType(Type): + """ + Represents an unevaluated type operator application, e.g., GetArg[T, Base, 0]. + + Analogous to TypeAliasType: stores a reference to the operator's TypeInfo + and the type arguments. NOT a ProperType - must be expanded before use + in most type operations. + + Type operators are generic classes in typeshed marked with @_type_operator. + """ + + __slots__ = ("type", "args") + + def __init__( + self, + type: TypeInfo, # The TypeInfo for the operator (e.g., typing.GetArg) + args: list[Type], # The type arguments + line: int = -1, + column: int = -1, + ) -> None: + super().__init__(line, column) + self.type = type + self.args = args + + def accept(self, visitor: TypeVisitor[T]) -> T: + return visitor.visit_type_operator_type(self) + + @property + def fullname(self) -> str: + return self.type.fullname + + def expand(self) -> Type: + """ + Evaluate this type operator to produce a concrete type. + Returns self if evaluation is not yet possible (e.g., contains unresolved type vars). + """ + from mypy.typelevel import evaluate_type_operator + return evaluate_type_operator(self) + + def serialize(self) -> JsonDict: + return { + ".class": "TypeOperatorType", + "type_ref": self.type.fullname, + "args": [a.serialize() for a in self.args], + } + + @classmethod + def deserialize(cls, data: JsonDict) -> TypeOperatorType: + # Similar to TypeAliasType deserialization + ... + + def copy_modified(self, *, args: list[Type] | None = None) -> TypeOperatorType: + return TypeOperatorType( + self.type, + args if args is not None else self.args.copy(), + self.line, + self.column, + ) ``` -#### Annotated Operations +### 1.3 Conditional Types and Comprehensions (`mypy/types.py`) -```python -class GetAnnotationsType(TypeOperatorType): - """Represents GetAnnotations[T]""" - target: Type +These are special syntactic constructs that need their own type classes: -class DropAnnotationsType(TypeOperatorType): - """Represents DropAnnotations[T]""" - target: Type +```python +class ConditionalType(Type): + """ + Represents `TrueType if Sub[T, Base] else FalseType`. + + NOT a ProperType - must be evaluated/expanded before use. + The condition is itself a TypeOperatorType (Sub[...]). + """ + + __slots__ = ("condition", "true_type", "false_type") + + def __init__( + self, + condition: Type, # Should be Sub[T, Base] or boolean combination thereof + true_type: Type, + false_type: Type, + line: int = -1, + column: int = -1, + ) -> None: + super().__init__(line, column) + self.condition = condition + self.true_type = true_type + self.false_type = false_type + + def accept(self, visitor: TypeVisitor[T]) -> T: + return visitor.visit_conditional_type(self) + + def expand(self) -> Type: + """Evaluate the condition and return the appropriate branch.""" + from mypy.typelevel import evaluate_conditional + return evaluate_conditional(self) + + +class TypeForComprehension(Type): + """ + Represents *[Expr for var in Iter[T] if Cond]. + + NOT a ProperType - expands to a tuple of types. + """ + + __slots__ = ("element_expr", "iter_var", "iter_type", "conditions") + + def __init__( + self, + element_expr: Type, + iter_var: str, + iter_type: Type, # The type being iterated (should be a tuple type) + conditions: list[Type], # Each should be Sub[...] or boolean combo + line: int = -1, + column: int = -1, + ) -> None: + super().__init__(line, column) + self.element_expr = element_expr + self.iter_var = iter_var + self.iter_type = iter_type + self.conditions = conditions + + def accept(self, visitor: TypeVisitor[T]) -> T: + return visitor.visit_type_for_comprehension(self) + + def expand(self) -> Type: + """Evaluate the comprehension to produce a tuple type.""" + from mypy.typelevel import evaluate_comprehension + return evaluate_comprehension(self) ``` -### 1.2 Update Type Visitor (`mypy/type_visitor.py`) +### 1.4 Update Type Visitor (`mypy/type_visitor.py`) -Add visitor methods for each new type: +Add visitor methods for the new types: ```python class TypeVisitor(Generic[T]): # ... existing methods ... - def visit_member_type(self, t: MemberType) -> T: ... - def visit_param_type(self, t: ParamType) -> T: ... + def visit_type_operator_type(self, t: TypeOperatorType) -> T: ... def visit_conditional_type(self, t: ConditionalType) -> T: ... def visit_type_for_comprehension(self, t: TypeForComprehension) -> T: ... - def visit_get_arg_type(self, t: GetArgType) -> T: ... - def visit_get_args_type(self, t: GetArgsType) -> T: ... - def visit_get_attr_type(self, t: GetAttrType) -> T: ... - def visit_members_type(self, t: MembersType) -> T: ... - def visit_attrs_type(self, t: AttrsType) -> T: ... - def visit_from_union_type(self, t: FromUnionType) -> T: ... - def visit_new_protocol_type(self, t: NewProtocolType) -> T: ... - def visit_new_typed_dict_type(self, t: NewTypedDictType) -> T: ... - def visit_iter_type(self, t: IterType) -> T: ... - def visit_slice_type(self, t: SliceType) -> T: ... - def visit_concat_type(self, t: ConcatType) -> T: ... - def visit_string_case_type(self, t: StringCaseType) -> T: ... - def visit_get_annotations_type(self, t: GetAnnotationsType) -> T: ... - def visit_drop_annotations_type(self, t: DropAnnotationsType) -> T: ... ``` -### 1.3 Register Special Form Names (`mypy/types.py`) +### 1.5 Declare Type Operators in Typeshed (`mypy/typeshed/stdlib/typing.pyi`) -Add constants for the new special forms: +All type operators are declared as generic classes with the `@_type_operator` decorator. +This decorator marks them for special handling by the type checker. ```python -TYPE_LEVEL_NAMES: Final = frozenset({ - 'typing.Member', - 'typing.Param', - 'typing.Sub', - 'typing.GetArg', - 'typing.GetArgs', - 'typing.GetAttr', - 'typing.GetName', - 'typing.GetType', - 'typing.GetQuals', - 'typing.GetInit', - 'typing.GetDefiner', - 'typing.Members', - 'typing.Attrs', - 'typing.FromUnion', - 'typing.NewProtocol', - 'typing.NewTypedDict', - 'typing.Iter', - 'typing.Slice', - 'typing.Concat', - 'typing.Uppercase', - 'typing.Lowercase', - 'typing.Capitalize', - 'typing.Uncapitalize', - 'typing.GetAnnotations', - 'typing.DropAnnotations', - 'typing.Length', -}) +# In typing.pyi + +def _type_operator(cls: type[T]) -> type[T]: ... + +# --- Data Types (used in type computations) --- + +@_type_operator +class Member(Generic[_Name, _Type, _Quals, _Init, _Definer]): + """ + Represents a class member with name, type, qualifiers, initializer, and definer. + - _Name: Literal[str] - the member name + - _Type: the member's type + - _Quals: Literal['ClassVar'] | Literal['Final'] | Never - qualifiers + - _Init: the literal type of the initializer expression + - _Definer: the class that defined this member + """ + ... + +@_type_operator +class Param(Generic[_Name, _Type, _Quals]): + """ + Represents a function parameter for extended callable syntax. + - _Name: Literal[str] | None - the parameter name + - _Type: the parameter's type + - _Quals: Literal['positional', 'keyword', 'default', '*', '**'] - qualifiers + """ + ... + +# Convenience aliases for Param +type PosParam[N, T] = Param[N, T, Literal["positional"]] +type PosDefaultParam[N, T] = Param[N, T, Literal["positional", "default"]] +type DefaultParam[N, T] = Param[N, T, Literal["default"]] +type NamedParam[N, T] = Param[N, T, Literal["keyword"]] +type NamedDefaultParam[N, T] = Param[N, T, Literal["keyword", "default"]] +type ArgsParam[T] = Param[None, T, Literal["*"]] +type KwargsParam[T] = Param[None, T, Literal["**"]] + +# --- Type Introspection Operators --- + +@_type_operator +class GetArg(Generic[_T, _Base, _Idx]): + """ + Get type argument at index _Idx from _T when viewed as _Base. + Returns Never if _T does not inherit from _Base or index is out of bounds. + """ + ... + +@_type_operator +class GetArgs(Generic[_T, _Base]): + """ + Get all type arguments from _T when viewed as _Base, as a tuple. + Returns Never if _T does not inherit from _Base. + """ + ... + +@_type_operator +class GetAttr(Generic[_T, _Name]): + """ + Get the type of attribute _Name from type _T. + _Name must be a Literal[str]. + """ + ... + +@_type_operator +class Members(Generic[_T]): + """ + Get all members of type _T as a tuple of Member types. + Includes methods, class variables, and instance attributes. + """ + ... + +@_type_operator +class Attrs(Generic[_T]): + """ + Get annotated instance attributes of _T as a tuple of Member types. + Excludes methods and ClassVar members. + """ + ... + +@_type_operator +class FromUnion(Generic[_T]): + """ + Convert a union type to a tuple of its constituent types. + If _T is not a union, returns a 1-tuple containing _T. + """ + ... + +# --- Member/Param Accessors (sugar for GetArg) --- + +@_type_operator +class GetName(Generic[_T]): + """Get the name from a Member or Param. Equivalent to GetArg[_T, Member, 0].""" + ... + +@_type_operator +class GetType(Generic[_T]): + """Get the type from a Member or Param. Equivalent to GetArg[_T, Member, 1].""" + ... + +@_type_operator +class GetQuals(Generic[_T]): + """Get the qualifiers from a Member or Param. Equivalent to GetArg[_T, Member, 2].""" + ... + +@_type_operator +class GetInit(Generic[_T]): + """Get the initializer type from a Member. Equivalent to GetArg[_T, Member, 3].""" + ... + +@_type_operator +class GetDefiner(Generic[_T]): + """Get the defining class from a Member. Equivalent to GetArg[_T, Member, 4].""" + ... + +# --- Type Construction Operators --- + +@_type_operator +class NewProtocol(Generic[Unpack[_Ts]]): + """ + Construct a new structural (protocol) type from Member types. + NewProtocol[Member[...], Member[...], ...] creates an anonymous protocol. + """ + ... + +@_type_operator +class NewTypedDict(Generic[Unpack[_Ts]]): + """ + Construct a new TypedDict from Member types. + NewTypedDict[Member[...], Member[...], ...] creates an anonymous TypedDict. + """ + ... + +# --- Boolean/Conditional Operators --- + +@_type_operator +class Sub(Generic[_T, _Base]): + """ + Type-level subtype check. Evaluates to a type-level boolean. + Used in conditional type expressions: `X if Sub[T, Base] else Y` + """ + ... + +@_type_operator +class Iter(Generic[_T]): + """ + Marks a type for iteration in type comprehensions. + `for x in Iter[T]` iterates over elements of tuple type T. + """ + ... + +# --- String Operations --- + +@_type_operator +class Slice(Generic[_S, _Start, _End]): + """ + Slice a literal string type. + Slice[Literal["hello"], Literal[1], Literal[3]] = Literal["el"] + """ + ... + +@_type_operator +class Concat(Generic[_S1, _S2]): + """ + Concatenate two literal string types. + Concat[Literal["hello"], Literal["world"]] = Literal["helloworld"] + """ + ... + +@_type_operator +class Uppercase(Generic[_S]): + """Convert literal string to uppercase.""" + ... + +@_type_operator +class Lowercase(Generic[_S]): + """Convert literal string to lowercase.""" + ... + +@_type_operator +class Capitalize(Generic[_S]): + """Capitalize first character of literal string.""" + ... + +@_type_operator +class Uncapitalize(Generic[_S]): + """Lowercase first character of literal string.""" + ... + +# --- Annotated Operations --- + +@_type_operator +class GetAnnotations(Generic[_T]): + """ + Extract Annotated metadata from a type. + GetAnnotations[Annotated[int, 'foo', 'bar']] = Literal['foo', 'bar'] + GetAnnotations[int] = Never + """ + ... + +@_type_operator +class DropAnnotations(Generic[_T]): + """ + Strip Annotated wrapper from a type. + DropAnnotations[Annotated[int, 'foo']] = int + DropAnnotations[int] = int + """ + ... + +# --- Utility Operators --- + +@_type_operator +class Length(Generic[_T]): + """ + Get the length of a tuple type as a Literal[int]. + Returns Literal[None] for unbounded tuples. + """ + ... +``` + +### 1.6 Detecting Type Operators (`mypy/nodes.py`) + +Add a flag to TypeInfo to mark type operators: + +```python +class TypeInfo(SymbolNode): + # ... existing fields ... + + is_type_operator: bool = False # True if decorated with @_type_operator +``` + +### 1.7 How Expansion Works + +The key insight is that `TypeOperatorType` is NOT a `ProperType`. Like `TypeAliasType`, +it must be expanded before most type operations can use it. The expansion happens via: + +1. **`get_proper_type()`** in `mypy/typeops.py` - already handles `TypeAliasType`, extend to handle `TypeOperatorType` +2. **Explicit `.expand()` calls** when we need to evaluate + +```python +# In mypy/typeops.py +def get_proper_type(typ: Type) -> ProperType: + while True: + if isinstance(typ, TypeAliasType): + typ = typ._expand_once() + elif isinstance(typ, TypeOperatorType): + typ = typ.expand() + elif isinstance(typ, ConditionalType): + typ = typ.expand() + elif isinstance(typ, TypeForComprehension): + typ = typ.expand() + else: + break + + assert isinstance(typ, ProperType), type(typ) + return typ ``` --- ## Phase 2: Type Analysis (`mypy/typeanal.py`) -### 2.1 Parse New Special Forms +### 2.1 Detect and Construct TypeOperatorType -Extend `try_analyze_special_unbound_type()` to handle new constructs: +Instead of special-casing each operator, we detect classes marked with `@_type_operator` +and construct a generic `TypeOperatorType`: ```python -def try_analyze_special_unbound_type(self, t: UnboundType, fullname: str) -> Type | None: - # ... existing cases ... - - if fullname == 'typing.Member': - return self.analyze_member_type(t) - elif fullname == 'typing.Param': - return self.analyze_param_type(t) - elif fullname == 'typing.Sub': - return self.analyze_sub_condition(t) - elif fullname == 'typing.GetArg': - return self.analyze_get_arg(t) - elif fullname == 'typing.GetArgs': - return self.analyze_get_args(t) - elif fullname == 'typing.GetAttr': - return self.analyze_get_attr(t) - elif fullname in ('typing.GetName', 'typing.GetType', 'typing.GetQuals', - 'typing.GetInit', 'typing.GetDefiner'): - return self.analyze_member_accessor(t, fullname) - elif fullname == 'typing.Members': - return self.analyze_members(t) - elif fullname == 'typing.Attrs': - return self.analyze_attrs(t) - elif fullname == 'typing.FromUnion': - return self.analyze_from_union(t) - elif fullname == 'typing.NewProtocol': - return self.analyze_new_protocol(t) - elif fullname == 'typing.NewTypedDict': - return self.analyze_new_typed_dict(t) - elif fullname == 'typing.Iter': - return self.analyze_iter(t) - elif fullname == 'typing.Slice': - return self.analyze_slice(t) - elif fullname == 'typing.Concat': - return self.analyze_concat(t) - elif fullname in ('typing.Uppercase', 'typing.Lowercase', - 'typing.Capitalize', 'typing.Uncapitalize'): - return self.analyze_string_case(t, fullname) - elif fullname == 'typing.GetAnnotations': - return self.analyze_get_annotations(t) - elif fullname == 'typing.DropAnnotations': - return self.analyze_drop_annotations(t) - elif fullname == 'typing.Length': - return self.analyze_length(t) - - return None +def analyze_unbound_type_nonoptional( + self, t: UnboundType, report_invalid_types: bool +) -> Type: + # ... existing logic to resolve the symbol ... + + node = self.lookup_qualified(t.name, t, ...) + + if isinstance(node, TypeInfo): + # Check if this is a type operator + if node.is_type_operator: + return self.analyze_type_operator(t, node) + + # ... existing instance type handling ... + + # ... rest of existing logic ... + + +def analyze_type_operator(self, t: UnboundType, type_info: TypeInfo) -> Type: + """ + Analyze a type operator application like GetArg[T, Base, 0]. + Returns a TypeOperatorType that will be expanded later. + """ + # Analyze all type arguments + args = [self.anal_type(arg) for arg in t.args] + + # Validate argument count against the operator's type parameters + # (This is optional - could also defer to expansion time) + expected = len(type_info.type_vars) + if len(args) != expected: + self.fail( + f"Type operator {type_info.name} expects {expected} arguments, got {len(args)}", + t + ) + + return TypeOperatorType(type_info, args, line=t.line, column=t.column) ``` ### 2.2 Parse Conditional Type Syntax @@ -326,22 +540,38 @@ def analyze_callable_type(self, t: UnboundType) -> Type: ### 3.1 Create Type Evaluator -A new module for evaluating type-level computations: +A new module for evaluating type-level computations. The evaluator dispatches +based on the operator's fullname rather than using isinstance checks for each +operator type. ```python """Type-level computation evaluation.""" +from __future__ import annotations + +from typing import Callable + from mypy.types import ( Type, ProperType, Instance, TupleType, UnionType, LiteralType, TypedDictType, CallableType, NoneType, AnyType, UninhabitedType, - MemberType, ParamType, ConditionalType, TypeForComprehension, - GetArgType, GetArgsType, GetAttrType, MembersType, AttrsType, - FromUnionType, NewProtocolType, NewTypedDictType, IterType, - SliceType, ConcatType, StringCaseType, GetAnnotationsType, - DropAnnotationsType, TypeCondition, SubtypeCondition, + TypeOperatorType, ConditionalType, TypeForComprehension, ) from mypy.subtypes import is_subtype from mypy.typeops import get_proper_type +from mypy.nodes import TypeInfo + + +# Registry mapping operator fullnames to their evaluation functions +_OPERATOR_EVALUATORS: dict[str, Callable[[TypeLevelEvaluator, TypeOperatorType], Type]] = {} + + +def register_operator(fullname: str): + """Decorator to register an operator evaluator.""" + def decorator(func: Callable[[TypeLevelEvaluator, TypeOperatorType], Type]): + _OPERATOR_EVALUATORS[fullname] = func + return func + return decorator + class TypeLevelEvaluator: """Evaluates type-level computations to concrete types.""" @@ -351,69 +581,40 @@ class TypeLevelEvaluator: def evaluate(self, typ: Type) -> Type: """Main entry point: evaluate a type to its simplified form.""" - typ = get_proper_type(typ) - - if isinstance(typ, ConditionalType): + if isinstance(typ, TypeOperatorType): + return self.eval_operator(typ) + elif isinstance(typ, ConditionalType): return self.eval_conditional(typ) elif isinstance(typ, TypeForComprehension): return self.eval_comprehension(typ) - elif isinstance(typ, GetArgType): - return self.eval_get_arg(typ) - elif isinstance(typ, GetArgsType): - return self.eval_get_args(typ) - elif isinstance(typ, GetAttrType): - return self.eval_get_attr(typ) - elif isinstance(typ, MembersType): - return self.eval_members(typ) - elif isinstance(typ, AttrsType): - return self.eval_attrs(typ) - elif isinstance(typ, FromUnionType): - return self.eval_from_union(typ) - elif isinstance(typ, NewProtocolType): - return self.eval_new_protocol(typ) - elif isinstance(typ, NewTypedDictType): - return self.eval_new_typed_dict(typ) - elif isinstance(typ, SliceType): - return self.eval_slice(typ) - elif isinstance(typ, ConcatType): - return self.eval_concat(typ) - elif isinstance(typ, StringCaseType): - return self.eval_string_case(typ) - elif isinstance(typ, GetAnnotationsType): - return self.eval_get_annotations(typ) - elif isinstance(typ, DropAnnotationsType): - return self.eval_drop_annotations(typ) - - return typ # Already a concrete type - - def eval_condition(self, cond: TypeCondition) -> bool | None: - """Evaluate a type condition. Returns None if undecidable.""" - if isinstance(cond, SubtypeCondition): - left = self.evaluate(cond.left) - right = self.evaluate(cond.right) + + return typ # Already a concrete type or can't be evaluated + + def eval_operator(self, typ: TypeOperatorType) -> Type: + """Evaluate a type operator by dispatching to registered handler.""" + fullname = typ.fullname + evaluator = _OPERATOR_EVALUATORS.get(fullname) + + if evaluator is None: + # Unknown operator - return as-is (might be a data type like Member) + return typ + + return evaluator(self, typ) + + def eval_condition(self, cond: Type) -> bool | None: + """ + Evaluate a type-level condition (Sub[T, Base]). + Returns True/False if decidable, None if undecidable. + """ + if isinstance(cond, TypeOperatorType) and cond.fullname == 'typing.Sub': + left = self.evaluate(cond.args[0]) + right = self.evaluate(cond.args[1]) # Handle type variables - may be undecidable if self.contains_unresolved_typevar(left) or self.contains_unresolved_typevar(right): return None return is_subtype(left, right) - elif isinstance(cond, NotCondition): - inner = self.eval_condition(cond.inner) - return None if inner is None else not inner - elif isinstance(cond, AndCondition): - left = self.eval_condition(cond.left) - right = self.eval_condition(cond.right) - if left is False or right is False: - return False - if left is None or right is None: - return None - return True - elif isinstance(cond, OrCondition): - left = self.eval_condition(cond.left) - right = self.eval_condition(cond.right) - if left is True or right is True: - return True - if left is None or right is None: - return None - return False + + # Could add support for boolean combinations (and, or, not) here return None def eval_conditional(self, typ: ConditionalType) -> Type: @@ -427,87 +628,15 @@ class TypeLevelEvaluator: # Undecidable - keep as ConditionalType return typ - def eval_get_arg(self, typ: GetArgType) -> Type: - """Evaluate GetArg[T, Base, Idx]""" - target = self.evaluate(typ.target) - base = self.evaluate(typ.base) - idx = self.evaluate(typ.index) - - target = get_proper_type(target) - base = get_proper_type(base) - - # Extract index as int - if not isinstance(idx, LiteralType) or not isinstance(idx.value, int): - return typ # Can't evaluate without literal index - - index = idx.value - - if isinstance(target, Instance) and isinstance(base, Instance): - # Find the type args when target is viewed as base - args = self.get_type_args_for_base(target, base.type) - if args is not None and 0 <= index < len(args): - return args[index] - return UninhabitedType() # Never - - return typ # Can't evaluate - - def eval_get_args(self, typ: GetArgsType) -> Type: - """Evaluate GetArgs[T, Base] -> tuple of args""" - target = self.evaluate(typ.target) - base = self.evaluate(typ.base) - - target = get_proper_type(target) - base = get_proper_type(base) - - if isinstance(target, Instance) and isinstance(base, Instance): - args = self.get_type_args_for_base(target, base.type) - if args is not None: - return TupleType(list(args), self.api.named_type('builtins.tuple')) - return UninhabitedType() - - return typ - - def eval_members(self, typ: MembersType) -> Type: - """Evaluate Members[T] -> tuple of Member types""" - target = self.evaluate(typ.target) - target = get_proper_type(target) - - if isinstance(target, Instance): - members = [] - for name, node in target.type.names.items(): - if node.type is not None: - member = MemberType( - name=LiteralType(name, self.api.named_type('builtins.str')), - member_type=node.type, - quals=self.extract_member_quals(node), - init=self.extract_member_init(node), - definer=Instance(target.type, []) - ) - members.append(member) - return TupleType(members, self.api.named_type('builtins.tuple')) - - return typ - - def eval_attrs(self, typ: AttrsType) -> Type: - """Evaluate Attrs[T] -> tuple of Member types (annotated attrs only)""" - # Similar to members but filters to only annotated attributes - # (excludes methods, class variables without annotations, etc.) - pass - - def eval_from_union(self, typ: FromUnionType) -> Type: - """Evaluate FromUnion[T] -> tuple of union elements""" - target = self.evaluate(typ.target) - target = get_proper_type(target) - - if isinstance(target, UnionType): - return TupleType(list(target.items), self.api.named_type('builtins.tuple')) - else: - # Non-union becomes 1-tuple - return TupleType([target], self.api.named_type('builtins.tuple')) - def eval_comprehension(self, typ: TypeForComprehension) -> Type: """Evaluate *[Expr for x in Iter[T] if Cond]""" + # First, evaluate the iter_type to get what we're iterating over iter_type = self.evaluate(typ.iter_type) + + # If it's an Iter[T] operator, extract T + if isinstance(iter_type, TypeOperatorType) and iter_type.fullname == 'typing.Iter': + iter_type = self.evaluate(iter_type.args[0]) + iter_type = get_proper_type(iter_type) if not isinstance(iter_type, TupleType): @@ -521,7 +650,7 @@ class TypeLevelEvaluator: # Check conditions all_conditions_true = True for cond in typ.conditions: - cond_subst = self.substitute_typevar_in_condition(cond, typ.iter_var, item) + cond_subst = self.substitute_typevar(cond, typ.iter_var, item) result = self.eval_condition(cond_subst) if result is False: all_conditions_true = False @@ -535,126 +664,503 @@ class TypeLevelEvaluator: return TupleType(results, self.api.named_type('builtins.tuple')) - def eval_new_protocol(self, typ: NewProtocolType) -> Type: - """Evaluate NewProtocol[*Members] -> create a new structural type""" - evaluated_members = [self.evaluate(m) for m in typ.members] + # --- Helper methods --- - # All members must be MemberType - for m in evaluated_members: - if not isinstance(get_proper_type(m), MemberType): - return typ # Can't evaluate yet + def get_type_args_for_base(self, instance: Instance, base: TypeInfo) -> list[Type] | None: + """Get type args when viewing instance as base class.""" + # Walk MRO to find base and map type arguments + for base_instance in instance.type.mro: + if base_instance == base: + # Found it - now map arguments through inheritance + return self.map_type_args_to_base(instance, base) + return None - # Create a new TypeInfo for the protocol - return self.create_protocol_from_members(evaluated_members) + def map_type_args_to_base(self, instance: Instance, base: TypeInfo) -> list[Type]: + """Map instance's type args through inheritance chain to base.""" + from mypy.expandtype import expand_type_by_instance + # Find the base in the MRO and get its type args + for b in instance.type.bases: + b_proper = get_proper_type(b) + if isinstance(b_proper, Instance) and b_proper.type == base: + return list(expand_type_by_instance(b_proper, instance).args) + return [] - def eval_new_typed_dict(self, typ: NewTypedDictType) -> Type: - """Evaluate NewTypedDict[*Members] -> create a new TypedDict""" - evaluated_members = [self.evaluate(m) for m in typ.members] + def contains_unresolved_typevar(self, typ: Type) -> bool: + """Check if type contains unresolved type variables.""" + from mypy.types import TypeVarType + from mypy.type_visitor import TypeQuery - items = {} - required_keys = set() + class HasTypeVar(TypeQuery[bool]): + def __init__(self): + super().__init__(any) - for m in evaluated_members: - m = get_proper_type(m) - if not isinstance(m, MemberType): - return typ # Can't evaluate yet + def visit_type_var(self, t: TypeVarType) -> bool: + return True - name = self.extract_literal_string(m.name) - if name is None: - return typ + return typ.accept(HasTypeVar()) - items[name] = m.member_type - # Check quals for Required/NotRequired - if not self.has_not_required_qual(m.quals): - required_keys.add(name) + def substitute_typevar(self, typ: Type, var_name: str, replacement: Type) -> Type: + """Substitute a type variable by name with a concrete type.""" + from mypy.type_visitor import TypeTranslator + from mypy.types import TypeVarType - return TypedDictType( - items=items, - required_keys=required_keys, - readonly_keys=frozenset(), - fallback=self.api.named_type('typing.TypedDict') + class SubstituteVar(TypeTranslator): + def visit_type_var(self, t: TypeVarType) -> Type: + if t.name == var_name: + return replacement + return t + + return typ.accept(SubstituteVar()) + + def extract_literal_string(self, typ: Type) -> str | None: + """Extract string value from LiteralType.""" + typ = get_proper_type(typ) + if isinstance(typ, LiteralType) and isinstance(typ.value, str): + return typ.value + return None + + def extract_literal_int(self, typ: Type) -> int | None: + """Extract int value from LiteralType.""" + typ = get_proper_type(typ) + if isinstance(typ, LiteralType) and isinstance(typ.value, int): + return typ.value + return None + + def make_member_type( + self, + name: str, + member_type: Type, + quals: Type, + init: Type, + definer: Type, + ) -> TypeOperatorType: + """Create a Member[...] type operator.""" + member_info = self.api.lookup_qualified('typing.Member', ...) + return TypeOperatorType( + member_info.node, # TypeInfo for Member + [ + LiteralType(name, self.api.named_type('builtins.str')), + member_type, + quals, + init, + definer, + ], ) - # String operations - def eval_slice(self, typ: SliceType) -> Type: - """Evaluate Slice[S, Start, End]""" - target = self.evaluate(typ.target) - start = self.evaluate(typ.start) - end = self.evaluate(typ.end) + def create_protocol_from_members(self, members: list[TypeOperatorType]) -> Type: + """Create a new Protocol TypeInfo from Member type operators.""" + # Extract member info and create synthetic TypeInfo + # This is complex - see Phase 4 for details + pass - s = self.extract_literal_string(target) - start_val = self.extract_literal_int_or_none(start) - end_val = self.extract_literal_int_or_none(end) - if s is not None and start_val is not ... and end_val is not ...: - result = s[start_val:end_val] - return LiteralType(result, self.api.named_type('builtins.str')) +# --- Operator Implementations --- +@register_operator('typing.GetArg') +def eval_get_arg(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: + """Evaluate GetArg[T, Base, Idx]""" + if len(typ.args) != 3: return typ - def eval_concat(self, typ: ConcatType) -> Type: - """Evaluate Concat[S1, S2]""" - left = self.extract_literal_string(self.evaluate(typ.left)) - right = self.extract_literal_string(self.evaluate(typ.right)) + target = evaluator.evaluate(typ.args[0]) + base = evaluator.evaluate(typ.args[1]) + idx = evaluator.evaluate(typ.args[2]) + + target = get_proper_type(target) + base = get_proper_type(base) + + # Extract index as int + index = evaluator.extract_literal_int(idx) + if index is None: + return typ # Can't evaluate without literal index + + if isinstance(target, Instance) and isinstance(base, Instance): + args = evaluator.get_type_args_for_base(target, base.type) + if args is not None and 0 <= index < len(args): + return args[index] + return UninhabitedType() # Never + + # Handle TypeOperatorType targets (e.g., GetArg[Member[...], Member, 1]) + if isinstance(target, TypeOperatorType) and isinstance(base, Instance): + if target.type == base.type: + if 0 <= index < len(target.args): + return target.args[index] + return UninhabitedType() - if left is not None and right is not None: - return LiteralType(left + right, self.api.named_type('builtins.str')) + return typ + +@register_operator('typing.GetArgs') +def eval_get_args(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: + """Evaluate GetArgs[T, Base] -> tuple of args""" + if len(typ.args) != 2: return typ - def eval_string_case(self, typ: StringCaseType) -> Type: - """Evaluate Uppercase, Lowercase, Capitalize, Uncapitalize""" - target = self.extract_literal_string(self.evaluate(typ.target)) - - if target is not None: - if typ.operation == 'upper': - result = target.upper() - elif typ.operation == 'lower': - result = target.lower() - elif typ.operation == 'capitalize': - result = target.capitalize() - elif typ.operation == 'uncapitalize': - result = target[0].lower() + target[1:] if target else target - else: - return typ - return LiteralType(result, self.api.named_type('builtins.str')) + target = evaluator.evaluate(typ.args[0]) + base = evaluator.evaluate(typ.args[1]) + + target = get_proper_type(target) + base = get_proper_type(base) + + if isinstance(target, Instance) and isinstance(base, Instance): + args = evaluator.get_type_args_for_base(target, base.type) + if args is not None: + return TupleType(list(args), evaluator.api.named_type('builtins.tuple')) + return UninhabitedType() + + return typ + +@register_operator('typing.Members') +def eval_members(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: + """Evaluate Members[T] -> tuple of Member types""" + if len(typ.args) != 1: return typ - # Helper methods - def get_type_args_for_base(self, instance: Instance, base: TypeInfo) -> list[Type] | None: - """Get type args when viewing instance as base class.""" - # Walk MRO to find base and map type arguments - pass + target = evaluator.evaluate(typ.args[0]) + target = get_proper_type(target) + + if isinstance(target, Instance): + members = [] + for name, node in target.type.names.items(): + if node.type is not None: + member = evaluator.make_member_type( + name=name, + member_type=node.type, + quals=extract_member_quals(node), + init=extract_member_init(node), + definer=Instance(target.type, []), + ) + members.append(member) + return TupleType(members, evaluator.api.named_type('builtins.tuple')) + + return typ + + +@register_operator('typing.Attrs') +def eval_attrs(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: + """Evaluate Attrs[T] -> tuple of Member types (annotated attrs only)""" + if len(typ.args) != 1: + return typ - def contains_unresolved_typevar(self, typ: Type) -> bool: - """Check if type contains unresolved type variables.""" - pass + target = evaluator.evaluate(typ.args[0]) + target = get_proper_type(target) + + if isinstance(target, Instance): + members = [] + for name, node in target.type.names.items(): + # Filter to annotated instance attributes only + if (node.type is not None and + not node.is_classvar and + not isinstance(node.type, CallableType)): + member = evaluator.make_member_type( + name=name, + member_type=node.type, + quals=extract_member_quals(node), + init=extract_member_init(node), + definer=Instance(target.type, []), + ) + members.append(member) + return TupleType(members, evaluator.api.named_type('builtins.tuple')) + + return typ + + +@register_operator('typing.FromUnion') +def eval_from_union(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: + """Evaluate FromUnion[T] -> tuple of union elements""" + if len(typ.args) != 1: + return typ - def substitute_typevar(self, typ: Type, var_name: str, replacement: Type) -> Type: - """Substitute a type variable with a concrete type.""" - pass + target = evaluator.evaluate(typ.args[0]) + target = get_proper_type(target) - def extract_literal_string(self, typ: Type) -> str | None: - """Extract string value from LiteralType.""" - typ = get_proper_type(typ) - if isinstance(typ, LiteralType) and isinstance(typ.value, str): - return typ.value - return None + if isinstance(target, UnionType): + return TupleType(list(target.items), evaluator.api.named_type('builtins.tuple')) + else: + # Non-union becomes 1-tuple + return TupleType([target], evaluator.api.named_type('builtins.tuple')) - def extract_literal_int_or_none(self, typ: Type) -> int | None | ...: - """Extract int or None from LiteralType. Returns ... if not extractable.""" - typ = get_proper_type(typ) - if isinstance(typ, NoneType): - return None - if isinstance(typ, LiteralType) and isinstance(typ.value, int): - return typ.value - return ... # sentinel for "not extractable" - def create_protocol_from_members(self, members: list[Type]) -> Type: - """Create a new Protocol TypeInfo from Member types.""" - # This needs to create synthetic TypeInfo - pass +@register_operator('typing.GetAttr') +def eval_get_attr(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: + """Evaluate GetAttr[T, Name]""" + if len(typ.args) != 2: + return typ + + target = evaluator.evaluate(typ.args[0]) + name_type = evaluator.evaluate(typ.args[1]) + + target = get_proper_type(target) + name = evaluator.extract_literal_string(name_type) + + if name is None: + return typ + + if isinstance(target, Instance): + node = target.type.names.get(name) + if node is not None and node.type is not None: + return node.type + return UninhabitedType() + + return typ + + +# --- Member/Param Accessors (sugar for GetArg) --- + +@register_operator('typing.GetName') +def eval_get_name(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: + """GetName[M] = GetArg[M, Member, 0] or GetArg[M, Param, 0]""" + if len(typ.args) != 1: + return typ + + target = evaluator.evaluate(typ.args[0]) + if isinstance(target, TypeOperatorType): + if target.fullname in ('typing.Member', 'typing.Param') and len(target.args) > 0: + return target.args[0] + return UninhabitedType() + + +@register_operator('typing.GetType') +def eval_get_type(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: + """GetType[M] = GetArg[M, Member, 1] or GetArg[M, Param, 1]""" + if len(typ.args) != 1: + return typ + + target = evaluator.evaluate(typ.args[0]) + if isinstance(target, TypeOperatorType): + if target.fullname in ('typing.Member', 'typing.Param') and len(target.args) > 1: + return target.args[1] + return UninhabitedType() + + +@register_operator('typing.GetQuals') +def eval_get_quals(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: + """GetQuals[M] = GetArg[M, Member, 2] or GetArg[M, Param, 2]""" + if len(typ.args) != 1: + return typ + + target = evaluator.evaluate(typ.args[0]) + if isinstance(target, TypeOperatorType): + if target.fullname in ('typing.Member', 'typing.Param') and len(target.args) > 2: + return target.args[2] + return UninhabitedType() + + +@register_operator('typing.GetInit') +def eval_get_init(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: + """GetInit[M] = GetArg[M, Member, 3]""" + if len(typ.args) != 1: + return typ + + target = evaluator.evaluate(typ.args[0]) + if isinstance(target, TypeOperatorType): + if target.fullname == 'typing.Member' and len(target.args) > 3: + return target.args[3] + return UninhabitedType() + + +@register_operator('typing.GetDefiner') +def eval_get_definer(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: + """GetDefiner[M] = GetArg[M, Member, 4]""" + if len(typ.args) != 1: + return typ + + target = evaluator.evaluate(typ.args[0]) + if isinstance(target, TypeOperatorType): + if target.fullname == 'typing.Member' and len(target.args) > 4: + return target.args[4] + return UninhabitedType() + + +# --- String Operations --- + +@register_operator('typing.Slice') +def eval_slice(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: + """Evaluate Slice[S, Start, End]""" + if len(typ.args) != 3: + return typ + + s = evaluator.extract_literal_string(evaluator.evaluate(typ.args[0])) + start = evaluator.extract_literal_int(evaluator.evaluate(typ.args[1])) + end = evaluator.extract_literal_int(evaluator.evaluate(typ.args[2])) + + # Handle None for start/end + start_arg = get_proper_type(evaluator.evaluate(typ.args[1])) + end_arg = get_proper_type(evaluator.evaluate(typ.args[2])) + if isinstance(start_arg, NoneType): + start = None + if isinstance(end_arg, NoneType): + end = None + + if s is not None: + result = s[start:end] + return LiteralType(result, evaluator.api.named_type('builtins.str')) + + return typ + + +@register_operator('typing.Concat') +def eval_concat(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: + """Evaluate Concat[S1, S2]""" + if len(typ.args) != 2: + return typ + + left = evaluator.extract_literal_string(evaluator.evaluate(typ.args[0])) + right = evaluator.extract_literal_string(evaluator.evaluate(typ.args[1])) + + if left is not None and right is not None: + return LiteralType(left + right, evaluator.api.named_type('builtins.str')) + + return typ + + +@register_operator('typing.Uppercase') +def eval_uppercase(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: + if len(typ.args) != 1: + return typ + s = evaluator.extract_literal_string(evaluator.evaluate(typ.args[0])) + if s is not None: + return LiteralType(s.upper(), evaluator.api.named_type('builtins.str')) + return typ + + +@register_operator('typing.Lowercase') +def eval_lowercase(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: + if len(typ.args) != 1: + return typ + s = evaluator.extract_literal_string(evaluator.evaluate(typ.args[0])) + if s is not None: + return LiteralType(s.lower(), evaluator.api.named_type('builtins.str')) + return typ + + +@register_operator('typing.Capitalize') +def eval_capitalize(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: + if len(typ.args) != 1: + return typ + s = evaluator.extract_literal_string(evaluator.evaluate(typ.args[0])) + if s is not None: + return LiteralType(s.capitalize(), evaluator.api.named_type('builtins.str')) + return typ + + +@register_operator('typing.Uncapitalize') +def eval_uncapitalize(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: + if len(typ.args) != 1: + return typ + s = evaluator.extract_literal_string(evaluator.evaluate(typ.args[0])) + if s is not None: + result = s[0].lower() + s[1:] if s else s + return LiteralType(result, evaluator.api.named_type('builtins.str')) + return typ + + +# --- Type Construction --- + +@register_operator('typing.NewProtocol') +def eval_new_protocol(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: + """Evaluate NewProtocol[*Members] -> create a new structural type""" + evaluated_members = [evaluator.evaluate(m) for m in typ.args] + + # All members must be Member type operators + for m in evaluated_members: + if not isinstance(m, TypeOperatorType) or m.fullname != 'typing.Member': + return typ # Can't evaluate yet + + return evaluator.create_protocol_from_members(evaluated_members) + + +@register_operator('typing.NewTypedDict') +def eval_new_typed_dict(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: + """Evaluate NewTypedDict[*Members] -> create a new TypedDict""" + evaluated_members = [evaluator.evaluate(m) for m in typ.args] + + items = {} + required_keys = set() + + for m in evaluated_members: + if not isinstance(m, TypeOperatorType) or m.fullname != 'typing.Member': + return typ # Can't evaluate yet + + name = evaluator.extract_literal_string(m.args[0]) + if name is None: + return typ + + items[name] = m.args[1] # The type + # Check quals (args[2]) for Required/NotRequired + quals = get_proper_type(m.args[2]) if len(m.args) > 2 else UninhabitedType() + if not has_not_required_qual(quals): + required_keys.add(name) + + return TypedDictType( + items=items, + required_keys=required_keys, + readonly_keys=frozenset(), + fallback=evaluator.api.named_type('typing.TypedDict'), + ) + + +@register_operator('typing.Length') +def eval_length(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: + """Evaluate Length[T] -> Literal[int] for tuple length""" + if len(typ.args) != 1: + return typ + + target = evaluator.evaluate(typ.args[0]) + target = get_proper_type(target) + + if isinstance(target, TupleType): + if target.partial_fallback: + # Unbounded tuple + return NoneType() + return LiteralType(len(target.items), evaluator.api.named_type('builtins.int')) + + return typ + + +# --- Helper functions --- + +def extract_member_quals(node) -> Type: + """Extract qualifiers (ClassVar, Final) from a symbol table node.""" + # Implementation depends on how qualifiers are stored + return UninhabitedType() # Never = no qualifiers + + +def extract_member_init(node) -> Type: + """Extract the literal type of an initializer from a symbol table node.""" + # Implementation depends on how initializers are tracked + return UninhabitedType() # Never = no initializer + + +def has_not_required_qual(quals: Type) -> bool: + """Check if qualifiers include NotRequired.""" + # Implementation depends on qualifier representation + return False + + +# --- Public API --- + +def evaluate_type_operator(typ: TypeOperatorType) -> Type: + """Evaluate a TypeOperatorType. Called from TypeOperatorType.expand().""" + # Need to get the API somehow - this is a design question + # Option 1: Pass API through a context variable + # Option 2: Store API reference on TypeOperatorType + # Option 3: Create evaluator lazily + evaluator = TypeLevelEvaluator(...) + return evaluator.eval_operator(typ) + + +def evaluate_conditional(typ: ConditionalType) -> Type: + """Evaluate a ConditionalType. Called from ConditionalType.expand().""" + evaluator = TypeLevelEvaluator(...) + return evaluator.eval_conditional(typ) + + +def evaluate_comprehension(typ: TypeForComprehension) -> Type: + """Evaluate a TypeForComprehension. Called from TypeForComprehension.expand().""" + evaluator = TypeLevelEvaluator(...) + return evaluator.eval_comprehension(typ) ``` --- @@ -952,39 +1458,53 @@ Port examples from the PEP: ## Key Design Decisions -### 1. Lazy vs Eager Evaluation -**Decision**: Lazy evaluation with caching. Type-level computations are evaluated when needed (e.g., during subtype checking) rather than immediately during parsing. +### 1. Unified TypeOperatorType (Not Per-Operator Classes) +**Decision**: Use a single `TypeOperatorType` class that references a TypeInfo (the operator) and contains args, rather than creating separate classes for each operator (GetArgType, MembersType, etc.). This keeps mypy's core minimal and allows new operators to be added in typeshed without modifying mypy. + +### 2. Type Operators Declared in Typeshed +**Decision**: All type operators (`GetArg`, `Members`, `Member`, `Param`, etc.) are declared as generic classes in `mypy/typeshed/stdlib/typing.pyi` with the `@_type_operator` decorator. This makes them first-class citizens that can be imported and used like any other typing construct. + +### 3. TypeOperatorType is NOT a ProperType +**Decision**: Like `TypeAliasType`, `TypeOperatorType` must be expanded before use in most type operations. This is handled by extending `get_proper_type()` to evaluate type operators. -### 2. Handling Undecidable Conditions +### 4. Lazy Evaluation with Caching +**Decision**: Type-level computations are evaluated when needed (e.g., during subtype checking) rather than immediately during parsing. Results should be cached. + +### 5. Handling Undecidable Conditions **Decision**: When a condition cannot be evaluated (e.g., involves unbound type variables), preserve the conditional type. It will be evaluated later when more type information is available. -### 3. Synthetic Type Identity +### 6. Synthetic Type Identity **Decision**: Types created via `NewProtocol` are structural (protocols), so identity is based on structure, not name. Each creation point may produce a "different" type that is structurally equivalent. -### 4. Error Handling +### 7. Error Handling **Decision**: Invalid type-level operations (e.g., `GetArg` on non-generic type) return `Never` rather than raising errors, consistent with the spec. -### 5. Runtime Evaluation +### 8. Runtime Evaluation **Decision**: This implementation focuses on static type checking. Runtime evaluation is a separate library concern (as noted in the spec). +### 9. Registry-Based Operator Dispatch +**Decision**: The evaluator uses a registry mapping operator fullnames to evaluation functions (via `@register_operator` decorator). This allows adding new operators without modifying the core evaluator logic. + --- ## Files to Create/Modify ### New Files -- `mypy/typelevel.py` - Type-level computation evaluator +- `mypy/typelevel.py` - Type-level computation evaluator with operator registry - `mypy/test/test_typelevel_*.py` - Test files - `test-data/unit/check-typelevel-*.test` - Test data ### Modified Files -- `mypy/types.py` - Add new type classes -- `mypy/type_visitor.py` - Add visitor methods -- `mypy/typeanal.py` - Parse new special forms -- `mypy/expandtype.py` - Expand new types -- `mypy/subtypes.py` - Subtype rules -- `mypy/checkexpr.py` - kwargs inference -- `mypy/semanal.py` - InitField handling -- `mypy/nodes.py` - Possibly extend TypeInfo +- `mypy/types.py` - Add `TypeOperatorType`, `ConditionalType`, `TypeForComprehension` classes +- `mypy/type_visitor.py` - Add `visit_type_operator_type`, `visit_conditional_type`, `visit_type_for_comprehension` +- `mypy/typeanal.py` - Detect `@_type_operator` classes and construct `TypeOperatorType` +- `mypy/typeops.py` - Extend `get_proper_type()` to expand type operators +- `mypy/expandtype.py` - Handle type variable substitution in type operators +- `mypy/subtypes.py` - Subtype rules for unevaluated type operators +- `mypy/checkexpr.py` - kwargs TypedDict inference +- `mypy/semanal.py` - Detect `@_type_operator` decorator, InitField handling +- `mypy/nodes.py` - Add `is_type_operator` flag to `TypeInfo` +- `mypy/typeshed/stdlib/typing.pyi` - Declare all type operators with `@_type_operator` --- @@ -1000,4 +1520,6 @@ Port examples from the PEP: 5. **Caching strategy**: How aggressively to cache evaluated type-level computations? -6. **Plugin interaction**: Should plugins be able to define custom type operators? +6. **API access in expand()**: How does `TypeOperatorType.expand()` get access to the semantic analyzer API? Options: context variable, stored reference, or lazy creation. + +7. **Type variable handling in operators**: When should type variables in operator arguments block evaluation vs. be substituted first? From 4313ba3ee8be52f238b3ec9c59952fee56312f6f Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Tue, 20 Jan 2026 13:45:30 -0800 Subject: [PATCH 008/161] [typemap] Member/Param as regular generics, accessors as type aliases Key changes: - Member and Param are now regular generic classes with attributes (name, typ, quals, init, definer), not @_type_operator decorated - GetName, GetType, GetQuals, GetInit, GetDefiner are now type aliases using GetAttr (e.g., `type GetName[T] = GetAttr[T, Literal["name"]]`) - Updated evaluator to create Member instances via make_member_instance() - Removed register_operator implementations for accessor helpers - NewProtocol/NewTypedDict now expect Instance types for Member This simplifies the design: GetAttr handles Member/Param attribute access automatically since they're regular classes with real attributes. Co-Authored-By: Claude Opus 4.5 --- TYPEMAP_IMPLEMENTATION_PLAN.md | 167 ++++++++++----------------------- 1 file changed, 51 insertions(+), 116 deletions(-) diff --git a/TYPEMAP_IMPLEMENTATION_PLAN.md b/TYPEMAP_IMPLEMENTATION_PLAN.md index 81cc45392e461..e3ca029b97d39 100644 --- a/TYPEMAP_IMPLEMENTATION_PLAN.md +++ b/TYPEMAP_IMPLEMENTATION_PLAN.md @@ -186,7 +186,6 @@ def _type_operator(cls: type[T]) -> type[T]: ... # --- Data Types (used in type computations) --- -@_type_operator class Member(Generic[_Name, _Type, _Quals, _Init, _Definer]): """ Represents a class member with name, type, qualifiers, initializer, and definer. @@ -196,9 +195,13 @@ class Member(Generic[_Name, _Type, _Quals, _Init, _Definer]): - _Init: the literal type of the initializer expression - _Definer: the class that defined this member """ - ... + name: _Name + typ: _Type + quals: _Quals + init: _Init + definer: _Definer + -@_type_operator class Param(Generic[_Name, _Type, _Quals]): """ Represents a function parameter for extended callable syntax. @@ -206,7 +209,9 @@ class Param(Generic[_Name, _Type, _Quals]): - _Type: the parameter's type - _Quals: Literal['positional', 'keyword', 'default', '*', '**'] - qualifiers """ - ... + name: _Name + typ: _Type + quals: _Quals # Convenience aliases for Param type PosParam[N, T] = Param[N, T, Literal["positional"]] @@ -269,30 +274,11 @@ class FromUnion(Generic[_T]): # --- Member/Param Accessors (sugar for GetArg) --- -@_type_operator -class GetName(Generic[_T]): - """Get the name from a Member or Param. Equivalent to GetArg[_T, Member, 0].""" - ... - -@_type_operator -class GetType(Generic[_T]): - """Get the type from a Member or Param. Equivalent to GetArg[_T, Member, 1].""" - ... - -@_type_operator -class GetQuals(Generic[_T]): - """Get the qualifiers from a Member or Param. Equivalent to GetArg[_T, Member, 2].""" - ... - -@_type_operator -class GetInit(Generic[_T]): - """Get the initializer type from a Member. Equivalent to GetArg[_T, Member, 3].""" - ... - -@_type_operator -class GetDefiner(Generic[_T]): - """Get the defining class from a Member. Equivalent to GetArg[_T, Member, 4].""" - ... +type GetName[T: Member | Param] = GetAttr[T, Literal["name"]] +type GetType[T: Member | Param] = GetAttr[T, Literal["typ"]] +type GetQuals[T: Member | Param] = GetAttr[T, Literal["quals"]] +type GetInit[T: Member] = GetAttr[T, Literal["init"]] +type GetDefiner[T: Member] = GetAttr[T, Literal["definer"]] # --- Type Construction Operators --- @@ -726,18 +712,18 @@ class TypeLevelEvaluator: return typ.value return None - def make_member_type( + def make_member_instance( self, name: str, member_type: Type, quals: Type, init: Type, definer: Type, - ) -> TypeOperatorType: - """Create a Member[...] type operator.""" - member_info = self.api.lookup_qualified('typing.Member', ...) - return TypeOperatorType( - member_info.node, # TypeInfo for Member + ) -> Instance: + """Create a Member[...] instance type (Member is a regular generic class).""" + member_info = self.api.lookup_qualified('typing.Member', ...).node + return Instance( + member_info, [ LiteralType(name, self.api.named_type('builtins.str')), member_type, @@ -747,7 +733,7 @@ class TypeLevelEvaluator: ], ) - def create_protocol_from_members(self, members: list[TypeOperatorType]) -> Type: + def create_protocol_from_members(self, members: list[Instance]) -> Type: """Create a new Protocol TypeInfo from Member type operators.""" # Extract member info and create synthetic TypeInfo # This is complex - see Phase 4 for details @@ -775,18 +761,12 @@ def eval_get_arg(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: return typ # Can't evaluate without literal index if isinstance(target, Instance) and isinstance(base, Instance): + # This works for both regular classes and Member/Param (which are now Instances) args = evaluator.get_type_args_for_base(target, base.type) if args is not None and 0 <= index < len(args): return args[index] return UninhabitedType() # Never - # Handle TypeOperatorType targets (e.g., GetArg[Member[...], Member, 1]) - if isinstance(target, TypeOperatorType) and isinstance(base, Instance): - if target.type == base.type: - if 0 <= index < len(target.args): - return target.args[index] - return UninhabitedType() - return typ @@ -813,7 +793,7 @@ def eval_get_args(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: @register_operator('typing.Members') def eval_members(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: - """Evaluate Members[T] -> tuple of Member types""" + """Evaluate Members[T] -> tuple of Member instance types""" if len(typ.args) != 1: return typ @@ -824,7 +804,7 @@ def eval_members(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: members = [] for name, node in target.type.names.items(): if node.type is not None: - member = evaluator.make_member_type( + member = evaluator.make_member_instance( name=name, member_type=node.type, quals=extract_member_quals(node), @@ -839,7 +819,7 @@ def eval_members(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: @register_operator('typing.Attrs') def eval_attrs(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: - """Evaluate Attrs[T] -> tuple of Member types (annotated attrs only)""" + """Evaluate Attrs[T] -> tuple of Member instance types (annotated attrs only)""" if len(typ.args) != 1: return typ @@ -853,7 +833,7 @@ def eval_attrs(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: if (node.type is not None and not node.is_classvar and not isinstance(node.type, CallableType)): - member = evaluator.make_member_type( + member = evaluator.make_member_instance( name=name, member_type=node.type, quals=extract_member_quals(node), @@ -906,71 +886,18 @@ def eval_get_attr(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: return typ -# --- Member/Param Accessors (sugar for GetArg) --- - -@register_operator('typing.GetName') -def eval_get_name(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: - """GetName[M] = GetArg[M, Member, 0] or GetArg[M, Param, 0]""" - if len(typ.args) != 1: - return typ - - target = evaluator.evaluate(typ.args[0]) - if isinstance(target, TypeOperatorType): - if target.fullname in ('typing.Member', 'typing.Param') and len(target.args) > 0: - return target.args[0] - return UninhabitedType() - - -@register_operator('typing.GetType') -def eval_get_type(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: - """GetType[M] = GetArg[M, Member, 1] or GetArg[M, Param, 1]""" - if len(typ.args) != 1: - return typ - - target = evaluator.evaluate(typ.args[0]) - if isinstance(target, TypeOperatorType): - if target.fullname in ('typing.Member', 'typing.Param') and len(target.args) > 1: - return target.args[1] - return UninhabitedType() - - -@register_operator('typing.GetQuals') -def eval_get_quals(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: - """GetQuals[M] = GetArg[M, Member, 2] or GetArg[M, Param, 2]""" - if len(typ.args) != 1: - return typ - - target = evaluator.evaluate(typ.args[0]) - if isinstance(target, TypeOperatorType): - if target.fullname in ('typing.Member', 'typing.Param') and len(target.args) > 2: - return target.args[2] - return UninhabitedType() - - -@register_operator('typing.GetInit') -def eval_get_init(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: - """GetInit[M] = GetArg[M, Member, 3]""" - if len(typ.args) != 1: - return typ - - target = evaluator.evaluate(typ.args[0]) - if isinstance(target, TypeOperatorType): - if target.fullname == 'typing.Member' and len(target.args) > 3: - return target.args[3] - return UninhabitedType() - - -@register_operator('typing.GetDefiner') -def eval_get_definer(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: - """GetDefiner[M] = GetArg[M, Member, 4]""" - if len(typ.args) != 1: - return typ - - target = evaluator.evaluate(typ.args[0]) - if isinstance(target, TypeOperatorType): - if target.fullname == 'typing.Member' and len(target.args) > 4: - return target.args[4] - return UninhabitedType() +# --- Member/Param Accessors --- +# NOTE: GetName, GetType, GetQuals, GetInit, GetDefiner are now type aliases +# defined in typeshed using GetAttr, not type operators: +# +# type GetName[T: Member | Param] = GetAttr[T, Literal["name"]] +# type GetType[T: Member | Param] = GetAttr[T, Literal["typ"]] +# type GetQuals[T: Member | Param] = GetAttr[T, Literal["quals"]] +# type GetInit[T: Member] = GetAttr[T, Literal["init"]] +# type GetDefiner[T: Member] = GetAttr[T, Literal["definer"]] +# +# Since Member and Param are regular generic classes with attributes, +# GetAttr handles these automatically - no special operator needed. # --- String Operations --- @@ -1063,9 +990,11 @@ def eval_new_protocol(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> T """Evaluate NewProtocol[*Members] -> create a new structural type""" evaluated_members = [evaluator.evaluate(m) for m in typ.args] - # All members must be Member type operators + # All members must be Member instances (Member is a regular generic class) + member_type_info = evaluator.api.lookup_qualified('typing.Member', ...).node for m in evaluated_members: - if not isinstance(m, TypeOperatorType) or m.fullname != 'typing.Member': + m = get_proper_type(m) + if not isinstance(m, Instance) or m.type != member_type_info: return typ # Can't evaluate yet return evaluator.create_protocol_from_members(evaluated_members) @@ -1076,13 +1005,16 @@ def eval_new_typed_dict(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> """Evaluate NewTypedDict[*Members] -> create a new TypedDict""" evaluated_members = [evaluator.evaluate(m) for m in typ.args] + member_type_info = evaluator.api.lookup_qualified('typing.Member', ...).node items = {} required_keys = set() for m in evaluated_members: - if not isinstance(m, TypeOperatorType) or m.fullname != 'typing.Member': + m = get_proper_type(m) + if not isinstance(m, Instance) or m.type != member_type_info: return typ # Can't evaluate yet + # Member[name, typ, quals, init, definer] - access via type args name = evaluator.extract_literal_string(m.args[0]) if name is None: return typ @@ -1462,7 +1394,10 @@ Port examples from the PEP: **Decision**: Use a single `TypeOperatorType` class that references a TypeInfo (the operator) and contains args, rather than creating separate classes for each operator (GetArgType, MembersType, etc.). This keeps mypy's core minimal and allows new operators to be added in typeshed without modifying mypy. ### 2. Type Operators Declared in Typeshed -**Decision**: All type operators (`GetArg`, `Members`, `Member`, `Param`, etc.) are declared as generic classes in `mypy/typeshed/stdlib/typing.pyi` with the `@_type_operator` decorator. This makes them first-class citizens that can be imported and used like any other typing construct. +**Decision**: Type operators (`GetArg`, `Members`, `GetAttr`, etc.) are declared as generic classes in `mypy/typeshed/stdlib/typing.pyi` with the `@_type_operator` decorator. `Member` and `Param` are regular generic classes (not operators) with actual attributes - they're just data containers used in type computations. + +### 2b. Member/Param Accessors as Type Aliases +**Decision**: `GetName`, `GetType`, `GetQuals`, `GetInit`, `GetDefiner` are type aliases using `GetAttr`, not separate type operators. Since `Member` and `Param` are regular classes with attributes, `GetAttr[Member[...], Literal["name"]]` works directly. ### 3. TypeOperatorType is NOT a ProperType **Decision**: Like `TypeAliasType`, `TypeOperatorType` must be expanded before use in most type operations. This is handled by extending `get_proper_type()` to evaluate type operators. @@ -1504,7 +1439,7 @@ Port examples from the PEP: - `mypy/checkexpr.py` - kwargs TypedDict inference - `mypy/semanal.py` - Detect `@_type_operator` decorator, InitField handling - `mypy/nodes.py` - Add `is_type_operator` flag to `TypeInfo` -- `mypy/typeshed/stdlib/typing.pyi` - Declare all type operators with `@_type_operator` +- `mypy/typeshed/stdlib/typing.pyi` - Declare type operators with `@_type_operator`, plus `Member`/`Param` as regular generic classes and accessor aliases --- From 5d57e90ef4358c5973418aacf91e1e970af19d77 Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Tue, 20 Jan 2026 13:48:35 -0800 Subject: [PATCH 009/161] [typemap] Add ComputedType base class for all computed types TypeOperatorType, ConditionalType, and TypeForComprehension now all inherit from a common ComputedType(Type) base class that defines the expand() interface. This simplifies get_proper_type() to a single isinstance check and provides a clear abstraction for unevaluated type-level computations. Co-Authored-By: Claude Opus 4.5 --- TYPEMAP_IMPLEMENTATION_PLAN.md | 86 +++++++++++++++++++++------------- 1 file changed, 53 insertions(+), 33 deletions(-) diff --git a/TYPEMAP_IMPLEMENTATION_PLAN.md b/TYPEMAP_IMPLEMENTATION_PLAN.md index e3ca029b97d39..2f4fbcd495cd7 100644 --- a/TYPEMAP_IMPLEMENTATION_PLAN.md +++ b/TYPEMAP_IMPLEMENTATION_PLAN.md @@ -28,17 +28,45 @@ Rather than creating a separate class for each type operator, we use a single un - Allows new operators to be added in typeshed without modifying mypy's core - Treats type operators as "unevaluated" types that expand to concrete types -### 1.2 Add `TypeOperatorType` (`mypy/types.py`) +### 1.2 Add `ComputedType` Base Class (`mypy/types.py`) + +All type-level computation types share a common base class that defines the `expand()` interface: ```python -class TypeOperatorType(Type): +class ComputedType(Type): """ - Represents an unevaluated type operator application, e.g., GetArg[T, Base, 0]. + Base class for types that represent unevaluated type-level computations. + + NOT a ProperType - must be expanded/evaluated before use in most type + operations. Analogous to TypeAliasType in that it wraps a computation + that produces a concrete type. + + Subclasses: + - TypeOperatorType: e.g., GetArg[T, Base, 0], Members[T] + - ConditionalType: e.g., X if Sub[T, Base] else Y + - TypeForComprehension: e.g., *[Expr for x in Iter[T] if Cond] + """ + + __slots__ = () + + def expand(self) -> Type: + """ + Evaluate this computed type to produce a concrete type. + Returns self if evaluation is not yet possible (e.g., contains unresolved type vars). + + Subclasses must implement this method. + """ + raise NotImplementedError +``` - Analogous to TypeAliasType: stores a reference to the operator's TypeInfo - and the type arguments. NOT a ProperType - must be expanded before use - in most type operations. +### 1.3 Add `TypeOperatorType` (`mypy/types.py`) +```python +class TypeOperatorType(ComputedType): + """ + Represents an unevaluated type operator application, e.g., GetArg[T, Base, 0]. + + Stores a reference to the operator's TypeInfo and the type arguments. Type operators are generic classes in typeshed marked with @_type_operator. """ @@ -63,10 +91,7 @@ class TypeOperatorType(Type): return self.type.fullname def expand(self) -> Type: - """ - Evaluate this type operator to produce a concrete type. - Returns self if evaluation is not yet possible (e.g., contains unresolved type vars). - """ + """Evaluate this type operator to produce a concrete type.""" from mypy.typelevel import evaluate_type_operator return evaluate_type_operator(self) @@ -91,16 +116,13 @@ class TypeOperatorType(Type): ) ``` -### 1.3 Conditional Types and Comprehensions (`mypy/types.py`) - -These are special syntactic constructs that need their own type classes: +### 1.4 Conditional Types and Comprehensions (`mypy/types.py`) ```python -class ConditionalType(Type): +class ConditionalType(ComputedType): """ Represents `TrueType if Sub[T, Base] else FalseType`. - NOT a ProperType - must be evaluated/expanded before use. The condition is itself a TypeOperatorType (Sub[...]). """ @@ -128,11 +150,11 @@ class ConditionalType(Type): return evaluate_conditional(self) -class TypeForComprehension(Type): +class TypeForComprehension(ComputedType): """ Represents *[Expr for var in Iter[T] if Cond]. - NOT a ProperType - expands to a tuple of types. + Expands to a tuple of types. """ __slots__ = ("element_expr", "iter_var", "iter_type", "conditions") @@ -161,9 +183,9 @@ class TypeForComprehension(Type): return evaluate_comprehension(self) ``` -### 1.4 Update Type Visitor (`mypy/type_visitor.py`) +### 1.5 Update Type Visitor (`mypy/type_visitor.py`) -Add visitor methods for the new types: +Add visitor methods for the new types (note: no visitor for `ComputedType` base class - each subclass has its own): ```python class TypeVisitor(Generic[T]): @@ -174,7 +196,7 @@ class TypeVisitor(Generic[T]): def visit_type_for_comprehension(self, t: TypeForComprehension) -> T: ... ``` -### 1.5 Declare Type Operators in Typeshed (`mypy/typeshed/stdlib/typing.pyi`) +### 1.6 Declare Type Operators in Typeshed (`mypy/typeshed/stdlib/typing.pyi`) All type operators are declared as generic classes with the `@_type_operator` decorator. This decorator marks them for special handling by the type checker. @@ -385,7 +407,7 @@ class Length(Generic[_T]): ... ``` -### 1.6 Detecting Type Operators (`mypy/nodes.py`) +### 1.7 Detecting Type Operators (`mypy/nodes.py`) Add a flag to TypeInfo to mark type operators: @@ -396,12 +418,13 @@ class TypeInfo(SymbolNode): is_type_operator: bool = False # True if decorated with @_type_operator ``` -### 1.7 How Expansion Works +### 1.8 How Expansion Works -The key insight is that `TypeOperatorType` is NOT a `ProperType`. Like `TypeAliasType`, -it must be expanded before most type operations can use it. The expansion happens via: +The key insight is that `ComputedType` (and its subclasses) is NOT a `ProperType`. +Like `TypeAliasType`, it must be expanded before most type operations can use it. +The expansion happens via: -1. **`get_proper_type()`** in `mypy/typeops.py` - already handles `TypeAliasType`, extend to handle `TypeOperatorType` +1. **`get_proper_type()`** in `mypy/typeops.py` - already handles `TypeAliasType`, extend to handle `ComputedType` 2. **Explicit `.expand()` calls** when we need to evaluate ```python @@ -410,11 +433,8 @@ def get_proper_type(typ: Type) -> ProperType: while True: if isinstance(typ, TypeAliasType): typ = typ._expand_once() - elif isinstance(typ, TypeOperatorType): - typ = typ.expand() - elif isinstance(typ, ConditionalType): - typ = typ.expand() - elif isinstance(typ, TypeForComprehension): + elif isinstance(typ, ComputedType): + # Handles TypeOperatorType, ConditionalType, TypeForComprehension typ = typ.expand() else: break @@ -1399,8 +1419,8 @@ Port examples from the PEP: ### 2b. Member/Param Accessors as Type Aliases **Decision**: `GetName`, `GetType`, `GetQuals`, `GetInit`, `GetDefiner` are type aliases using `GetAttr`, not separate type operators. Since `Member` and `Param` are regular classes with attributes, `GetAttr[Member[...], Literal["name"]]` works directly. -### 3. TypeOperatorType is NOT a ProperType -**Decision**: Like `TypeAliasType`, `TypeOperatorType` must be expanded before use in most type operations. This is handled by extending `get_proper_type()` to evaluate type operators. +### 3. ComputedType Hierarchy (NOT ProperType) +**Decision**: All computed types (`TypeOperatorType`, `ConditionalType`, `TypeForComprehension`) inherit from a common `ComputedType` base class. Like `TypeAliasType`, `ComputedType` is NOT a `ProperType` and must be expanded before use in most type operations. This is handled by a single `isinstance(typ, ComputedType)` check in `get_proper_type()`. ### 4. Lazy Evaluation with Caching **Decision**: Type-level computations are evaluated when needed (e.g., during subtype checking) rather than immediately during parsing. Results should be cached. @@ -1430,7 +1450,7 @@ Port examples from the PEP: - `test-data/unit/check-typelevel-*.test` - Test data ### Modified Files -- `mypy/types.py` - Add `TypeOperatorType`, `ConditionalType`, `TypeForComprehension` classes +- `mypy/types.py` - Add `ComputedType` base class, `TypeOperatorType`, `ConditionalType`, `TypeForComprehension` - `mypy/type_visitor.py` - Add `visit_type_operator_type`, `visit_conditional_type`, `visit_type_for_comprehension` - `mypy/typeanal.py` - Detect `@_type_operator` classes and construct `TypeOperatorType` - `mypy/typeops.py` - Extend `get_proper_type()` to expand type operators From 61b9b8a0e6193b2050b614057af0c3bc23898b5d Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Tue, 20 Jan 2026 13:58:57 -0800 Subject: [PATCH 010/161] [typemap] Use native ternary syntax for conditional types (Option B) Instead of a special form like Cond[X, Sub[T, Base], Y], extend the parser to handle Python's native ternary syntax: X if Sub[T, Base] else Y Implementation approach: - Extend expr_to_unanalyzed_type() in fastparse.py to handle IfExpr - Add ConditionalUnboundType for unanalyzed conditional types - TypeAnalyser converts ConditionalUnboundType -> ConditionalType - Validates that condition is Sub[...] or boolean combination Co-Authored-By: Claude Opus 4.5 --- TYPEMAP_IMPLEMENTATION_PLAN.md | 124 +++++++++++++++++++++++++++------ 1 file changed, 101 insertions(+), 23 deletions(-) diff --git a/TYPEMAP_IMPLEMENTATION_PLAN.md b/TYPEMAP_IMPLEMENTATION_PLAN.md index 2f4fbcd495cd7..fa895dfa030d7 100644 --- a/TYPEMAP_IMPLEMENTATION_PLAN.md +++ b/TYPEMAP_IMPLEMENTATION_PLAN.md @@ -492,25 +492,104 @@ def analyze_type_operator(self, t: UnboundType, type_info: TypeInfo) -> Type: ### 2.2 Parse Conditional Type Syntax -Need to handle the `X if Cond else Y` syntax in type contexts. This requires modification to how type expressions are parsed. +Handle the `X if Sub[T, Base] else Y` syntax in type contexts by extending the parser. -**Option A**: Use a special form `Cond[TrueType, Condition, FalseType]` -**Option B**: Extend the parser to handle ternary in type contexts +#### 2.2.1 AST Representation -For now, pursue Option A as it's less invasive: +Python's parser already produces `IfExpr` (ternary) nodes. In type contexts, we need to +recognize these and convert them to type expressions. The AST for `X if Cond else Y` is: ```python -# typing.Cond[TrueType, Sub[T, Base], FalseType] -def analyze_conditional_type(self, t: UnboundType) -> Type: - if len(t.args) != 3: - self.fail('Cond requires 3 arguments', t) +IfExpr( + cond=..., # The condition expression + body=..., # The "true" branch (X) + orelse=..., # The "false" branch (Y) +) +``` + +#### 2.2.2 Extend `expr_to_unanalyzed_type()` (`mypy/fastparse.py`) + +The `expr_to_unanalyzed_type()` function converts AST expressions to unanalyzed types. +Extend it to handle `IfExpr`: + +```python +def expr_to_unanalyzed_type( + expr: ast.expr, + options: Options, + ..., +) -> ProperType | UnboundType: + # ... existing cases ... + + if isinstance(expr, IfExpr): + # Convert ternary to ConditionalUnboundType + return ConditionalUnboundType( + condition=expr_to_unanalyzed_type(expr.cond, options, ...), + true_type=expr_to_unanalyzed_type(expr.body, options, ...), + false_type=expr_to_unanalyzed_type(expr.orelse, options, ...), + line=expr.lineno, + column=expr.col_offset, + ) + + # ... rest of existing logic ... +``` + +#### 2.2.3 Add `ConditionalUnboundType` (`mypy/types.py`) + +A new unbound type to represent conditionals before analysis: + +```python +class ConditionalUnboundType(Type): + """Unanalyzed conditional type: X if Cond else Y""" + + __slots__ = ("condition", "true_type", "false_type") + + def __init__( + self, + condition: Type, + true_type: Type, + false_type: Type, + line: int = -1, + column: int = -1, + ) -> None: + super().__init__(line, column) + self.condition = condition + self.true_type = true_type + self.false_type = false_type + + def accept(self, visitor: TypeVisitor[T]) -> T: + return visitor.visit_conditional_unbound_type(self) +``` + +#### 2.2.4 Analyze Conditional Types (`mypy/typeanal.py`) + +In `TypeAnalyser`, handle `ConditionalUnboundType`: + +```python +def visit_conditional_unbound_type(self, t: ConditionalUnboundType) -> Type: + """Analyze X if Sub[T, Base] else Y""" + # Analyze all three parts + condition = self.anal_type(t.condition) + true_type = self.anal_type(t.true_type) + false_type = self.anal_type(t.false_type) + + # Validate condition is a Sub[...] or boolean combination + if not self.is_valid_type_condition(condition): + self.fail( + "Condition in type-level conditional must be Sub[T, Base] " + "or a boolean combination thereof", + t + ) return AnyType(TypeOfAny.from_error) - true_type = self.anal_type(t.args[0]) - condition = self.analyze_type_condition(t.args[1]) - false_type = self.anal_type(t.args[2]) + return ConditionalType(condition, true_type, false_type, line=t.line, column=t.column) + - return ConditionalType(condition, true_type, false_type) +def is_valid_type_condition(self, typ: Type) -> bool: + """Check if typ is a valid type-level condition (Sub or boolean combo).""" + if isinstance(typ, TypeOperatorType): + return typ.fullname == 'typing.Sub' + # Could also check for And/Or/Not combinations if we support those + return False ``` ### 2.3 Parse Type Comprehensions @@ -1450,9 +1529,10 @@ Port examples from the PEP: - `test-data/unit/check-typelevel-*.test` - Test data ### Modified Files -- `mypy/types.py` - Add `ComputedType` base class, `TypeOperatorType`, `ConditionalType`, `TypeForComprehension` -- `mypy/type_visitor.py` - Add `visit_type_operator_type`, `visit_conditional_type`, `visit_type_for_comprehension` -- `mypy/typeanal.py` - Detect `@_type_operator` classes and construct `TypeOperatorType` +- `mypy/types.py` - Add `ComputedType` base class, `TypeOperatorType`, `ConditionalType`, `TypeForComprehension`, `ConditionalUnboundType` +- `mypy/type_visitor.py` - Add `visit_type_operator_type`, `visit_conditional_type`, `visit_type_for_comprehension`, `visit_conditional_unbound_type` +- `mypy/fastparse.py` - Extend `expr_to_unanalyzed_type()` to handle `IfExpr` for conditional types +- `mypy/typeanal.py` - Detect `@_type_operator` classes, construct `TypeOperatorType`, analyze `ConditionalUnboundType` - `mypy/typeops.py` - Extend `get_proper_type()` to expand type operators - `mypy/expandtype.py` - Handle type variable substitution in type operators - `mypy/subtypes.py` - Subtype rules for unevaluated type operators @@ -1465,16 +1545,14 @@ Port examples from the PEP: ## Open Questions for Discussion -1. **Syntax for conditionals**: Use `X if Sub[T, Base] else Y` (requires parser changes) or `Cond[X, Sub[T, Base], Y]` (works with existing syntax)? - -2. **Protocol vs TypedDict creation**: Should `NewProtocol` create true protocols (with `is_protocol=True`) or just structural types? +1. **Protocol vs TypedDict creation**: Should `NewProtocol` create true protocols (with `is_protocol=True`) or just structural types? -3. **Type alias recursion**: How to handle recursive type aliases that use type-level computation? +2. **Type alias recursion**: How to handle recursive type aliases that use type-level computation? -4. **Error recovery**: What should happen when type-level computation fails? Currently spec says return `Never`. +3. **Error recovery**: What should happen when type-level computation fails? Currently spec says return `Never`. -5. **Caching strategy**: How aggressively to cache evaluated type-level computations? +4. **Caching strategy**: How aggressively to cache evaluated type-level computations? -6. **API access in expand()**: How does `TypeOperatorType.expand()` get access to the semantic analyzer API? Options: context variable, stored reference, or lazy creation. +5. **API access in expand()**: How does `TypeOperatorType.expand()` get access to the semantic analyzer API? Options: context variable, stored reference, or lazy creation. -7. **Type variable handling in operators**: When should type variables in operator arguments block evaluation vs. be substituted first? +6. **Type variable handling in operators**: When should type variables in operator arguments block evaluation vs. be substituted first? From f4d2808260fe1165404c1e6d1bf5498b8fe35b6b Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Tue, 20 Jan 2026 14:10:15 -0800 Subject: [PATCH 011/161] [typemap] Simplify: convert IfExpr directly to ConditionalType No need for intermediate ConditionalUnboundType - just create ConditionalType directly in fastparse.py with unanalyzed nested types, then TypeAnalyser.visit_conditional_type() analyzes the components. Co-Authored-By: Claude Opus 4.5 --- TYPEMAP_IMPLEMENTATION_PLAN.md | 51 ++++++++-------------------------- 1 file changed, 12 insertions(+), 39 deletions(-) diff --git a/TYPEMAP_IMPLEMENTATION_PLAN.md b/TYPEMAP_IMPLEMENTATION_PLAN.md index fa895dfa030d7..2e940e403e160 100644 --- a/TYPEMAP_IMPLEMENTATION_PLAN.md +++ b/TYPEMAP_IMPLEMENTATION_PLAN.md @@ -510,7 +510,7 @@ IfExpr( #### 2.2.2 Extend `expr_to_unanalyzed_type()` (`mypy/fastparse.py`) The `expr_to_unanalyzed_type()` function converts AST expressions to unanalyzed types. -Extend it to handle `IfExpr`: +Extend it to handle `IfExpr`, converting directly to `ConditionalType`: ```python def expr_to_unanalyzed_type( @@ -521,8 +521,8 @@ def expr_to_unanalyzed_type( # ... existing cases ... if isinstance(expr, IfExpr): - # Convert ternary to ConditionalUnboundType - return ConditionalUnboundType( + # Convert ternary directly to ConditionalType + return ConditionalType( condition=expr_to_unanalyzed_type(expr.cond, options, ...), true_type=expr_to_unanalyzed_type(expr.body, options, ...), false_type=expr_to_unanalyzed_type(expr.orelse, options, ...), @@ -533,41 +533,14 @@ def expr_to_unanalyzed_type( # ... rest of existing logic ... ``` -#### 2.2.3 Add `ConditionalUnboundType` (`mypy/types.py`) - -A new unbound type to represent conditionals before analysis: - -```python -class ConditionalUnboundType(Type): - """Unanalyzed conditional type: X if Cond else Y""" - - __slots__ = ("condition", "true_type", "false_type") - - def __init__( - self, - condition: Type, - true_type: Type, - false_type: Type, - line: int = -1, - column: int = -1, - ) -> None: - super().__init__(line, column) - self.condition = condition - self.true_type = true_type - self.false_type = false_type - - def accept(self, visitor: TypeVisitor[T]) -> T: - return visitor.visit_conditional_unbound_type(self) -``` - -#### 2.2.4 Analyze Conditional Types (`mypy/typeanal.py`) +#### 2.2.3 Handle in Type Analysis (`mypy/typeanal.py`) -In `TypeAnalyser`, handle `ConditionalUnboundType`: +`ConditionalType` contains unanalyzed types initially. The `TypeAnalyser` visitor +analyzes the nested types: ```python -def visit_conditional_unbound_type(self, t: ConditionalUnboundType) -> Type: - """Analyze X if Sub[T, Base] else Y""" - # Analyze all three parts +def visit_conditional_type(self, t: ConditionalType) -> Type: + """Analyze the components of a conditional type.""" condition = self.anal_type(t.condition) true_type = self.anal_type(t.true_type) false_type = self.anal_type(t.false_type) @@ -1529,10 +1502,10 @@ Port examples from the PEP: - `test-data/unit/check-typelevel-*.test` - Test data ### Modified Files -- `mypy/types.py` - Add `ComputedType` base class, `TypeOperatorType`, `ConditionalType`, `TypeForComprehension`, `ConditionalUnboundType` -- `mypy/type_visitor.py` - Add `visit_type_operator_type`, `visit_conditional_type`, `visit_type_for_comprehension`, `visit_conditional_unbound_type` -- `mypy/fastparse.py` - Extend `expr_to_unanalyzed_type()` to handle `IfExpr` for conditional types -- `mypy/typeanal.py` - Detect `@_type_operator` classes, construct `TypeOperatorType`, analyze `ConditionalUnboundType` +- `mypy/types.py` - Add `ComputedType` base class, `TypeOperatorType`, `ConditionalType`, `TypeForComprehension` +- `mypy/type_visitor.py` - Add `visit_type_operator_type`, `visit_conditional_type`, `visit_type_for_comprehension` +- `mypy/fastparse.py` - Extend `expr_to_unanalyzed_type()` to handle `IfExpr` → `ConditionalType` +- `mypy/typeanal.py` - Detect `@_type_operator` classes, construct `TypeOperatorType`, analyze `ConditionalType` - `mypy/typeops.py` - Extend `get_proper_type()` to expand type operators - `mypy/expandtype.py` - Handle type variable substitution in type operators - `mypy/subtypes.py` - Subtype rules for unevaluated type operators From 5de3b9b8ced033ce834d8ca7833ac14d85e6a313 Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Tue, 20 Jan 2026 14:16:51 -0800 Subject: [PATCH 012/161] [typemap] Rename Sub to IsSub throughout the plan Aligns with the updated spec-draft naming. Co-Authored-By: Claude Opus 4.5 --- TYPEMAP_IMPLEMENTATION_PLAN.md | 34 +++++++++++++++++----------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/TYPEMAP_IMPLEMENTATION_PLAN.md b/TYPEMAP_IMPLEMENTATION_PLAN.md index 2e940e403e160..5b51c3c3ac8f2 100644 --- a/TYPEMAP_IMPLEMENTATION_PLAN.md +++ b/TYPEMAP_IMPLEMENTATION_PLAN.md @@ -6,8 +6,8 @@ This document outlines a plan for implementing the type-level computation propos The proposal introduces TypeScript-inspired type-level introspection and construction facilities: -1. **Type Operators**: `GetArg`, `GetArgs`, `FromUnion`, `Sub` (subtype check) -2. **Conditional Types**: `X if Sub[T, Base] else Y` +1. **Type Operators**: `GetArg`, `GetArgs`, `FromUnion`, `IsSub` (subtype check) +2. **Conditional Types**: `X if IsSub[T, Base] else Y` 3. **Type-Level Iteration**: `*[... for t in Iter[...]]` 4. **Object Inspection**: `Members`, `Attrs`, `Member`, `NewProtocol`, `NewTypedDict` 5. **Callable Extension**: `Param` type with qualifiers for extended callable syntax @@ -43,7 +43,7 @@ class ComputedType(Type): Subclasses: - TypeOperatorType: e.g., GetArg[T, Base, 0], Members[T] - - ConditionalType: e.g., X if Sub[T, Base] else Y + - ConditionalType: e.g., X if IsSub[T, Base] else Y - TypeForComprehension: e.g., *[Expr for x in Iter[T] if Cond] """ @@ -121,16 +121,16 @@ class TypeOperatorType(ComputedType): ```python class ConditionalType(ComputedType): """ - Represents `TrueType if Sub[T, Base] else FalseType`. + Represents `TrueType if IsSub[T, Base] else FalseType`. - The condition is itself a TypeOperatorType (Sub[...]). + The condition is itself a TypeOperatorType (IsSub[...]). """ __slots__ = ("condition", "true_type", "false_type") def __init__( self, - condition: Type, # Should be Sub[T, Base] or boolean combination thereof + condition: Type, # Should be IsSub[T, Base] or boolean combination thereof true_type: Type, false_type: Type, line: int = -1, @@ -164,7 +164,7 @@ class TypeForComprehension(ComputedType): element_expr: Type, iter_var: str, iter_type: Type, # The type being iterated (should be a tuple type) - conditions: list[Type], # Each should be Sub[...] or boolean combo + conditions: list[Type], # Each should be IsSub[...] or boolean combo line: int = -1, column: int = -1, ) -> None: @@ -323,10 +323,10 @@ class NewTypedDict(Generic[Unpack[_Ts]]): # --- Boolean/Conditional Operators --- @_type_operator -class Sub(Generic[_T, _Base]): +class IsSub(Generic[_T, _Base]): """ Type-level subtype check. Evaluates to a type-level boolean. - Used in conditional type expressions: `X if Sub[T, Base] else Y` + Used in conditional type expressions: `X if IsSub[T, Base] else Y` """ ... @@ -492,7 +492,7 @@ def analyze_type_operator(self, t: UnboundType, type_info: TypeInfo) -> Type: ### 2.2 Parse Conditional Type Syntax -Handle the `X if Sub[T, Base] else Y` syntax in type contexts by extending the parser. +Handle the `X if IsSub[T, Base] else Y` syntax in type contexts by extending the parser. #### 2.2.1 AST Representation @@ -545,10 +545,10 @@ def visit_conditional_type(self, t: ConditionalType) -> Type: true_type = self.anal_type(t.true_type) false_type = self.anal_type(t.false_type) - # Validate condition is a Sub[...] or boolean combination + # Validate condition is a IsSub[...] or boolean combination if not self.is_valid_type_condition(condition): self.fail( - "Condition in type-level conditional must be Sub[T, Base] " + "Condition in type-level conditional must be IsSub[T, Base] " "or a boolean combination thereof", t ) @@ -558,9 +558,9 @@ def visit_conditional_type(self, t: ConditionalType) -> Type: def is_valid_type_condition(self, typ: Type) -> bool: - """Check if typ is a valid type-level condition (Sub or boolean combo).""" + """Check if typ is a valid type-level condition (IsSub or boolean combo).""" if isinstance(typ, TypeOperatorType): - return typ.fullname == 'typing.Sub' + return typ.fullname == 'typing.IsSub' # Could also check for And/Or/Not combinations if we support those return False ``` @@ -661,10 +661,10 @@ class TypeLevelEvaluator: def eval_condition(self, cond: Type) -> bool | None: """ - Evaluate a type-level condition (Sub[T, Base]). + Evaluate a type-level condition (IsSub[T, Base]). Returns True/False if decidable, None if undecidable. """ - if isinstance(cond, TypeOperatorType) and cond.fullname == 'typing.Sub': + if isinstance(cond, TypeOperatorType) and cond.fullname == 'typing.IsSub': left = self.evaluate(cond.args[0]) right = self.evaluate(cond.args[1]) # Handle type variables - may be undecidable @@ -1414,7 +1414,7 @@ Port examples from the PEP: ### Milestone 2: Conditional Types (Weeks 4-5) 1. Add `ConditionalType` and condition classes -2. Add `Sub` condition operator +2. Add `IsSub` condition operator 3. Integrate with type evaluator 4. Tests for conditionals From bb8ab617436cd495a660d4a71202f76ecad3a2be Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Tue, 20 Jan 2026 14:37:26 -0800 Subject: [PATCH 013/161] Start adding the typing nodes --- mypy/nodes.py | 7 + mypy/type_visitor.py | 50 +++++ mypy/types.py | 344 +++++++++++++++++++++++++++++++- mypy/typeshed/stdlib/typing.pyi | 249 +++++++++++++++++++++++ 4 files changed, 644 insertions(+), 6 deletions(-) diff --git a/mypy/nodes.py b/mypy/nodes.py index 589da3d240fb9..51d79249a3c38 100644 --- a/mypy/nodes.py +++ b/mypy/nodes.py @@ -3581,6 +3581,7 @@ class is generic then it will be a type constructor of higher kind. "self_type", "dataclass_transform_spec", "is_type_check_only", + "is_type_operator", "deprecated", "type_object_type", ) @@ -3737,6 +3738,10 @@ class is generic then it will be a type constructor of higher kind. # Is set to `True` when class is decorated with `@typing.type_check_only` is_type_check_only: bool + # Is set to `True` when class is decorated with `@typing._type_operator` + # Type operators are used for type-level computation (e.g., GetArg, Members, etc.) + is_type_operator: bool + # The type's deprecation message (in case it is deprecated) deprecated: str | None @@ -3756,6 +3761,7 @@ class is generic then it will be a type constructor of higher kind. "is_final", "is_disjoint_base", "is_intersection", + "is_type_operator", ] def __init__(self, names: SymbolTable, defn: ClassDef, module_name: str) -> None: @@ -3803,6 +3809,7 @@ def __init__(self, names: SymbolTable, defn: ClassDef, module_name: str) -> None self.self_type = None self.dataclass_transform_spec = None self.is_type_check_only = False + self.is_type_operator = False self.deprecated = None self.type_object_type = None diff --git a/mypy/type_visitor.py b/mypy/type_visitor.py index 1b38481ba0004..1960998a8a231 100644 --- a/mypy/type_visitor.py +++ b/mypy/type_visitor.py @@ -23,6 +23,7 @@ AnyType, CallableArgument, CallableType, + ConditionalType, DeletedType, EllipsisType, ErasedType, @@ -39,7 +40,9 @@ Type, TypeAliasType, TypedDictType, + TypeForComprehension, TypeList, + TypeOperatorType, TypeType, TypeVarLikeType, TypeVarTupleType, @@ -146,6 +149,18 @@ def visit_type_alias_type(self, t: TypeAliasType, /) -> T: def visit_unpack_type(self, t: UnpackType, /) -> T: pass + @abstractmethod + def visit_type_operator_type(self, t: TypeOperatorType, /) -> T: + pass + + @abstractmethod + def visit_conditional_type(self, t: ConditionalType, /) -> T: + pass + + @abstractmethod + def visit_type_for_comprehension(self, t: TypeForComprehension, /) -> T: + pass + @trait @mypyc_attr(allow_interpreted_subclasses=True) @@ -340,6 +355,23 @@ def visit_type_alias_type(self, t: TypeAliasType, /) -> Type: # must implement this depending on its semantics. pass + def visit_type_operator_type(self, t: TypeOperatorType, /) -> Type: + return t.copy_modified(args=self.translate_type_list(t.args)) + + def visit_conditional_type(self, t: ConditionalType, /) -> Type: + return t.copy_modified( + condition=t.condition.accept(self), + true_type=t.true_type.accept(self), + false_type=t.false_type.accept(self), + ) + + def visit_type_for_comprehension(self, t: TypeForComprehension, /) -> Type: + return t.copy_modified( + element_expr=t.element_expr.accept(self), + iter_type=t.iter_type.accept(self), + conditions=[c.accept(self) for c in t.conditions], + ) + @mypyc_attr(allow_interpreted_subclasses=True) class TypeQuery(SyntheticTypeVisitor[T]): @@ -456,6 +488,15 @@ def visit_type_alias_type(self, t: TypeAliasType, /) -> T: self.seen_aliases.add(t) return get_proper_type(t).accept(self) + def visit_type_operator_type(self, t: TypeOperatorType, /) -> T: + return self.query_types(t.args) + + def visit_conditional_type(self, t: ConditionalType, /) -> T: + return self.query_types([t.condition, t.true_type, t.false_type]) + + def visit_type_for_comprehension(self, t: TypeForComprehension, /) -> T: + return self.query_types([t.element_expr, t.iter_type] + t.conditions) + def query_types(self, types: Iterable[Type]) -> T: """Perform a query for a list of types using the strategy to combine the results.""" return self.strategy([t.accept(self) for t in types]) @@ -597,6 +638,15 @@ def visit_type_alias_type(self, t: TypeAliasType, /) -> bool: self.seen_aliases.add(t) return get_proper_type(t).accept(self) + def visit_type_operator_type(self, t: TypeOperatorType, /) -> bool: + return self.query_types(t.args) + + def visit_conditional_type(self, t: ConditionalType, /) -> bool: + return self.query_types([t.condition, t.true_type, t.false_type]) + + def visit_type_for_comprehension(self, t: TypeForComprehension, /) -> bool: + return self.query_types([t.element_expr, t.iter_type] + t.conditions) + def query_types(self, types: list[Type] | tuple[Type, ...]) -> bool: """Perform a query for a sequence of types using the strategy to combine the results.""" # Special-case for lists and tuples to allow mypyc to produce better code. diff --git a/mypy/types.py b/mypy/types.py index 9cb63d58ba89d..bafe5cc496671 100644 --- a/mypy/types.py +++ b/mypy/types.py @@ -462,6 +462,320 @@ def read(cls, data: ReadBuffer) -> TypeAliasType: return alias +class ComputedType(Type): + """Base class for types that represent unevaluated type-level computations. + + NOT a ProperType - must be expanded/evaluated before use in most type + operations. Analogous to TypeAliasType in that it wraps a computation + that produces a concrete type. + + Subclasses: + - TypeOperatorType: e.g., GetArg[T, Base, 0], Members[T] + - ConditionalType: e.g., X if IsSub[T, Base] else Y + - TypeForComprehension: e.g., *[Expr for x in Iter[T] if Cond] + """ + + __slots__ = () + + def expand(self) -> Type: + """Evaluate this computed type to produce a concrete type. + + Returns self if evaluation is not yet possible (e.g., contains unresolved type vars). + + Subclasses must implement this method. + """ + raise NotImplementedError + + +class TypeOperatorType(ComputedType): + """Represents an unevaluated type operator application, e.g., GetArg[T, Base, 0]. + + Stores a reference to the operator's TypeInfo and the type arguments. + Type operators are generic classes in typeshed marked with @_type_operator. + """ + + __slots__ = ("type", "args", "type_ref") + + def __init__( + self, + type: mypy.nodes.TypeInfo | None, # The TypeInfo for the operator (e.g., typing.GetArg) + args: list[Type], # The type arguments + line: int = -1, + column: int = -1, + ) -> None: + super().__init__(line, column) + self.type = type + self.args = args + self.type_ref: str | None = None + + def accept(self, visitor: TypeVisitor[T]) -> T: + return visitor.visit_type_operator_type(self) + + @property + def fullname(self) -> str: + if self.type is not None: + return self.type.fullname + assert self.type_ref is not None + return self.type_ref + + def expand(self) -> Type: + """Evaluate this type operator to produce a concrete type.""" + from mypy.typelevel import evaluate_type_operator + + return evaluate_type_operator(self) + + def __hash__(self) -> int: + return hash((self.fullname, tuple(self.args))) + + def __eq__(self, other: object) -> bool: + if not isinstance(other, TypeOperatorType): + return NotImplemented + return self.fullname == other.fullname and self.args == other.args + + def __repr__(self) -> str: + return f"TypeOperatorType({self.fullname}, {self.args})" + + def serialize(self) -> JsonDict: + data: JsonDict = { + ".class": "TypeOperatorType", + "type_ref": self.fullname, + "args": [arg.serialize() for arg in self.args], + } + return data + + @classmethod + def deserialize(cls, data: JsonDict) -> TypeOperatorType: + assert data[".class"] == "TypeOperatorType" + args: list[Type] = [] + if "args" in data: + args_list = data["args"] + assert isinstance(args_list, list) + args = [deserialize_type(arg) for arg in args_list] + typ = TypeOperatorType(None, args) + typ.type_ref = data["type_ref"] + return typ + + def copy_modified(self, *, args: list[Type] | None = None) -> TypeOperatorType: + return TypeOperatorType( + self.type, + args if args is not None else self.args.copy(), + self.line, + self.column, + ) + + def write(self, data: WriteBuffer) -> None: + write_tag(data, TYPE_OPERATOR_TYPE) + write_type_list(data, self.args) + write_str(data, self.fullname) + write_tag(data, END_TAG) + + @classmethod + def read(cls, data: ReadBuffer) -> TypeOperatorType: + typ = TypeOperatorType(None, read_type_list(data)) + typ.type_ref = read_str(data) + assert read_tag(data) == END_TAG + return typ + + +class ConditionalType(ComputedType): + """Represents `TrueType if IsSub[T, Base] else FalseType`. + + The condition is itself a type (should be IsSub[T, Base] or boolean combination). + """ + + __slots__ = ("condition", "true_type", "false_type") + + def __init__( + self, + condition: Type, # Should be IsSub[T, Base] or boolean combination thereof + true_type: Type, + false_type: Type, + line: int = -1, + column: int = -1, + ) -> None: + super().__init__(line, column) + self.condition = condition + self.true_type = true_type + self.false_type = false_type + + def accept(self, visitor: TypeVisitor[T]) -> T: + return visitor.visit_conditional_type(self) + + def expand(self) -> Type: + """Evaluate the condition and return the appropriate branch.""" + from mypy.typelevel import evaluate_conditional + + return evaluate_conditional(self) + + def __hash__(self) -> int: + return hash((self.condition, self.true_type, self.false_type)) + + def __eq__(self, other: object) -> bool: + if not isinstance(other, ConditionalType): + return NotImplemented + return ( + self.condition == other.condition + and self.true_type == other.true_type + and self.false_type == other.false_type + ) + + def __repr__(self) -> str: + return f"ConditionalType({self.true_type} if {self.condition} else {self.false_type})" + + def serialize(self) -> JsonDict: + return { + ".class": "ConditionalType", + "condition": self.condition.serialize(), + "true_type": self.true_type.serialize(), + "false_type": self.false_type.serialize(), + } + + @classmethod + def deserialize(cls, data: JsonDict) -> ConditionalType: + assert data[".class"] == "ConditionalType" + return ConditionalType( + deserialize_type(data["condition"]), + deserialize_type(data["true_type"]), + deserialize_type(data["false_type"]), + ) + + def copy_modified( + self, + *, + condition: Type | None = None, + true_type: Type | None = None, + false_type: Type | None = None, + ) -> ConditionalType: + return ConditionalType( + condition if condition is not None else self.condition, + true_type if true_type is not None else self.true_type, + false_type if false_type is not None else self.false_type, + self.line, + self.column, + ) + + def write(self, data: WriteBuffer) -> None: + write_tag(data, CONDITIONAL_TYPE) + self.condition.write(data) + self.true_type.write(data) + self.false_type.write(data) + write_tag(data, END_TAG) + + @classmethod + def read(cls, data: ReadBuffer) -> ConditionalType: + condition = read_type(data) + true_type = read_type(data) + false_type = read_type(data) + assert read_tag(data) == END_TAG + return ConditionalType(condition, true_type, false_type) + + +class TypeForComprehension(ComputedType): + """Represents *[Expr for var in Iter[T] if Cond]. + + Expands to a tuple of types. + """ + + __slots__ = ("element_expr", "iter_var", "iter_type", "conditions") + + def __init__( + self, + element_expr: Type, + iter_var: str, + iter_type: Type, # The type being iterated (should be a tuple type) + conditions: list[Type], # Each should be IsSub[...] or boolean combo + line: int = -1, + column: int = -1, + ) -> None: + super().__init__(line, column) + self.element_expr = element_expr + self.iter_var = iter_var + self.iter_type = iter_type + self.conditions = conditions + + def accept(self, visitor: TypeVisitor[T]) -> T: + return visitor.visit_type_for_comprehension(self) + + def expand(self) -> Type: + """Evaluate the comprehension to produce a tuple type.""" + from mypy.typelevel import evaluate_comprehension + + return evaluate_comprehension(self) + + def __hash__(self) -> int: + return hash((self.element_expr, self.iter_var, self.iter_type, tuple(self.conditions))) + + def __eq__(self, other: object) -> bool: + if not isinstance(other, TypeForComprehension): + return NotImplemented + return ( + self.element_expr == other.element_expr + and self.iter_var == other.iter_var + and self.iter_type == other.iter_type + and self.conditions == other.conditions + ) + + def __repr__(self) -> str: + conds = "".join(f" if {c}" for c in self.conditions) + return f"TypeForComprehension([{self.element_expr} for {self.iter_var} in {self.iter_type}{conds}])" + + def serialize(self) -> JsonDict: + return { + ".class": "TypeForComprehension", + "element_expr": self.element_expr.serialize(), + "iter_var": self.iter_var, + "iter_type": self.iter_type.serialize(), + "conditions": [c.serialize() for c in self.conditions], + } + + @classmethod + def deserialize(cls, data: JsonDict) -> TypeForComprehension: + assert data[".class"] == "TypeForComprehension" + return TypeForComprehension( + deserialize_type(data["element_expr"]), + data["iter_var"], + deserialize_type(data["iter_type"]), + [deserialize_type(c) for c in data["conditions"]], + ) + + def copy_modified( + self, + *, + element_expr: Type | None = None, + iter_var: str | None = None, + iter_type: Type | None = None, + conditions: list[Type] | None = None, + ) -> TypeForComprehension: + return TypeForComprehension( + element_expr if element_expr is not None else self.element_expr, + iter_var if iter_var is not None else self.iter_var, + iter_type if iter_type is not None else self.iter_type, + conditions if conditions is not None else self.conditions.copy(), + self.line, + self.column, + ) + + def write(self, data: WriteBuffer) -> None: + write_tag(data, TYPE_FOR_COMPREHENSION) + self.element_expr.write(data) + write_str(data, self.iter_var) + self.iter_type.write(data) + write_int(data, len(self.conditions)) + for cond in self.conditions: + cond.write(data) + write_tag(data, END_TAG) + + @classmethod + def read(cls, data: ReadBuffer) -> TypeForComprehension: + element_expr = read_type(data) + iter_var = read_str(data) + iter_type = read_type(data) + num_conditions = read_int(data) + conditions = [read_type(data) for _ in range(num_conditions)] + assert read_tag(data) == END_TAG + return TypeForComprehension(element_expr, iter_var, iter_type, conditions) + + class TypeGuardedType(Type): """Only used by find_isinstance_check() etc.""" @@ -513,9 +827,10 @@ def accept(self, visitor: TypeVisitor[T]) -> T: class ProperType(Type): - """Not a type alias. + """Not a type alias or computed type. - Every type except TypeAliasType must inherit from this type. + Every type except TypeAliasType and ComputedType (and its subclasses) + must inherit from this type. """ __slots__ = () @@ -3645,7 +3960,7 @@ def get_proper_type(typ: Type) -> ProperType: ... def get_proper_type(typ: Type | None) -> ProperType | None: - """Get the expansion of a type alias type. + """Get the expansion of a type alias type or computed type. If the type is already a proper type, this is a no-op. Use this function wherever a decision is made on a call like e.g. 'if isinstance(typ, UnionType): ...', @@ -3658,8 +3973,14 @@ def get_proper_type(typ: Type | None) -> ProperType | None: # TODO: this is an ugly hack, remove. if isinstance(typ, TypeGuardedType): typ = typ.type_guard - while isinstance(typ, TypeAliasType): - typ = typ._expand_once() + while True: + if isinstance(typ, TypeAliasType): + typ = typ._expand_once() + elif isinstance(typ, ComputedType): + # Handles TypeOperatorType, ConditionalType, TypeForComprehension + typ = typ.expand() + else: + break # TODO: store the name of original type alias on this type, so we can show it in errors. return cast(ProperType, typ) @@ -3680,7 +4001,9 @@ 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): + if not any( + isinstance(t, (TypeAliasType, TypeGuardedType, ComputedType)) for t in typelist + ): return cast("list[ProperType]", typelist) return [get_proper_type(t) for t in typelist] else: @@ -4333,6 +4656,9 @@ def type_vars_as_args(type_vars: Sequence[TypeVarLikeType]) -> tuple[Type, ...]: ELLIPSIS_TYPE: Final[Tag] = 119 # Only valid in serialized ASTs RAW_EXPRESSION_TYPE: Final[Tag] = 120 # Only valid in serialized ASTs CALL_TYPE: Final[Tag] = 121 # Only valid in serialized ASTs +TYPE_OPERATOR_TYPE: Final[Tag] = 122 +CONDITIONAL_TYPE: Final[Tag] = 123 +TYPE_FOR_COMPREHENSION: Final[Tag] = 124 def read_type(data: ReadBuffer, tag: Tag | None = None) -> Type: @@ -4377,6 +4703,12 @@ def read_type(data: ReadBuffer, tag: Tag | None = None) -> Type: return UnboundType.read(data) if tag == DELETED_TYPE: return DeletedType.read(data) + if tag == TYPE_OPERATOR_TYPE: + return TypeOperatorType.read(data) + if tag == CONDITIONAL_TYPE: + return ConditionalType.read(data) + if tag == TYPE_FOR_COMPREHENSION: + return TypeForComprehension.read(data) assert False, f"Unknown type tag {tag}" diff --git a/mypy/typeshed/stdlib/typing.pyi b/mypy/typeshed/stdlib/typing.pyi index 0bced03866439..b6ca6f66c3c10 100644 --- a/mypy/typeshed/stdlib/typing.pyi +++ b/mypy/typeshed/stdlib/typing.pyi @@ -1186,3 +1186,252 @@ if sys.version_info >= (3, 13): NoDefault: _NoDefaultType TypeIs: _SpecialForm ReadOnly: _SpecialForm + +# --- Type-level computation support --- + +# Marker decorator for type operators. Classes decorated with this are treated +# specially by the type checker as type-level computation operators. +def _type_operator(cls: type[_T]) -> type[_T]: ... + +# MemberQuals: qualifiers that can apply to a Member +MemberQuals: typing_extensions.TypeAlias = Literal["ClassVar", "Final"] + +# ParamQuals: qualifiers that can apply to a Param +ParamQuals: typing_extensions.TypeAlias = Literal["positional", "keyword", "default", "*", "**"] + +# --- Data Types (used in type computations) --- + +_Name = TypeVar("_Name") +_Type = TypeVar("_Type") +_Quals = TypeVar("_Quals") +_Init = TypeVar("_Init") +_Definer = TypeVar("_Definer") + +class Member(Generic[_Name, _Type, _Quals, _Init, _Definer]): + """ + Represents a class member with name, type, qualifiers, initializer, and definer. + - _Name: Literal[str] - the member name + - _Type: the member's type + - _Quals: Literal['ClassVar'] | Literal['Final'] | Never - qualifiers + - _Init: the literal type of the initializer expression + - _Definer: the class that defined this member + """ + + name: _Name + typ: _Type + quals: _Quals + init: _Init + definer: _Definer + +class Param(Generic[_Name, _Type, _Quals]): + """ + Represents a function parameter for extended callable syntax. + - _Name: Literal[str] | None - the parameter name + - _Type: the parameter's type + - _Quals: Literal['positional', 'keyword', 'default', '*', '**'] - qualifiers + """ + + name: _Name + typ: _Type + quals: _Quals + +# Convenience aliases for Param +type PosParam[N, T] = Param[N, T, Literal["positional"]] +type PosDefaultParam[N, T] = Param[N, T, Literal["positional", "default"]] +type DefaultParam[N, T] = Param[N, T, Literal["default"]] +type NamedParam[N, T] = Param[N, T, Literal["keyword"]] +type NamedDefaultParam[N, T] = Param[N, T, Literal["keyword", "default"]] +type ArgsParam[T] = Param[None, T, Literal["*"]] +type KwargsParam[T] = Param[None, T, Literal["**"]] + +# --- Type Introspection Operators --- + +_T = TypeVar("_T") +_Base = TypeVar("_Base") +_Idx = TypeVar("_Idx") +_S = TypeVar("_S") +_S1 = TypeVar("_S1") +_S2 = TypeVar("_S2") +_Start = TypeVar("_Start") +_End = TypeVar("_End") + +@_type_operator +class GetArg(Generic[_T, _Base, _Idx]): + """ + Get type argument at index _Idx from _T when viewed as _Base. + Returns Never if _T does not inherit from _Base or index is out of bounds. + """ + + ... + +@_type_operator +class GetArgs(Generic[_T, _Base]): + """ + Get all type arguments from _T when viewed as _Base, as a tuple. + Returns Never if _T does not inherit from _Base. + """ + + ... + +@_type_operator +class GetAttr(Generic[_T, _Name]): + """ + Get the type of attribute _Name from type _T. + _Name must be a Literal[str]. + """ + + ... + +@_type_operator +class Members(Generic[_T]): + """ + Get all members of type _T as a tuple of Member types. + Includes methods, class variables, and instance attributes. + """ + + ... + +@_type_operator +class Attrs(Generic[_T]): + """ + Get annotated instance attributes of _T as a tuple of Member types. + Excludes methods and ClassVar members. + """ + + ... + +@_type_operator +class FromUnion(Generic[_T]): + """ + Convert a union type to a tuple of its constituent types. + If _T is not a union, returns a 1-tuple containing _T. + """ + + ... + +# --- Member/Param Accessors (defined as type aliases using GetAttr) --- + +type GetName[T: Member[Any, Any, Any, Any, Any] | Param[Any, Any, Any]] = GetAttr[T, Literal["name"]] +type GetType[T: Member[Any, Any, Any, Any, Any] | Param[Any, Any, Any]] = GetAttr[T, Literal["typ"]] +type GetQuals[T: Member[Any, Any, Any, Any, Any] | Param[Any, Any, Any]] = GetAttr[T, Literal["quals"]] +type GetInit[T: Member[Any, Any, Any, Any, Any]] = GetAttr[T, Literal["init"]] +type GetDefiner[T: Member[Any, Any, Any, Any, Any]] = GetAttr[T, Literal["definer"]] + +# --- Type Construction Operators --- + +_Ts = TypeVarTuple("_Ts") if sys.version_info >= (3, 11) else TypeVar("_Ts") # type: ignore[misc] + +@_type_operator +class NewProtocol(Generic[Unpack[_Ts]] if sys.version_info >= (3, 11) else Generic[_Ts]): # type: ignore[misc] + """ + Construct a new structural (protocol) type from Member types. + NewProtocol[Member[...], Member[...], ...] creates an anonymous protocol. + """ + + ... + +@_type_operator +class NewTypedDict(Generic[Unpack[_Ts]] if sys.version_info >= (3, 11) else Generic[_Ts]): # type: ignore[misc] + """ + Construct a new TypedDict from Member types. + NewTypedDict[Member[...], Member[...], ...] creates an anonymous TypedDict. + """ + + ... + +# --- Boolean/Conditional Operators --- + +@_type_operator +class IsSub(Generic[_T, _Base]): + """ + Type-level subtype check. Evaluates to a type-level boolean. + Used in conditional type expressions: `X if IsSub[T, Base] else Y` + """ + + ... + +@_type_operator +class Iter(Generic[_T]): + """ + Marks a type for iteration in type comprehensions. + `for x in Iter[T]` iterates over elements of tuple type T. + """ + + ... + +# --- String Operations --- + +@_type_operator +class Slice(Generic[_S, _Start, _End]): + """ + Slice a literal string type. + Slice[Literal["hello"], Literal[1], Literal[3]] = Literal["el"] + """ + + ... + +@_type_operator +class Concat(Generic[_S1, _S2]): + """ + Concatenate two literal string types. + Concat[Literal["hello"], Literal["world"]] = Literal["helloworld"] + """ + + ... + +@_type_operator +class Uppercase(Generic[_S]): + """Convert literal string to uppercase.""" + + ... + +@_type_operator +class Lowercase(Generic[_S]): + """Convert literal string to lowercase.""" + + ... + +@_type_operator +class Capitalize(Generic[_S]): + """Capitalize first character of literal string.""" + + ... + +@_type_operator +class Uncapitalize(Generic[_S]): + """Lowercase first character of literal string.""" + + ... + +# --- Annotated Operations --- + +@_type_operator +class GetAnnotations(Generic[_T]): + """ + Extract Annotated metadata from a type. + GetAnnotations[Annotated[int, 'foo', 'bar']] = Literal['foo', 'bar'] + GetAnnotations[int] = Never + """ + + ... + +@_type_operator +class DropAnnotations(Generic[_T]): + """ + Strip Annotated wrapper from a type. + DropAnnotations[Annotated[int, 'foo']] = int + DropAnnotations[int] = int + """ + + ... + +# --- Utility Operators --- + +@_type_operator +class Length(Generic[_T]): + """ + Get the length of a tuple type as a Literal[int]. + Returns Literal[None] for unbounded tuples. + """ + + ... From 1f6f554c6e1401d339f871ee4ff7312d59c2af97 Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Tue, 20 Jan 2026 15:01:28 -0800 Subject: [PATCH 014/161] [typemap] Add visitor methods for computed types to all TypeVisitor subclasses Add visit_type_operator_type, visit_conditional_type, and visit_type_for_comprehension methods to all TypeVisitor subclasses throughout the codebase to support the new computed type system. Files updated: - mypy/types.py: TypeStrVisitor - mypy/copytype.py: TypeShallowCopier - mypy/erasetype.py: EraseTypeVisitor - mypy/fixup.py: TypeFixer - mypy/typetraverser.py: TypeTraverserVisitor - mypy/subtypes.py: SubtypeVisitor - mypy/constraints.py: ConstraintBuilderVisitor - mypy/join.py: TypeJoinVisitor - mypy/meet.py: TypeMeetVisitor - mypy/typeanal.py: TypeAnalyser, FindTypeVarVisitor - mypy/indirection.py: TypeIndirectionVisitor - mypy/server/astmerge.py: TypeReplaceVisitor - mypy/server/deps.py: TypeTriggersVisitor - mypy/server/astdiff.py: SnapshotTypeVisitor Also adds mypy/typelevel.py with stub implementations for type-level computation evaluation functions. Co-Authored-By: Claude Opus 4.5 --- mypy/constraints.py | 12 ++++++++++ mypy/copytype.py | 12 ++++++++++ mypy/erasetype.py | 12 ++++++++++ mypy/fixup.py | 18 +++++++++++++++ mypy/indirection.py | 15 +++++++++++++ mypy/join.py | 12 ++++++++++ mypy/meet.py | 12 ++++++++++ mypy/server/astdiff.py | 24 ++++++++++++++++++++ mypy/server/astmerge.py | 18 +++++++++++++++ mypy/server/deps.py | 26 ++++++++++++++++++++++ mypy/subtypes.py | 12 ++++++++++ mypy/typeanal.py | 28 +++++++++++++++++++++++ mypy/typelevel.py | 39 +++++++++++++++++++++++++++++++++ mypy/types.py | 19 ++++++++++++++-- mypy/typeshed/stdlib/typing.pyi | 24 ++++++++------------ mypy/typetraverser.py | 16 ++++++++++++++ 16 files changed, 282 insertions(+), 17 deletions(-) create mode 100644 mypy/typelevel.py diff --git a/mypy/constraints.py b/mypy/constraints.py index f95f140aeeac6..da5fb740cec05 100644 --- a/mypy/constraints.py +++ b/mypy/constraints.py @@ -24,6 +24,7 @@ TUPLE_LIKE_INSTANCE_NAMES, AnyType, CallableType, + ConditionalType, DeletedType, ErasedType, Instance, @@ -39,7 +40,9 @@ Type, TypeAliasType, TypedDictType, + TypeForComprehension, TypeOfAny, + TypeOperatorType, TypeType, TypeVarId, TypeVarLikeType, @@ -1428,6 +1431,15 @@ def visit_union_type(self, template: UnionType) -> list[Constraint]: def visit_type_alias_type(self, template: TypeAliasType) -> list[Constraint]: assert False, f"This should be never called, got {template}" + def visit_type_operator_type(self, template: TypeOperatorType) -> list[Constraint]: + assert False, f"Computed types should be expanded before constraint inference, got {template}" + + def visit_conditional_type(self, template: ConditionalType) -> list[Constraint]: + assert False, f"Computed types should be expanded before constraint inference, got {template}" + + def visit_type_for_comprehension(self, template: TypeForComprehension) -> list[Constraint]: + assert False, f"Computed types should be expanded before constraint inference, got {template}" + def infer_against_any(self, types: Iterable[Type], any_type: AnyType) -> list[Constraint]: res: list[Constraint] = [] # Some items may be things like `*Tuple[*Ts, T]` for example from callable types with diff --git a/mypy/copytype.py b/mypy/copytype.py index 9a390a01bdbab..463e7338056c2 100644 --- a/mypy/copytype.py +++ b/mypy/copytype.py @@ -5,6 +5,7 @@ from mypy.types import ( AnyType, CallableType, + ConditionalType, DeletedType, ErasedType, Instance, @@ -18,6 +19,8 @@ TupleType, TypeAliasType, TypedDictType, + TypeForComprehension, + TypeOperatorType, TypeType, TypeVarTupleType, TypeVarType, @@ -126,6 +129,15 @@ def visit_type_type(self, t: TypeType) -> ProperType: def visit_type_alias_type(self, t: TypeAliasType) -> ProperType: assert False, "only ProperTypes supported" + def visit_type_operator_type(self, t: TypeOperatorType) -> ProperType: + assert False, "only ProperTypes supported" + + def visit_conditional_type(self, t: ConditionalType) -> ProperType: + assert False, "only ProperTypes supported" + + def visit_type_for_comprehension(self, t: TypeForComprehension) -> ProperType: + assert False, "only ProperTypes supported" + def copy_common(self, t: ProperType, t2: ProperType) -> ProperType: t2.line = t.line t2.column = t.column diff --git a/mypy/erasetype.py b/mypy/erasetype.py index cb8d66f292dd3..335b70398a5de 100644 --- a/mypy/erasetype.py +++ b/mypy/erasetype.py @@ -7,6 +7,7 @@ from mypy.types import ( AnyType, CallableType, + ConditionalType, DeletedType, ErasedType, Instance, @@ -21,7 +22,9 @@ Type, TypeAliasType, TypedDictType, + TypeForComprehension, TypeOfAny, + TypeOperatorType, TypeTranslator, TypeType, TypeVarId, @@ -141,6 +144,15 @@ def visit_type_type(self, t: TypeType) -> ProperType: def visit_type_alias_type(self, t: TypeAliasType) -> ProperType: raise RuntimeError("Type aliases should be expanded before accepting this visitor") + def visit_type_operator_type(self, t: TypeOperatorType) -> ProperType: + raise RuntimeError("Computed types should be expanded before accepting this visitor") + + def visit_conditional_type(self, t: ConditionalType) -> ProperType: + raise RuntimeError("Computed types should be expanded before accepting this visitor") + + def visit_type_for_comprehension(self, t: TypeForComprehension) -> ProperType: + raise RuntimeError("Computed types should be expanded before accepting this visitor") + def erase_typevars(t: Type, ids_to_erase: Container[TypeVarId] | None = None) -> Type: """Replace all type variables in a type with any, diff --git a/mypy/fixup.py b/mypy/fixup.py index d0205f64b7207..d9abf51ea9de6 100644 --- a/mypy/fixup.py +++ b/mypy/fixup.py @@ -24,6 +24,7 @@ NOT_READY, AnyType, CallableType, + ConditionalType, Instance, LiteralType, Overloaded, @@ -33,7 +34,9 @@ TupleType, TypeAliasType, TypedDictType, + TypeForComprehension, TypeOfAny, + TypeOperatorType, TypeType, TypeVarTupleType, TypeVarType, @@ -376,6 +379,21 @@ def visit_union_type(self, ut: UnionType) -> None: def visit_type_type(self, t: TypeType) -> None: t.item.accept(self) + def visit_type_operator_type(self, t: TypeOperatorType) -> None: + for a in t.args: + a.accept(self) + + def visit_conditional_type(self, t: ConditionalType) -> None: + t.condition.accept(self) + t.true_type.accept(self) + t.false_type.accept(self) + + def visit_type_for_comprehension(self, t: TypeForComprehension) -> None: + t.element_expr.accept(self) + t.iter_type.accept(self) + for c in t.conditions: + c.accept(self) + def lookup_fully_qualified_typeinfo( modules: dict[str, MypyFile], name: str, *, allow_missing: bool diff --git a/mypy/indirection.py b/mypy/indirection.py index c5f3fa89b8c4a..949e351969035 100644 --- a/mypy/indirection.py +++ b/mypy/indirection.py @@ -168,3 +168,18 @@ def visit_type_alias_type(self, t: types.TypeAliasType) -> None: self.modules.add(t.alias.module) self._visit(t.alias.target) self._visit_type_list(t.args) + + def visit_type_operator_type(self, t: types.TypeOperatorType) -> None: + if t.type: + self.modules.add(t.type.module_name) + self._visit_type_list(t.args) + + def visit_conditional_type(self, t: types.ConditionalType) -> None: + self._visit(t.condition) + self._visit(t.true_type) + self._visit(t.false_type) + + def visit_type_for_comprehension(self, t: types.TypeForComprehension) -> None: + self._visit(t.element_expr) + self._visit(t.iter_type) + self._visit_type_list(t.conditions) diff --git a/mypy/join.py b/mypy/join.py index a8c9910e60bb7..cf045bce68526 100644 --- a/mypy/join.py +++ b/mypy/join.py @@ -21,6 +21,7 @@ from mypy.types import ( AnyType, CallableType, + ConditionalType, DeletedType, ErasedType, FunctionLike, @@ -36,7 +37,9 @@ Type, TypeAliasType, TypedDictType, + TypeForComprehension, TypeOfAny, + TypeOperatorType, TypeType, TypeVarId, TypeVarLikeType, @@ -665,6 +668,15 @@ def visit_type_type(self, t: TypeType) -> ProperType: def visit_type_alias_type(self, t: TypeAliasType) -> ProperType: assert False, f"This should be never called, got {t}" + def visit_type_operator_type(self, t: TypeOperatorType) -> ProperType: + assert False, f"Computed types should be expanded before join, got {t}" + + def visit_conditional_type(self, t: ConditionalType) -> ProperType: + assert False, f"Computed types should be expanded before join, got {t}" + + def visit_type_for_comprehension(self, t: TypeForComprehension) -> ProperType: + assert False, f"Computed types should be expanded before join, got {t}" + def default(self, typ: Type) -> ProperType: typ = get_proper_type(typ) if isinstance(typ, Instance): diff --git a/mypy/meet.py b/mypy/meet.py index ee32f239df8c3..a65daad308119 100644 --- a/mypy/meet.py +++ b/mypy/meet.py @@ -21,6 +21,7 @@ TUPLE_LIKE_INSTANCE_NAMES, AnyType, CallableType, + ConditionalType, DeletedType, ErasedType, FunctionLike, @@ -36,8 +37,10 @@ Type, TypeAliasType, TypedDictType, + TypeForComprehension, TypeGuardedType, TypeOfAny, + TypeOperatorType, TypeType, TypeVarLikeType, TypeVarTupleType, @@ -1135,6 +1138,15 @@ def visit_type_type(self, t: TypeType) -> ProperType: def visit_type_alias_type(self, t: TypeAliasType) -> ProperType: assert False, f"This should be never called, got {t}" + def visit_type_operator_type(self, t: TypeOperatorType) -> ProperType: + assert False, f"Computed types should be expanded before meet, got {t}" + + def visit_conditional_type(self, t: ConditionalType) -> ProperType: + assert False, f"Computed types should be expanded before meet, got {t}" + + def visit_type_for_comprehension(self, t: TypeForComprehension) -> ProperType: + assert False, f"Computed types should be expanded before meet, got {t}" + def meet(self, s: Type, t: Type) -> ProperType: return meet_types(s, t) diff --git a/mypy/server/astdiff.py b/mypy/server/astdiff.py index 9bbc3077ec512..58307264a3bed 100644 --- a/mypy/server/astdiff.py +++ b/mypy/server/astdiff.py @@ -78,6 +78,7 @@ class level -- these are handled at attribute level (say, 'mod.Cls.method' from mypy.types import ( AnyType, CallableType, + ConditionalType, DeletedType, ErasedType, Instance, @@ -91,6 +92,8 @@ class level -- these are handled at attribute level (say, 'mod.Cls.method' Type, TypeAliasType, TypedDictType, + TypeForComprehension, + TypeOperatorType, TypeType, TypeVarId, TypeVarLikeType, @@ -524,6 +527,27 @@ def visit_type_alias_type(self, typ: TypeAliasType) -> SnapshotItem: assert typ.alias is not None return ("TypeAliasType", typ.alias.fullname, snapshot_types(typ.args)) + def visit_type_operator_type(self, typ: TypeOperatorType) -> SnapshotItem: + name = typ.type.fullname if typ.type else "" + return ("TypeOperatorType", name, snapshot_types(typ.args)) + + def visit_conditional_type(self, typ: ConditionalType) -> SnapshotItem: + return ( + "ConditionalType", + snapshot_type(typ.condition), + snapshot_type(typ.true_type), + snapshot_type(typ.false_type), + ) + + def visit_type_for_comprehension(self, typ: TypeForComprehension) -> SnapshotItem: + return ( + "TypeForComprehension", + snapshot_type(typ.element_expr), + typ.iter_var, + snapshot_type(typ.iter_type), + snapshot_types(typ.conditions), + ) + def snapshot_untyped_signature(func: OverloadedFuncDef | FuncItem) -> SymbolSnapshot: """Create a snapshot of the signature of a function that has no explicit signature. diff --git a/mypy/server/astmerge.py b/mypy/server/astmerge.py index 56f2f935481c5..6ec3a19a68a8d 100644 --- a/mypy/server/astmerge.py +++ b/mypy/server/astmerge.py @@ -84,6 +84,7 @@ AnyType, CallableArgument, CallableType, + ConditionalType, DeletedType, EllipsisType, ErasedType, @@ -101,7 +102,9 @@ Type, TypeAliasType, TypedDictType, + TypeForComprehension, TypeList, + TypeOperatorType, TypeType, TypeVarTupleType, TypeVarType, @@ -539,6 +542,21 @@ def visit_union_type(self, typ: UnionType) -> None: for item in typ.items: item.accept(self) + def visit_type_operator_type(self, typ: TypeOperatorType) -> None: + for arg in typ.args: + arg.accept(self) + + def visit_conditional_type(self, typ: ConditionalType) -> None: + typ.condition.accept(self) + typ.true_type.accept(self) + typ.false_type.accept(self) + + def visit_type_for_comprehension(self, typ: TypeForComprehension) -> None: + typ.element_expr.accept(self) + typ.iter_type.accept(self) + for c in typ.conditions: + c.accept(self) + def visit_placeholder_type(self, t: PlaceholderType) -> None: for item in t.args: item.accept(self) diff --git a/mypy/server/deps.py b/mypy/server/deps.py index ba622329665ea..8fd8ad1385e2d 100644 --- a/mypy/server/deps.py +++ b/mypy/server/deps.py @@ -147,6 +147,7 @@ class 'mod.Cls'. This can also refer to an attribute inherited from a from mypy.types import ( AnyType, CallableType, + ConditionalType, DeletedType, ErasedType, FunctionLike, @@ -162,7 +163,9 @@ class 'mod.Cls'. This can also refer to an attribute inherited from a Type, TypeAliasType, TypedDictType, + TypeForComprehension, TypeOfAny, + TypeOperatorType, TypeType, TypeVarTupleType, TypeVarType, @@ -1097,6 +1100,29 @@ def visit_union_type(self, typ: UnionType) -> list[str]: triggers.extend(self.get_type_triggers(item)) return triggers + def visit_type_operator_type(self, typ: TypeOperatorType) -> list[str]: + triggers = [] + if typ.type: + triggers.append(make_trigger(typ.type.fullname)) + for arg in typ.args: + triggers.extend(self.get_type_triggers(arg)) + return triggers + + def visit_conditional_type(self, typ: ConditionalType) -> list[str]: + triggers = [] + triggers.extend(self.get_type_triggers(typ.condition)) + triggers.extend(self.get_type_triggers(typ.true_type)) + triggers.extend(self.get_type_triggers(typ.false_type)) + return triggers + + def visit_type_for_comprehension(self, typ: TypeForComprehension) -> list[str]: + triggers = [] + triggers.extend(self.get_type_triggers(typ.element_expr)) + triggers.extend(self.get_type_triggers(typ.iter_type)) + for cond in typ.conditions: + triggers.extend(self.get_type_triggers(cond)) + return triggers + def merge_dependencies(new_deps: dict[str, set[str]], deps: dict[str, set[str]]) -> None: for trigger, targets in new_deps.items(): diff --git a/mypy/subtypes.py b/mypy/subtypes.py index 66d7a95eb4252..e83fd1bae8ba1 100644 --- a/mypy/subtypes.py +++ b/mypy/subtypes.py @@ -41,6 +41,7 @@ TYPED_NAMEDTUPLE_NAMES, AnyType, CallableType, + ConditionalType, DeletedType, ErasedType, FormalArgument, @@ -58,7 +59,9 @@ Type, TypeAliasType, TypedDictType, + TypeForComprehension, TypeOfAny, + TypeOperatorType, TypeType, TypeVarTupleType, TypeVarType, @@ -1160,6 +1163,15 @@ def visit_type_type(self, left: TypeType) -> bool: def visit_type_alias_type(self, left: TypeAliasType) -> bool: assert False, f"This should be never called, got {left}" + def visit_type_operator_type(self, left: TypeOperatorType) -> bool: + assert False, f"Computed types should be expanded before subtype check, got {left}" + + def visit_conditional_type(self, left: ConditionalType) -> bool: + assert False, f"Computed types should be expanded before subtype check, got {left}" + + def visit_type_for_comprehension(self, left: TypeForComprehension) -> bool: + assert False, f"Computed types should be expanded before subtype check, got {left}" + T = TypeVar("T", bound=Type) diff --git a/mypy/typeanal.py b/mypy/typeanal.py index b22e1f80be592..4306914ce4892 100644 --- a/mypy/typeanal.py +++ b/mypy/typeanal.py @@ -75,6 +75,7 @@ BoolTypeQuery, CallableArgument, CallableType, + ConditionalType, DeletedType, EllipsisType, ErasedType, @@ -97,8 +98,10 @@ Type, TypeAliasType, TypedDictType, + TypeForComprehension, TypeList, TypeOfAny, + TypeOperatorType, TypeQuery, TypeType, TypeVarId, @@ -1112,6 +1115,18 @@ def visit_type_alias_type(self, t: TypeAliasType) -> Type: # TODO: should we do something here? return t + def visit_type_operator_type(self, t: TypeOperatorType) -> Type: + # Type operators are analyzed elsewhere + return t + + def visit_conditional_type(self, t: ConditionalType) -> Type: + # Conditional types are analyzed elsewhere + return t + + def visit_type_for_comprehension(self, t: TypeForComprehension) -> Type: + # Type comprehensions are analyzed elsewhere + return t + def visit_type_var(self, t: TypeVarType) -> Type: return t @@ -2712,6 +2727,19 @@ def visit_placeholder_type(self, t: PlaceholderType) -> None: def visit_type_alias_type(self, t: TypeAliasType) -> None: self.process_types(t.args) + def visit_type_operator_type(self, t: TypeOperatorType) -> None: + self.process_types(t.args) + + def visit_conditional_type(self, t: ConditionalType) -> None: + t.condition.accept(self) + t.true_type.accept(self) + t.false_type.accept(self) + + def visit_type_for_comprehension(self, t: TypeForComprehension) -> None: + t.element_expr.accept(self) + t.iter_type.accept(self) + self.process_types(t.conditions) + def process_types(self, types: list[Type] | tuple[Type, ...]) -> None: # Redundant type check helps mypyc. if isinstance(types, list): diff --git a/mypy/typelevel.py b/mypy/typelevel.py new file mode 100644 index 0000000000000..a443c6dff96af --- /dev/null +++ b/mypy/typelevel.py @@ -0,0 +1,39 @@ +"""Type-level computation evaluation. + +This module provides the evaluation functions for type-level computations +(TypeOperatorType, ConditionalType, TypeForComprehension). + +Note: This is a stub implementation. The full implementation will be added +in a later phase. +""" + +from __future__ import annotations + +from mypy.types import ComputedType, ConditionalType, Type, TypeForComprehension, TypeOperatorType + + +def evaluate_type_operator(typ: TypeOperatorType) -> Type: + """Evaluate a TypeOperatorType. Called from TypeOperatorType.expand(). + + Returns the type unchanged if evaluation is not yet possible. + """ + # Stub implementation - return the type unchanged + return typ + + +def evaluate_conditional(typ: ConditionalType) -> Type: + """Evaluate a ConditionalType. Called from ConditionalType.expand(). + + Returns the type unchanged if evaluation is not yet possible. + """ + # Stub implementation - return the type unchanged + return typ + + +def evaluate_comprehension(typ: TypeForComprehension) -> Type: + """Evaluate a TypeForComprehension. Called from TypeForComprehension.expand(). + + Returns the type unchanged if evaluation is not yet possible. + """ + # Stub implementation - return the type unchanged + return typ diff --git a/mypy/types.py b/mypy/types.py index bafe5cc496671..1f1f31444b8cf 100644 --- a/mypy/types.py +++ b/mypy/types.py @@ -3976,8 +3976,9 @@ def get_proper_type(typ: Type | None) -> ProperType | None: while True: if isinstance(typ, TypeAliasType): typ = typ._expand_once() - elif isinstance(typ, ComputedType): + elif isinstance(typ, ComputedType): # type: ignore[misc] # Handles TypeOperatorType, ConditionalType, TypeForComprehension + # Note: This isinstance check is intentional - this function does the expansion typ = typ.expand() else: break @@ -4002,7 +4003,8 @@ def get_proper_types( typelist = types # Optimize for the common case so that we don't need to allocate anything if not any( - isinstance(t, (TypeAliasType, TypeGuardedType, ComputedType)) for t in typelist + isinstance(t, (TypeAliasType, TypeGuardedType, ComputedType)) # type: ignore[misc] + for t in typelist ): return cast("list[ProperType]", typelist) return [get_proper_type(t) for t in typelist] @@ -4337,6 +4339,19 @@ def visit_unpack_type(self, t: UnpackType, /) -> str: return f"*{t.type.accept(self)}" return f"Unpack[{t.type.accept(self)}]" + def visit_type_operator_type(self, t: TypeOperatorType, /) -> str: + name = t.type.fullname if t.type else "" + return f"{name}[{self.list_str(t.args)}]" + + def visit_conditional_type(self, t: ConditionalType, /) -> str: + return f"({t.true_type.accept(self)} if {t.condition.accept(self)} else {t.false_type.accept(self)})" + + def visit_type_for_comprehension(self, t: TypeForComprehension, /) -> str: + conditions = "" + if t.conditions: + conditions = " if " + " if ".join(c.accept(self) for c in t.conditions) + return f"*[{t.element_expr.accept(self)} for {t.iter_var} in {t.iter_type.accept(self)}{conditions}]" + def list_str(self, a: Iterable[Type], *, use_or_syntax: bool = False) -> str: """Convert items of an array to strings (pretty-print types) and join the results with commas. diff --git a/mypy/typeshed/stdlib/typing.pyi b/mypy/typeshed/stdlib/typing.pyi index b6ca6f66c3c10..2f748795c535c 100644 --- a/mypy/typeshed/stdlib/typing.pyi +++ b/mypy/typeshed/stdlib/typing.pyi @@ -1235,14 +1235,9 @@ class Param(Generic[_Name, _Type, _Quals]): typ: _Type quals: _Quals -# Convenience aliases for Param -type PosParam[N, T] = Param[N, T, Literal["positional"]] -type PosDefaultParam[N, T] = Param[N, T, Literal["positional", "default"]] -type DefaultParam[N, T] = Param[N, T, Literal["default"]] -type NamedParam[N, T] = Param[N, T, Literal["keyword"]] -type NamedDefaultParam[N, T] = Param[N, T, Literal["keyword", "default"]] -type ArgsParam[T] = Param[None, T, Literal["*"]] -type KwargsParam[T] = Param[None, T, Literal["**"]] +# Note: Convenience aliases for Param like PosParam, NamedParam, etc. +# require Python 3.12+ type statement syntax and are not defined here. +# Users can write the full Param[N, T, Literal["positional"]] types directly. # --- Type Introspection Operators --- @@ -1309,13 +1304,12 @@ class FromUnion(Generic[_T]): ... -# --- Member/Param Accessors (defined as type aliases using GetAttr) --- - -type GetName[T: Member[Any, Any, Any, Any, Any] | Param[Any, Any, Any]] = GetAttr[T, Literal["name"]] -type GetType[T: Member[Any, Any, Any, Any, Any] | Param[Any, Any, Any]] = GetAttr[T, Literal["typ"]] -type GetQuals[T: Member[Any, Any, Any, Any, Any] | Param[Any, Any, Any]] = GetAttr[T, Literal["quals"]] -type GetInit[T: Member[Any, Any, Any, Any, Any]] = GetAttr[T, Literal["init"]] -type GetDefiner[T: Member[Any, Any, Any, Any, Any]] = GetAttr[T, Literal["definer"]] +# --- Member/Param Accessors --- +# Note: GetName, GetType, GetQuals, GetInit, GetDefiner are generic type aliases +# that require Python 3.12+ type statement syntax. Users can use GetAttr directly: +# GetAttr[T, Literal["name"]] instead of GetName[T] +# GetAttr[T, Literal["typ"]] instead of GetType[T] +# etc. # --- Type Construction Operators --- diff --git a/mypy/typetraverser.py b/mypy/typetraverser.py index abd0f6bf3bdfe..8baefdf9e5489 100644 --- a/mypy/typetraverser.py +++ b/mypy/typetraverser.py @@ -8,6 +8,7 @@ AnyType, CallableArgument, CallableType, + ConditionalType, DeletedType, EllipsisType, ErasedType, @@ -25,7 +26,9 @@ Type, TypeAliasType, TypedDictType, + TypeForComprehension, TypeList, + TypeOperatorType, TypeType, TypeVarTupleType, TypeVarType, @@ -142,6 +145,19 @@ def visit_type_alias_type(self, t: TypeAliasType, /) -> None: def visit_unpack_type(self, t: UnpackType, /) -> None: t.type.accept(self) + def visit_type_operator_type(self, t: TypeOperatorType, /) -> None: + self.traverse_type_list(t.args) + + def visit_conditional_type(self, t: ConditionalType, /) -> None: + t.condition.accept(self) + t.true_type.accept(self) + t.false_type.accept(self) + + def visit_type_for_comprehension(self, t: TypeForComprehension, /) -> None: + t.element_expr.accept(self) + t.iter_type.accept(self) + self.traverse_type_list(t.conditions) + # Helpers def traverse_types(self, types: Iterable[Type], /) -> None: From 2bb3512618172f2bc6171759c7c3f072d5bce302 Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Tue, 20 Jan 2026 15:38:27 -0800 Subject: [PATCH 015/161] Restore the helper type aliases that I want --- mypy/typeanal.py | 15 +++++++++++++++ mypy/typeshed/stdlib/typing.pyi | 32 ++++++++++++++++++++++---------- 2 files changed, 37 insertions(+), 10 deletions(-) diff --git a/mypy/typeanal.py b/mypy/typeanal.py index 4306914ce4892..393b1117aab05 100644 --- a/mypy/typeanal.py +++ b/mypy/typeanal.py @@ -1753,6 +1753,13 @@ def analyze_literal_param(self, idx: int, arg: Type, ctx: Context) -> list[Type] and arg.original_str_expr is not None ): assert arg.original_str_fallback is not None + # XXX: Since adding some Literals to typing.pyi, sometimes + # they get processed before builtins.str is available. + # Fix this by deferring, I guess. + if self.api.lookup_fully_qualified_or_none(arg.original_str_fallback) is None: + self.api.defer() + return None + return [ LiteralType( value=arg.original_str_expr, @@ -1808,6 +1815,14 @@ def analyze_literal_param(self, idx: int, arg: Type, ctx: Context) -> list[Type] return None # Remap bytes and unicode into the appropriate type for the correct Python version + + # XXX: Since adding some Literals to typing.pyi, sometimes + # they get processed before builtins.str is available. + # Fix this by deferring, I guess. + if self.api.lookup_fully_qualified_or_none(arg.base_type_name) is None: + self.api.defer() + return None + fallback = self.named_type(arg.base_type_name) assert isinstance(fallback, Instance) return [LiteralType(arg.literal_value, fallback, line=arg.line, column=arg.column)] diff --git a/mypy/typeshed/stdlib/typing.pyi b/mypy/typeshed/stdlib/typing.pyi index 2f748795c535c..0df732eca457e 100644 --- a/mypy/typeshed/stdlib/typing.pyi +++ b/mypy/typeshed/stdlib/typing.pyi @@ -1235,13 +1235,20 @@ class Param(Generic[_Name, _Type, _Quals]): typ: _Type quals: _Quals -# Note: Convenience aliases for Param like PosParam, NamedParam, etc. -# require Python 3.12+ type statement syntax and are not defined here. -# Users can write the full Param[N, T, Literal["positional"]] types directly. + +_N = TypeVar("_N", bound=str) + +# Convenience aliases for Param +PosParam: typing_extensions.TypeAlias = Param[_N, _T, Literal["positional"]] +PosDefaultParam: typing_extensions.TypeAlias = Param[_N, _T, Literal["positional", "default"]] +DefaultParam: typing_extensions.TypeAlias = Param[_N, _T, Literal["default"]] +NamedParam: typing_extensions.TypeAlias = Param[_N, _T, Literal["keyword"]] +NamedDefaultParam: typing_extensions.TypeAlias = Param[_N, _T, Literal["keyword", "default"]] +ArgsParam: typing_extensions.TypeAlias = Param[None, _T, Literal["*"]] +KwargsParam: typing_extensions.TypeAlias = Param[None, _T, Literal["**"]] # --- Type Introspection Operators --- -_T = TypeVar("_T") _Base = TypeVar("_Base") _Idx = TypeVar("_Idx") _S = TypeVar("_S") @@ -1304,12 +1311,17 @@ class FromUnion(Generic[_T]): ... -# --- Member/Param Accessors --- -# Note: GetName, GetType, GetQuals, GetInit, GetDefiner are generic type aliases -# that require Python 3.12+ type statement syntax. Users can use GetAttr directly: -# GetAttr[T, Literal["name"]] instead of GetName[T] -# GetAttr[T, Literal["typ"]] instead of GetType[T] -# etc. +# --- Member/Param Accessors (defined as type aliases using GetAttr) --- + +_MP = TypeVar("_MP", bound=Member[Any, Any, Any, Any, Any] | Param[Any, Any, Any]) +_M = TypeVar("_M", bound=Member[Any, Any, Any, Any, Any]) + + +GetName: typing_extensions.TypeAlias = GetAttr[_MP, Literal["name"]] +GetType: typing_extensions.TypeAlias = GetAttr[_MP, Literal["typ"]] +GetQuals: typing_extensions.TypeAlias = GetAttr[_MP, Literal["quals"]] +GetInit: typing_extensions.TypeAlias = GetAttr[_M, Literal["init"]] +GetDefiner: typing_extensions.TypeAlias = GetAttr[_M, Literal["definer"]] # --- Type Construction Operators --- From c88fdcfcc8afbb292e0338f94739473f3dc97f21 Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Tue, 20 Jan 2026 16:05:39 -0800 Subject: [PATCH 016/161] [typemap] Remove ConditionalType class in favor of _Cond type operator Consolidate conditional type handling by removing the separate ConditionalType class and using _Cond[condition, TrueType, FalseType] as a TypeOperatorType instead. This simplifies the type system by having one unified mechanism for type-level computations. Co-Authored-By: Claude Opus 4.5 --- mypy/constraints.py | 4 -- mypy/copytype.py | 4 -- mypy/erasetype.py | 4 -- mypy/fixup.py | 6 -- mypy/indirection.py | 5 -- mypy/join.py | 4 -- mypy/meet.py | 4 -- mypy/server/astdiff.py | 9 --- mypy/server/astmerge.py | 6 -- mypy/server/deps.py | 8 --- mypy/subtypes.py | 4 -- mypy/type_visitor.py | 18 ------ mypy/typeanal.py | 10 --- mypy/typelevel.py | 15 ++--- mypy/types.py | 106 +------------------------------- mypy/typeshed/stdlib/typing.pyi | 15 ++++- mypy/typetraverser.py | 6 -- 17 files changed, 21 insertions(+), 207 deletions(-) diff --git a/mypy/constraints.py b/mypy/constraints.py index da5fb740cec05..fbc793a1beded 100644 --- a/mypy/constraints.py +++ b/mypy/constraints.py @@ -24,7 +24,6 @@ TUPLE_LIKE_INSTANCE_NAMES, AnyType, CallableType, - ConditionalType, DeletedType, ErasedType, Instance, @@ -1434,9 +1433,6 @@ def visit_type_alias_type(self, template: TypeAliasType) -> list[Constraint]: def visit_type_operator_type(self, template: TypeOperatorType) -> list[Constraint]: assert False, f"Computed types should be expanded before constraint inference, got {template}" - def visit_conditional_type(self, template: ConditionalType) -> list[Constraint]: - assert False, f"Computed types should be expanded before constraint inference, got {template}" - def visit_type_for_comprehension(self, template: TypeForComprehension) -> list[Constraint]: assert False, f"Computed types should be expanded before constraint inference, got {template}" diff --git a/mypy/copytype.py b/mypy/copytype.py index 463e7338056c2..c74518de86b01 100644 --- a/mypy/copytype.py +++ b/mypy/copytype.py @@ -5,7 +5,6 @@ from mypy.types import ( AnyType, CallableType, - ConditionalType, DeletedType, ErasedType, Instance, @@ -132,9 +131,6 @@ def visit_type_alias_type(self, t: TypeAliasType) -> ProperType: def visit_type_operator_type(self, t: TypeOperatorType) -> ProperType: assert False, "only ProperTypes supported" - def visit_conditional_type(self, t: ConditionalType) -> ProperType: - assert False, "only ProperTypes supported" - def visit_type_for_comprehension(self, t: TypeForComprehension) -> ProperType: assert False, "only ProperTypes supported" diff --git a/mypy/erasetype.py b/mypy/erasetype.py index 335b70398a5de..e75625a613658 100644 --- a/mypy/erasetype.py +++ b/mypy/erasetype.py @@ -7,7 +7,6 @@ from mypy.types import ( AnyType, CallableType, - ConditionalType, DeletedType, ErasedType, Instance, @@ -147,9 +146,6 @@ def visit_type_alias_type(self, t: TypeAliasType) -> ProperType: def visit_type_operator_type(self, t: TypeOperatorType) -> ProperType: raise RuntimeError("Computed types should be expanded before accepting this visitor") - def visit_conditional_type(self, t: ConditionalType) -> ProperType: - raise RuntimeError("Computed types should be expanded before accepting this visitor") - def visit_type_for_comprehension(self, t: TypeForComprehension) -> ProperType: raise RuntimeError("Computed types should be expanded before accepting this visitor") diff --git a/mypy/fixup.py b/mypy/fixup.py index d9abf51ea9de6..437e4ae55b220 100644 --- a/mypy/fixup.py +++ b/mypy/fixup.py @@ -24,7 +24,6 @@ NOT_READY, AnyType, CallableType, - ConditionalType, Instance, LiteralType, Overloaded, @@ -383,11 +382,6 @@ def visit_type_operator_type(self, t: TypeOperatorType) -> None: for a in t.args: a.accept(self) - def visit_conditional_type(self, t: ConditionalType) -> None: - t.condition.accept(self) - t.true_type.accept(self) - t.false_type.accept(self) - def visit_type_for_comprehension(self, t: TypeForComprehension) -> None: t.element_expr.accept(self) t.iter_type.accept(self) diff --git a/mypy/indirection.py b/mypy/indirection.py index 949e351969035..f0b35caa16740 100644 --- a/mypy/indirection.py +++ b/mypy/indirection.py @@ -174,11 +174,6 @@ def visit_type_operator_type(self, t: types.TypeOperatorType) -> None: self.modules.add(t.type.module_name) self._visit_type_list(t.args) - def visit_conditional_type(self, t: types.ConditionalType) -> None: - self._visit(t.condition) - self._visit(t.true_type) - self._visit(t.false_type) - def visit_type_for_comprehension(self, t: types.TypeForComprehension) -> None: self._visit(t.element_expr) self._visit(t.iter_type) diff --git a/mypy/join.py b/mypy/join.py index cf045bce68526..a7c6776872637 100644 --- a/mypy/join.py +++ b/mypy/join.py @@ -21,7 +21,6 @@ from mypy.types import ( AnyType, CallableType, - ConditionalType, DeletedType, ErasedType, FunctionLike, @@ -671,9 +670,6 @@ def visit_type_alias_type(self, t: TypeAliasType) -> ProperType: def visit_type_operator_type(self, t: TypeOperatorType) -> ProperType: assert False, f"Computed types should be expanded before join, got {t}" - def visit_conditional_type(self, t: ConditionalType) -> ProperType: - assert False, f"Computed types should be expanded before join, got {t}" - def visit_type_for_comprehension(self, t: TypeForComprehension) -> ProperType: assert False, f"Computed types should be expanded before join, got {t}" diff --git a/mypy/meet.py b/mypy/meet.py index a65daad308119..056fbcd03a2c7 100644 --- a/mypy/meet.py +++ b/mypy/meet.py @@ -21,7 +21,6 @@ TUPLE_LIKE_INSTANCE_NAMES, AnyType, CallableType, - ConditionalType, DeletedType, ErasedType, FunctionLike, @@ -1141,9 +1140,6 @@ def visit_type_alias_type(self, t: TypeAliasType) -> ProperType: def visit_type_operator_type(self, t: TypeOperatorType) -> ProperType: assert False, f"Computed types should be expanded before meet, got {t}" - def visit_conditional_type(self, t: ConditionalType) -> ProperType: - assert False, f"Computed types should be expanded before meet, got {t}" - def visit_type_for_comprehension(self, t: TypeForComprehension) -> ProperType: assert False, f"Computed types should be expanded before meet, got {t}" diff --git a/mypy/server/astdiff.py b/mypy/server/astdiff.py index 58307264a3bed..b9d3473fdf726 100644 --- a/mypy/server/astdiff.py +++ b/mypy/server/astdiff.py @@ -78,7 +78,6 @@ class level -- these are handled at attribute level (say, 'mod.Cls.method' from mypy.types import ( AnyType, CallableType, - ConditionalType, DeletedType, ErasedType, Instance, @@ -531,14 +530,6 @@ def visit_type_operator_type(self, typ: TypeOperatorType) -> SnapshotItem: name = typ.type.fullname if typ.type else "" return ("TypeOperatorType", name, snapshot_types(typ.args)) - def visit_conditional_type(self, typ: ConditionalType) -> SnapshotItem: - return ( - "ConditionalType", - snapshot_type(typ.condition), - snapshot_type(typ.true_type), - snapshot_type(typ.false_type), - ) - def visit_type_for_comprehension(self, typ: TypeForComprehension) -> SnapshotItem: return ( "TypeForComprehension", diff --git a/mypy/server/astmerge.py b/mypy/server/astmerge.py index 6ec3a19a68a8d..280d31a34e01d 100644 --- a/mypy/server/astmerge.py +++ b/mypy/server/astmerge.py @@ -84,7 +84,6 @@ AnyType, CallableArgument, CallableType, - ConditionalType, DeletedType, EllipsisType, ErasedType, @@ -546,11 +545,6 @@ def visit_type_operator_type(self, typ: TypeOperatorType) -> None: for arg in typ.args: arg.accept(self) - def visit_conditional_type(self, typ: ConditionalType) -> None: - typ.condition.accept(self) - typ.true_type.accept(self) - typ.false_type.accept(self) - def visit_type_for_comprehension(self, typ: TypeForComprehension) -> None: typ.element_expr.accept(self) typ.iter_type.accept(self) diff --git a/mypy/server/deps.py b/mypy/server/deps.py index 8fd8ad1385e2d..d55471d8da133 100644 --- a/mypy/server/deps.py +++ b/mypy/server/deps.py @@ -147,7 +147,6 @@ class 'mod.Cls'. This can also refer to an attribute inherited from a from mypy.types import ( AnyType, CallableType, - ConditionalType, DeletedType, ErasedType, FunctionLike, @@ -1108,13 +1107,6 @@ def visit_type_operator_type(self, typ: TypeOperatorType) -> list[str]: triggers.extend(self.get_type_triggers(arg)) return triggers - def visit_conditional_type(self, typ: ConditionalType) -> list[str]: - triggers = [] - triggers.extend(self.get_type_triggers(typ.condition)) - triggers.extend(self.get_type_triggers(typ.true_type)) - triggers.extend(self.get_type_triggers(typ.false_type)) - return triggers - def visit_type_for_comprehension(self, typ: TypeForComprehension) -> list[str]: triggers = [] triggers.extend(self.get_type_triggers(typ.element_expr)) diff --git a/mypy/subtypes.py b/mypy/subtypes.py index e83fd1bae8ba1..ab4fa1101052d 100644 --- a/mypy/subtypes.py +++ b/mypy/subtypes.py @@ -41,7 +41,6 @@ TYPED_NAMEDTUPLE_NAMES, AnyType, CallableType, - ConditionalType, DeletedType, ErasedType, FormalArgument, @@ -1166,9 +1165,6 @@ def visit_type_alias_type(self, left: TypeAliasType) -> bool: def visit_type_operator_type(self, left: TypeOperatorType) -> bool: assert False, f"Computed types should be expanded before subtype check, got {left}" - def visit_conditional_type(self, left: ConditionalType) -> bool: - assert False, f"Computed types should be expanded before subtype check, got {left}" - def visit_type_for_comprehension(self, left: TypeForComprehension) -> bool: assert False, f"Computed types should be expanded before subtype check, got {left}" diff --git a/mypy/type_visitor.py b/mypy/type_visitor.py index 1960998a8a231..91ae19bcb8656 100644 --- a/mypy/type_visitor.py +++ b/mypy/type_visitor.py @@ -23,7 +23,6 @@ AnyType, CallableArgument, CallableType, - ConditionalType, DeletedType, EllipsisType, ErasedType, @@ -153,10 +152,6 @@ def visit_unpack_type(self, t: UnpackType, /) -> T: def visit_type_operator_type(self, t: TypeOperatorType, /) -> T: pass - @abstractmethod - def visit_conditional_type(self, t: ConditionalType, /) -> T: - pass - @abstractmethod def visit_type_for_comprehension(self, t: TypeForComprehension, /) -> T: pass @@ -358,13 +353,6 @@ def visit_type_alias_type(self, t: TypeAliasType, /) -> Type: def visit_type_operator_type(self, t: TypeOperatorType, /) -> Type: return t.copy_modified(args=self.translate_type_list(t.args)) - def visit_conditional_type(self, t: ConditionalType, /) -> Type: - return t.copy_modified( - condition=t.condition.accept(self), - true_type=t.true_type.accept(self), - false_type=t.false_type.accept(self), - ) - def visit_type_for_comprehension(self, t: TypeForComprehension, /) -> Type: return t.copy_modified( element_expr=t.element_expr.accept(self), @@ -491,9 +479,6 @@ def visit_type_alias_type(self, t: TypeAliasType, /) -> T: def visit_type_operator_type(self, t: TypeOperatorType, /) -> T: return self.query_types(t.args) - def visit_conditional_type(self, t: ConditionalType, /) -> T: - return self.query_types([t.condition, t.true_type, t.false_type]) - def visit_type_for_comprehension(self, t: TypeForComprehension, /) -> T: return self.query_types([t.element_expr, t.iter_type] + t.conditions) @@ -641,9 +626,6 @@ def visit_type_alias_type(self, t: TypeAliasType, /) -> bool: def visit_type_operator_type(self, t: TypeOperatorType, /) -> bool: return self.query_types(t.args) - def visit_conditional_type(self, t: ConditionalType, /) -> bool: - return self.query_types([t.condition, t.true_type, t.false_type]) - def visit_type_for_comprehension(self, t: TypeForComprehension, /) -> bool: return self.query_types([t.element_expr, t.iter_type] + t.conditions) diff --git a/mypy/typeanal.py b/mypy/typeanal.py index 393b1117aab05..a1fe014f73ea2 100644 --- a/mypy/typeanal.py +++ b/mypy/typeanal.py @@ -75,7 +75,6 @@ BoolTypeQuery, CallableArgument, CallableType, - ConditionalType, DeletedType, EllipsisType, ErasedType, @@ -1119,10 +1118,6 @@ def visit_type_operator_type(self, t: TypeOperatorType) -> Type: # Type operators are analyzed elsewhere return t - def visit_conditional_type(self, t: ConditionalType) -> Type: - # Conditional types are analyzed elsewhere - return t - def visit_type_for_comprehension(self, t: TypeForComprehension) -> Type: # Type comprehensions are analyzed elsewhere return t @@ -2745,11 +2740,6 @@ def visit_type_alias_type(self, t: TypeAliasType) -> None: def visit_type_operator_type(self, t: TypeOperatorType) -> None: self.process_types(t.args) - def visit_conditional_type(self, t: ConditionalType) -> None: - t.condition.accept(self) - t.true_type.accept(self) - t.false_type.accept(self) - def visit_type_for_comprehension(self, t: TypeForComprehension) -> None: t.element_expr.accept(self) t.iter_type.accept(self) diff --git a/mypy/typelevel.py b/mypy/typelevel.py index a443c6dff96af..07204c69dd6e3 100644 --- a/mypy/typelevel.py +++ b/mypy/typelevel.py @@ -1,7 +1,9 @@ """Type-level computation evaluation. This module provides the evaluation functions for type-level computations -(TypeOperatorType, ConditionalType, TypeForComprehension). +(TypeOperatorType, TypeForComprehension). + +Note: Conditional types are now represented as _Cond[...] TypeOperatorType. Note: This is a stub implementation. The full implementation will be added in a later phase. @@ -9,7 +11,7 @@ from __future__ import annotations -from mypy.types import ComputedType, ConditionalType, Type, TypeForComprehension, TypeOperatorType +from mypy.types import ComputedType, Type, TypeForComprehension, TypeOperatorType def evaluate_type_operator(typ: TypeOperatorType) -> Type: @@ -21,15 +23,6 @@ def evaluate_type_operator(typ: TypeOperatorType) -> Type: return typ -def evaluate_conditional(typ: ConditionalType) -> Type: - """Evaluate a ConditionalType. Called from ConditionalType.expand(). - - Returns the type unchanged if evaluation is not yet possible. - """ - # Stub implementation - return the type unchanged - return typ - - def evaluate_comprehension(typ: TypeForComprehension) -> Type: """Evaluate a TypeForComprehension. Called from TypeForComprehension.expand(). diff --git a/mypy/types.py b/mypy/types.py index 1f1f31444b8cf..782d8dd318adf 100644 --- a/mypy/types.py +++ b/mypy/types.py @@ -470,8 +470,7 @@ class ComputedType(Type): that produces a concrete type. Subclasses: - - TypeOperatorType: e.g., GetArg[T, Base, 0], Members[T] - - ConditionalType: e.g., X if IsSub[T, Base] else Y + - TypeOperatorType: e.g., GetArg[T, Base, 0], Members[T], _Cond[IsSub[T, Base], X, Y] - TypeForComprehension: e.g., *[Expr for x in Iter[T] if Cond] """ @@ -577,99 +576,6 @@ def read(cls, data: ReadBuffer) -> TypeOperatorType: return typ -class ConditionalType(ComputedType): - """Represents `TrueType if IsSub[T, Base] else FalseType`. - - The condition is itself a type (should be IsSub[T, Base] or boolean combination). - """ - - __slots__ = ("condition", "true_type", "false_type") - - def __init__( - self, - condition: Type, # Should be IsSub[T, Base] or boolean combination thereof - true_type: Type, - false_type: Type, - line: int = -1, - column: int = -1, - ) -> None: - super().__init__(line, column) - self.condition = condition - self.true_type = true_type - self.false_type = false_type - - def accept(self, visitor: TypeVisitor[T]) -> T: - return visitor.visit_conditional_type(self) - - def expand(self) -> Type: - """Evaluate the condition and return the appropriate branch.""" - from mypy.typelevel import evaluate_conditional - - return evaluate_conditional(self) - - def __hash__(self) -> int: - return hash((self.condition, self.true_type, self.false_type)) - - def __eq__(self, other: object) -> bool: - if not isinstance(other, ConditionalType): - return NotImplemented - return ( - self.condition == other.condition - and self.true_type == other.true_type - and self.false_type == other.false_type - ) - - def __repr__(self) -> str: - return f"ConditionalType({self.true_type} if {self.condition} else {self.false_type})" - - def serialize(self) -> JsonDict: - return { - ".class": "ConditionalType", - "condition": self.condition.serialize(), - "true_type": self.true_type.serialize(), - "false_type": self.false_type.serialize(), - } - - @classmethod - def deserialize(cls, data: JsonDict) -> ConditionalType: - assert data[".class"] == "ConditionalType" - return ConditionalType( - deserialize_type(data["condition"]), - deserialize_type(data["true_type"]), - deserialize_type(data["false_type"]), - ) - - def copy_modified( - self, - *, - condition: Type | None = None, - true_type: Type | None = None, - false_type: Type | None = None, - ) -> ConditionalType: - return ConditionalType( - condition if condition is not None else self.condition, - true_type if true_type is not None else self.true_type, - false_type if false_type is not None else self.false_type, - self.line, - self.column, - ) - - def write(self, data: WriteBuffer) -> None: - write_tag(data, CONDITIONAL_TYPE) - self.condition.write(data) - self.true_type.write(data) - self.false_type.write(data) - write_tag(data, END_TAG) - - @classmethod - def read(cls, data: ReadBuffer) -> ConditionalType: - condition = read_type(data) - true_type = read_type(data) - false_type = read_type(data) - assert read_tag(data) == END_TAG - return ConditionalType(condition, true_type, false_type) - - class TypeForComprehension(ComputedType): """Represents *[Expr for var in Iter[T] if Cond]. @@ -3977,7 +3883,7 @@ def get_proper_type(typ: Type | None) -> ProperType | None: if isinstance(typ, TypeAliasType): typ = typ._expand_once() elif isinstance(typ, ComputedType): # type: ignore[misc] - # Handles TypeOperatorType, ConditionalType, TypeForComprehension + # Handles TypeOperatorType, TypeForComprehension # Note: This isinstance check is intentional - this function does the expansion typ = typ.expand() else: @@ -4343,9 +4249,6 @@ def visit_type_operator_type(self, t: TypeOperatorType, /) -> str: name = t.type.fullname if t.type else "" return f"{name}[{self.list_str(t.args)}]" - def visit_conditional_type(self, t: ConditionalType, /) -> str: - return f"({t.true_type.accept(self)} if {t.condition.accept(self)} else {t.false_type.accept(self)})" - def visit_type_for_comprehension(self, t: TypeForComprehension, /) -> str: conditions = "" if t.conditions: @@ -4672,8 +4575,7 @@ def type_vars_as_args(type_vars: Sequence[TypeVarLikeType]) -> tuple[Type, ...]: RAW_EXPRESSION_TYPE: Final[Tag] = 120 # Only valid in serialized ASTs CALL_TYPE: Final[Tag] = 121 # Only valid in serialized ASTs TYPE_OPERATOR_TYPE: Final[Tag] = 122 -CONDITIONAL_TYPE: Final[Tag] = 123 -TYPE_FOR_COMPREHENSION: Final[Tag] = 124 +TYPE_FOR_COMPREHENSION: Final[Tag] = 123 def read_type(data: ReadBuffer, tag: Tag | None = None) -> Type: @@ -4720,8 +4622,6 @@ def read_type(data: ReadBuffer, tag: Tag | None = None) -> Type: return DeletedType.read(data) if tag == TYPE_OPERATOR_TYPE: return TypeOperatorType.read(data) - if tag == CONDITIONAL_TYPE: - return ConditionalType.read(data) if tag == TYPE_FOR_COMPREHENSION: return TypeForComprehension.read(data) assert False, f"Unknown type tag {tag}" diff --git a/mypy/typeshed/stdlib/typing.pyi b/mypy/typeshed/stdlib/typing.pyi index 0df732eca457e..3db2b80b5ffe8 100644 --- a/mypy/typeshed/stdlib/typing.pyi +++ b/mypy/typeshed/stdlib/typing.pyi @@ -1351,7 +1351,20 @@ class NewTypedDict(Generic[Unpack[_Ts]] if sys.version_info >= (3, 11) else Gene class IsSub(Generic[_T, _Base]): """ Type-level subtype check. Evaluates to a type-level boolean. - Used in conditional type expressions: `X if IsSub[T, Base] else Y` + Used in conditional type expressions: `_Cond[IsSub[T, Base], X, Y]` + """ + + ... + +_TrueType = TypeVar("_TrueType") +_FalseType = TypeVar("_FalseType") + +@_type_operator +class _Cond(Generic[_T, _TrueType, _FalseType]): + """ + Type-level conditional expression. + _Cond[IsSub[T, Base], TrueType, FalseType] evaluates to TrueType if T is a subtype of Base, + otherwise FalseType. """ ... diff --git a/mypy/typetraverser.py b/mypy/typetraverser.py index 8baefdf9e5489..f1dc4dce60770 100644 --- a/mypy/typetraverser.py +++ b/mypy/typetraverser.py @@ -8,7 +8,6 @@ AnyType, CallableArgument, CallableType, - ConditionalType, DeletedType, EllipsisType, ErasedType, @@ -148,11 +147,6 @@ def visit_unpack_type(self, t: UnpackType, /) -> None: def visit_type_operator_type(self, t: TypeOperatorType, /) -> None: self.traverse_type_list(t.args) - def visit_conditional_type(self, t: ConditionalType, /) -> None: - t.condition.accept(self) - t.true_type.accept(self) - t.false_type.accept(self) - def visit_type_for_comprehension(self, t: TypeForComprehension, /) -> None: t.element_expr.accept(self) t.iter_type.accept(self) From 12966905e224e54483363c5b35040a1cacc8d907 Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Tue, 20 Jan 2026 17:01:06 -0800 Subject: [PATCH 017/161] Fix more tests and lints --- mypy/constraints.py | 8 ++++++-- mypy/typelevel.py | 2 +- mypy/types.py | 5 +---- mypy/typeshed/stdlib/typing.pyi | 28 +++++++++++++++------------- 4 files changed, 23 insertions(+), 20 deletions(-) diff --git a/mypy/constraints.py b/mypy/constraints.py index fbc793a1beded..78d8fca91fda3 100644 --- a/mypy/constraints.py +++ b/mypy/constraints.py @@ -1431,10 +1431,14 @@ def visit_type_alias_type(self, template: TypeAliasType) -> list[Constraint]: assert False, f"This should be never called, got {template}" def visit_type_operator_type(self, template: TypeOperatorType) -> list[Constraint]: - assert False, f"Computed types should be expanded before constraint inference, got {template}" + assert ( + False + ), f"Computed types should be expanded before constraint inference, got {template}" def visit_type_for_comprehension(self, template: TypeForComprehension) -> list[Constraint]: - assert False, f"Computed types should be expanded before constraint inference, got {template}" + assert ( + False + ), f"Computed types should be expanded before constraint inference, got {template}" def infer_against_any(self, types: Iterable[Type], any_type: AnyType) -> list[Constraint]: res: list[Constraint] = [] diff --git a/mypy/typelevel.py b/mypy/typelevel.py index 07204c69dd6e3..68d289573a5ac 100644 --- a/mypy/typelevel.py +++ b/mypy/typelevel.py @@ -11,7 +11,7 @@ from __future__ import annotations -from mypy.types import ComputedType, Type, TypeForComprehension, TypeOperatorType +from mypy.types import Type, TypeForComprehension, TypeOperatorType def evaluate_type_operator(typ: TypeOperatorType) -> Type: diff --git a/mypy/types.py b/mypy/types.py index 782d8dd318adf..f46ffd5b2fc37 100644 --- a/mypy/types.py +++ b/mypy/types.py @@ -556,10 +556,7 @@ def deserialize(cls, data: JsonDict) -> TypeOperatorType: def copy_modified(self, *, args: list[Type] | None = None) -> TypeOperatorType: return TypeOperatorType( - self.type, - args if args is not None else self.args.copy(), - self.line, - self.column, + self.type, args if args is not None else self.args.copy(), self.line, self.column ) def write(self, data: WriteBuffer) -> None: diff --git a/mypy/typeshed/stdlib/typing.pyi b/mypy/typeshed/stdlib/typing.pyi index 3db2b80b5ffe8..5bb2d66d9ab66 100644 --- a/mypy/typeshed/stdlib/typing.pyi +++ b/mypy/typeshed/stdlib/typing.pyi @@ -1239,19 +1239,21 @@ class Param(Generic[_Name, _Type, _Quals]): _N = TypeVar("_N", bound=str) # Convenience aliases for Param -PosParam: typing_extensions.TypeAlias = Param[_N, _T, Literal["positional"]] -PosDefaultParam: typing_extensions.TypeAlias = Param[_N, _T, Literal["positional", "default"]] -DefaultParam: typing_extensions.TypeAlias = Param[_N, _T, Literal["default"]] -NamedParam: typing_extensions.TypeAlias = Param[_N, _T, Literal["keyword"]] -NamedDefaultParam: typing_extensions.TypeAlias = Param[_N, _T, Literal["keyword", "default"]] -ArgsParam: typing_extensions.TypeAlias = Param[None, _T, Literal["*"]] -KwargsParam: typing_extensions.TypeAlias = Param[None, _T, Literal["**"]] + +# XXX: For mysterious reasons, if I mark this as `: +# typing_extensions.TypeAlias`, mypy thinks _N and _T are unbound... +PosParam = Param[_N, _T, Literal["positional"]] +PosDefaultParam = Param[_N, _T, Literal["positional", "default"]] +DefaultParam = Param[_N, _T, Literal["default"]] +NamedParam = Param[_N, _T, Literal["keyword"]] +NamedDefaultParam = Param[_N, _T, Literal["keyword", "default"]] +ArgsParam = Param[None, _T, Literal["*"]] +KwargsParam = Param[None, _T, Literal["**"]] # --- Type Introspection Operators --- _Base = TypeVar("_Base") _Idx = TypeVar("_Idx") -_S = TypeVar("_S") _S1 = TypeVar("_S1") _S2 = TypeVar("_S2") _Start = TypeVar("_Start") @@ -1317,11 +1319,11 @@ _MP = TypeVar("_MP", bound=Member[Any, Any, Any, Any, Any] | Param[Any, Any, Any _M = TypeVar("_M", bound=Member[Any, Any, Any, Any, Any]) -GetName: typing_extensions.TypeAlias = GetAttr[_MP, Literal["name"]] -GetType: typing_extensions.TypeAlias = GetAttr[_MP, Literal["typ"]] -GetQuals: typing_extensions.TypeAlias = GetAttr[_MP, Literal["quals"]] -GetInit: typing_extensions.TypeAlias = GetAttr[_M, Literal["init"]] -GetDefiner: typing_extensions.TypeAlias = GetAttr[_M, Literal["definer"]] +GetName = GetAttr[_MP, Literal["name"]] +GetType = GetAttr[_MP, Literal["typ"]] +GetQuals = GetAttr[_MP, Literal["quals"]] +GetInit = GetAttr[_M, Literal["init"]] +GetDefiner = GetAttr[_M, Literal["definer"]] # --- Type Construction Operators --- From b6a61c4ef61383cf94f5a87f5dff04df388444af Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Tue, 20 Jan 2026 17:06:22 -0800 Subject: [PATCH 018/161] [typemap] Update plan: ConditionalType removed in favor of _Cond operator Update the implementation plan to reflect that conditional types are now represented as _Cond[condition, TrueType, FalseType] TypeOperatorType instead of a separate ConditionalType class. This simplifies the type system by having one unified mechanism for all type-level computations. Co-Authored-By: Claude Opus 4.5 --- TYPEMAP_IMPLEMENTATION_PLAN.md | 207 +++++++++++++++------------------ 1 file changed, 97 insertions(+), 110 deletions(-) diff --git a/TYPEMAP_IMPLEMENTATION_PLAN.md b/TYPEMAP_IMPLEMENTATION_PLAN.md index 5b51c3c3ac8f2..2c9fed87e7d5b 100644 --- a/TYPEMAP_IMPLEMENTATION_PLAN.md +++ b/TYPEMAP_IMPLEMENTATION_PLAN.md @@ -6,8 +6,8 @@ This document outlines a plan for implementing the type-level computation propos The proposal introduces TypeScript-inspired type-level introspection and construction facilities: -1. **Type Operators**: `GetArg`, `GetArgs`, `FromUnion`, `IsSub` (subtype check) -2. **Conditional Types**: `X if IsSub[T, Base] else Y` +1. **Type Operators**: `GetArg`, `GetArgs`, `FromUnion`, `IsSub` (subtype check), `_Cond` (conditional) +2. **Conditional Types**: `_Cond[IsSub[T, Base], TrueType, FalseType]` (also supports ternary syntax `X if IsSub[T, Base] else Y`) 3. **Type-Level Iteration**: `*[... for t in Iter[...]]` 4. **Object Inspection**: `Members`, `Attrs`, `Member`, `NewProtocol`, `NewTypedDict` 5. **Callable Extension**: `Param` type with qualifiers for extended callable syntax @@ -42,9 +42,10 @@ class ComputedType(Type): that produces a concrete type. Subclasses: - - TypeOperatorType: e.g., GetArg[T, Base, 0], Members[T] - - ConditionalType: e.g., X if IsSub[T, Base] else Y + - TypeOperatorType: e.g., GetArg[T, Base, 0], Members[T], _Cond[IsSub[T, Base], X, Y] - TypeForComprehension: e.g., *[Expr for x in Iter[T] if Cond] + + Note: Conditional types are represented as _Cond[...] TypeOperatorType, not a separate class. """ __slots__ = () @@ -118,38 +119,12 @@ class TypeOperatorType(ComputedType): ### 1.4 Conditional Types and Comprehensions (`mypy/types.py`) -```python -class ConditionalType(ComputedType): - """ - Represents `TrueType if IsSub[T, Base] else FalseType`. - - The condition is itself a TypeOperatorType (IsSub[...]). - """ - - __slots__ = ("condition", "true_type", "false_type") - - def __init__( - self, - condition: Type, # Should be IsSub[T, Base] or boolean combination thereof - true_type: Type, - false_type: Type, - line: int = -1, - column: int = -1, - ) -> None: - super().__init__(line, column) - self.condition = condition - self.true_type = true_type - self.false_type = false_type - - def accept(self, visitor: TypeVisitor[T]) -> T: - return visitor.visit_conditional_type(self) - - def expand(self) -> Type: - """Evaluate the condition and return the appropriate branch.""" - from mypy.typelevel import evaluate_conditional - return evaluate_conditional(self) - +**Note:** Conditional types are now represented as `_Cond[condition, TrueType, FalseType]` using +`TypeOperatorType`, not a separate `ConditionalType` class. This simplifies the type system by +having one unified mechanism for all type-level computations. The ternary syntax +`X if IsSub[T, Base] else Y` is parsed and converted directly to `_Cond[IsSub[T, Base], X, Y]`. +```python class TypeForComprehension(ComputedType): """ Represents *[Expr for var in Iter[T] if Cond]. @@ -192,10 +167,12 @@ class TypeVisitor(Generic[T]): # ... existing methods ... def visit_type_operator_type(self, t: TypeOperatorType) -> T: ... - def visit_conditional_type(self, t: ConditionalType) -> T: ... def visit_type_for_comprehension(self, t: TypeForComprehension) -> T: ... ``` +Note: There is no `visit_conditional_type` - conditional types are represented as `_Cond[...]` +TypeOperatorType and handled by `visit_type_operator_type`. + ### 1.6 Declare Type Operators in Typeshed (`mypy/typeshed/stdlib/typing.pyi`) All type operators are declared as generic classes with the `@_type_operator` decorator. @@ -326,7 +303,18 @@ class NewTypedDict(Generic[Unpack[_Ts]]): class IsSub(Generic[_T, _Base]): """ Type-level subtype check. Evaluates to a type-level boolean. - Used in conditional type expressions: `X if IsSub[T, Base] else Y` + Used in conditional type expressions: `_Cond[IsSub[T, Base], TrueType, FalseType]` + """ + ... + +@_type_operator +class _Cond(Generic[_T, _TrueType, _FalseType]): + """ + Type-level conditional expression. + _Cond[IsSub[T, Base], TrueType, FalseType] evaluates to TrueType if T is a subtype of Base, + otherwise FalseType. + + The ternary syntax `X if IsSub[T, Base] else Y` is converted to `_Cond[IsSub[T, Base], X, Y]`. """ ... @@ -434,7 +422,7 @@ def get_proper_type(typ: Type) -> ProperType: if isinstance(typ, TypeAliasType): typ = typ._expand_once() elif isinstance(typ, ComputedType): - # Handles TypeOperatorType, ConditionalType, TypeForComprehension + # Handles TypeOperatorType (including _Cond), TypeForComprehension typ = typ.expand() else: break @@ -493,11 +481,12 @@ def analyze_type_operator(self, t: UnboundType, type_info: TypeInfo) -> Type: ### 2.2 Parse Conditional Type Syntax Handle the `X if IsSub[T, Base] else Y` syntax in type contexts by extending the parser. +The ternary syntax is converted directly to `_Cond[condition, TrueType, FalseType]` TypeOperatorType. #### 2.2.1 AST Representation Python's parser already produces `IfExpr` (ternary) nodes. In type contexts, we need to -recognize these and convert them to type expressions. The AST for `X if Cond else Y` is: +recognize these and convert them to `_Cond` type operator calls. The AST for `X if Cond else Y` is: ```python IfExpr( @@ -510,7 +499,7 @@ IfExpr( #### 2.2.2 Extend `expr_to_unanalyzed_type()` (`mypy/fastparse.py`) The `expr_to_unanalyzed_type()` function converts AST expressions to unanalyzed types. -Extend it to handle `IfExpr`, converting directly to `ConditionalType`: +Extend it to handle `IfExpr`, converting to an UnboundType for `_Cond`: ```python def expr_to_unanalyzed_type( @@ -521,11 +510,13 @@ def expr_to_unanalyzed_type( # ... existing cases ... if isinstance(expr, IfExpr): - # Convert ternary directly to ConditionalType - return ConditionalType( - condition=expr_to_unanalyzed_type(expr.cond, options, ...), - true_type=expr_to_unanalyzed_type(expr.body, options, ...), - false_type=expr_to_unanalyzed_type(expr.orelse, options, ...), + # Convert ternary to _Cond[condition, true_type, false_type] + condition = expr_to_unanalyzed_type(expr.cond, options, ...) + true_type = expr_to_unanalyzed_type(expr.body, options, ...) + false_type = expr_to_unanalyzed_type(expr.orelse, options, ...) + return UnboundType( + "typing._Cond", + [condition, true_type, false_type], line=expr.lineno, column=expr.col_offset, ) @@ -535,28 +526,11 @@ def expr_to_unanalyzed_type( #### 2.2.3 Handle in Type Analysis (`mypy/typeanal.py`) -`ConditionalType` contains unanalyzed types initially. The `TypeAnalyser` visitor -analyzes the nested types: +Since conditional types are now `_Cond[...]` TypeOperatorType, they are analyzed like +any other type operator via `analyze_type_operator()`. The condition validation happens +during evaluation in `mypy/typelevel.py`: ```python -def visit_conditional_type(self, t: ConditionalType) -> Type: - """Analyze the components of a conditional type.""" - condition = self.anal_type(t.condition) - true_type = self.anal_type(t.true_type) - false_type = self.anal_type(t.false_type) - - # Validate condition is a IsSub[...] or boolean combination - if not self.is_valid_type_condition(condition): - self.fail( - "Condition in type-level conditional must be IsSub[T, Base] " - "or a boolean combination thereof", - t - ) - return AnyType(TypeOfAny.from_error) - - return ConditionalType(condition, true_type, false_type, line=t.line, column=t.column) - - def is_valid_type_condition(self, typ: Type) -> bool: """Check if typ is a valid type-level condition (IsSub or boolean combo).""" if isinstance(typ, TypeOperatorType): @@ -612,7 +586,7 @@ from typing import Callable from mypy.types import ( Type, ProperType, Instance, TupleType, UnionType, LiteralType, TypedDictType, CallableType, NoneType, AnyType, UninhabitedType, - TypeOperatorType, ConditionalType, TypeForComprehension, + TypeOperatorType, TypeForComprehension, ) from mypy.subtypes import is_subtype from mypy.typeops import get_proper_type @@ -641,8 +615,6 @@ class TypeLevelEvaluator: """Main entry point: evaluate a type to its simplified form.""" if isinstance(typ, TypeOperatorType): return self.eval_operator(typ) - elif isinstance(typ, ConditionalType): - return self.eval_conditional(typ) elif isinstance(typ, TypeForComprehension): return self.eval_comprehension(typ) @@ -675,15 +647,18 @@ class TypeLevelEvaluator: # Could add support for boolean combinations (and, or, not) here return None - def eval_conditional(self, typ: ConditionalType) -> Type: - """Evaluate X if Cond else Y""" - result = self.eval_condition(typ.condition) + def eval_conditional(self, typ: TypeOperatorType) -> Type: + """Evaluate _Cond[condition, TrueType, FalseType]""" + if len(typ.args) != 3: + return typ + condition, true_type, false_type = typ.args + result = self.eval_condition(condition) if result is True: - return self.evaluate(typ.true_type) + return self.evaluate(true_type) elif result is False: - return self.evaluate(typ.false_type) + return self.evaluate(false_type) else: - # Undecidable - keep as ConditionalType + # Undecidable - keep as _Cond TypeOperatorType return typ def eval_comprehension(self, typ: TypeForComprehension) -> Type: @@ -814,6 +789,12 @@ class TypeLevelEvaluator: # --- Operator Implementations --- +@register_operator('typing._Cond') +def eval_cond(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: + """Evaluate _Cond[condition, TrueType, FalseType]""" + return evaluator.eval_conditional(typ) + + @register_operator('typing.GetArg') def eval_get_arg(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: """Evaluate GetArg[T, Base, Idx]""" @@ -1146,7 +1127,10 @@ def has_not_required_qual(quals: Type) -> bool: # --- Public API --- def evaluate_type_operator(typ: TypeOperatorType) -> Type: - """Evaluate a TypeOperatorType. Called from TypeOperatorType.expand().""" + """Evaluate a TypeOperatorType. Called from TypeOperatorType.expand(). + + This handles all type operators including _Cond for conditional types. + """ # Need to get the API somehow - this is a design question # Option 1: Pass API through a context variable # Option 2: Store API reference on TypeOperatorType @@ -1155,12 +1139,6 @@ def evaluate_type_operator(typ: TypeOperatorType) -> Type: return evaluator.eval_operator(typ) -def evaluate_conditional(typ: ConditionalType) -> Type: - """Evaluate a ConditionalType. Called from ConditionalType.expand().""" - evaluator = TypeLevelEvaluator(...) - return evaluator.eval_conditional(typ) - - def evaluate_comprehension(typ: TypeForComprehension) -> Type: """Evaluate a TypeForComprehension. Called from TypeForComprehension.expand().""" evaluator = TypeLevelEvaluator(...) @@ -1195,12 +1173,9 @@ Extend `ExpandTypeVisitor` to handle new types: class ExpandTypeVisitor(TypeTransformVisitor): # ... existing methods ... - def visit_conditional_type(self, t: ConditionalType) -> Type: - return ConditionalType( - self.expand_condition(t.condition), - t.true_type.accept(self), - t.false_type.accept(self), - ) + def visit_type_operator_type(self, t: TypeOperatorType) -> Type: + # Expand type args, including _Cond which has condition, true_type, false_type as args + return t.copy_modified(args=[arg.accept(self) for arg in t.args]) def visit_type_for_comprehension(self, t: TypeForComprehension) -> Type: # Don't substitute the iteration variable @@ -1208,12 +1183,15 @@ class ExpandTypeVisitor(TypeTransformVisitor): t.element_expr.accept(self), t.iter_var, t.iter_type.accept(self), - [self.expand_condition(c) for c in t.conditions], + [c.accept(self) for c in t.conditions], ) # ... more visit methods for other new types ... ``` +Note: Conditional types are now `_Cond[...]` TypeOperatorType, so they are handled by +`visit_type_operator_type` along with all other type operators. + ### 4.3 Subtype Checking (`mypy/subtypes.py`) Add subtype rules for new types: @@ -1222,20 +1200,28 @@ Add subtype rules for new types: class SubtypeVisitor(TypeVisitor[bool]): # ... existing methods ... - def visit_conditional_type(self, left: ConditionalType) -> bool: - # A conditional type is subtype if both branches are subtypes + def visit_type_operator_type(self, left: TypeOperatorType) -> bool: + # For _Cond[condition, TrueType, FalseType]: subtype if both branches are subtypes # OR if we can evaluate the condition - evaluator = TypeLevelEvaluator(...) - result = evaluator.eval_condition(left.condition) - - if result is True: - return is_subtype(left.true_type, self.right) - elif result is False: - return is_subtype(left.false_type, self.right) - else: - # Must be subtype in both cases - return (is_subtype(left.true_type, self.right) and - is_subtype(left.false_type, self.right)) + if left.fullname == 'typing._Cond' and len(left.args) == 3: + condition, true_type, false_type = left.args + evaluator = TypeLevelEvaluator(...) + result = evaluator.eval_condition(condition) + + if result is True: + return is_subtype(true_type, self.right) + elif result is False: + return is_subtype(false_type, self.right) + else: + # Must be subtype in both cases + return (is_subtype(true_type, self.right) and + is_subtype(false_type, self.right)) + + # For other type operators, expand first then check subtype + expanded = left.expand() + if expanded is not left: + return is_subtype(expanded, self.right) + return False # Unevaluatable type operator ``` ### 4.4 Type Inference with `**kwargs` TypeVar @@ -1413,10 +1399,11 @@ Port examples from the PEP: 5. Tests for basic operations ### Milestone 2: Conditional Types (Weeks 4-5) -1. Add `ConditionalType` and condition classes +1. Add `_Cond` type operator (conditional types are TypeOperatorType, not a separate class) 2. Add `IsSub` condition operator -3. Integrate with type evaluator -4. Tests for conditionals +3. Implement ternary syntax parsing to `_Cond` conversion +4. Integrate with type evaluator +5. Tests for conditionals ### Milestone 3: Type Comprehensions (Weeks 6-7) 1. Add `TypeForComprehension`, `IterType` @@ -1472,7 +1459,7 @@ Port examples from the PEP: **Decision**: `GetName`, `GetType`, `GetQuals`, `GetInit`, `GetDefiner` are type aliases using `GetAttr`, not separate type operators. Since `Member` and `Param` are regular classes with attributes, `GetAttr[Member[...], Literal["name"]]` works directly. ### 3. ComputedType Hierarchy (NOT ProperType) -**Decision**: All computed types (`TypeOperatorType`, `ConditionalType`, `TypeForComprehension`) inherit from a common `ComputedType` base class. Like `TypeAliasType`, `ComputedType` is NOT a `ProperType` and must be expanded before use in most type operations. This is handled by a single `isinstance(typ, ComputedType)` check in `get_proper_type()`. +**Decision**: All computed types (`TypeOperatorType`, `TypeForComprehension`) inherit from a common `ComputedType` base class. Like `TypeAliasType`, `ComputedType` is NOT a `ProperType` and must be expanded before use in most type operations. This is handled by a single `isinstance(typ, ComputedType)` check in `get_proper_type()`. Note: Conditional types are represented as `_Cond[...]` TypeOperatorType, not a separate class. ### 4. Lazy Evaluation with Caching **Decision**: Type-level computations are evaluated when needed (e.g., during subtype checking) rather than immediately during parsing. Results should be cached. @@ -1502,17 +1489,17 @@ Port examples from the PEP: - `test-data/unit/check-typelevel-*.test` - Test data ### Modified Files -- `mypy/types.py` - Add `ComputedType` base class, `TypeOperatorType`, `ConditionalType`, `TypeForComprehension` -- `mypy/type_visitor.py` - Add `visit_type_operator_type`, `visit_conditional_type`, `visit_type_for_comprehension` -- `mypy/fastparse.py` - Extend `expr_to_unanalyzed_type()` to handle `IfExpr` → `ConditionalType` -- `mypy/typeanal.py` - Detect `@_type_operator` classes, construct `TypeOperatorType`, analyze `ConditionalType` +- `mypy/types.py` - Add `ComputedType` base class, `TypeOperatorType`, `TypeForComprehension` +- `mypy/type_visitor.py` - Add `visit_type_operator_type`, `visit_type_for_comprehension` +- `mypy/fastparse.py` - Extend `expr_to_unanalyzed_type()` to handle `IfExpr` → `_Cond[...]` UnboundType +- `mypy/typeanal.py` - Detect `@_type_operator` classes, construct `TypeOperatorType` - `mypy/typeops.py` - Extend `get_proper_type()` to expand type operators - `mypy/expandtype.py` - Handle type variable substitution in type operators - `mypy/subtypes.py` - Subtype rules for unevaluated type operators - `mypy/checkexpr.py` - kwargs TypedDict inference - `mypy/semanal.py` - Detect `@_type_operator` decorator, InitField handling - `mypy/nodes.py` - Add `is_type_operator` flag to `TypeInfo` -- `mypy/typeshed/stdlib/typing.pyi` - Declare type operators with `@_type_operator`, plus `Member`/`Param` as regular generic classes and accessor aliases +- `mypy/typeshed/stdlib/typing.pyi` - Declare type operators with `@_type_operator` (including `_Cond` for conditionals), plus `Member`/`Param` as regular generic classes and accessor aliases --- From ba9dd1d7bbee7f8d1d1ab4b53cf37e4529aafe00 Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Tue, 20 Jan 2026 17:18:14 -0800 Subject: [PATCH 019/161] [typemap] Implement phases 2.1 and 2.2: Type operator detection and ternary parsing Phase 2.1 - Detect @_type_operator classes and construct TypeOperatorType: - Add TYPE_OPERATOR_NAMES constant in types.py - Add decorator detection in semanal.py to set is_type_operator flag - Add analyze_type_operator method in typeanal.py to construct TypeOperatorType when encountering a type decorated with @_type_operator Phase 2.2 - Parse conditional type syntax: - Add visit_IfExp method to TypeConverter in fastparse.py - Convert ternary expressions `X if Cond else Y` in type contexts to `_Cond[Cond, X, Y]` UnboundType Co-Authored-By: Claude Opus 4.5 --- mypy/fastparse.py | 17 +++++++++++++++++ mypy/semanal.py | 3 +++ mypy/typeanal.py | 18 ++++++++++++++++++ mypy/types.py | 3 +++ 4 files changed, 41 insertions(+) diff --git a/mypy/fastparse.py b/mypy/fastparse.py index e85b8fffaf9e9..901e1d7cebf18 100644 --- a/mypy/fastparse.py +++ b/mypy/fastparse.py @@ -2163,6 +2163,23 @@ def visit_List(self, n: ast3.List) -> Type: result = self.translate_argument_list(n.elts) return result + # IfExp(expr test, expr body, expr orelse) + def visit_IfExp(self, n: ast3.IfExp) -> Type: + """Handle ternary expressions in type contexts. + + Convert `X if Cond else Y` to `_Cond[Cond, X, Y]`. + """ + condition = self.visit(n.test) + true_type = self.visit(n.body) + false_type = self.visit(n.orelse) + + return UnboundType( + "typing._Cond", + [condition, true_type, false_type], + line=self.line, + column=self.convert_column(n.col_offset), + ) + def stringify_name(n: AST) -> str | None: if isinstance(n, Name): diff --git a/mypy/semanal.py b/mypy/semanal.py index 9bc6d00446388..0b87c928dac89 100644 --- a/mypy/semanal.py +++ b/mypy/semanal.py @@ -272,6 +272,7 @@ TYPE_ALIAS_NAMES, TYPE_CHECK_ONLY_NAMES, TYPE_NAMES, + TYPE_OPERATOR_NAMES, TYPE_VAR_LIKE_NAMES, TYPED_NAMEDTUPLE_NAMES, UNPACK_TYPE_NAMES, @@ -2297,6 +2298,8 @@ def analyze_class_decorator_common( info.is_disjoint_base = True elif refers_to_fullname(decorator, TYPE_CHECK_ONLY_NAMES): info.is_type_check_only = True + elif refers_to_fullname(decorator, TYPE_OPERATOR_NAMES): + info.is_type_operator = True elif (deprecated := self.get_deprecated(decorator)) is not None: info.deprecated = f"class {defn.fullname} is deprecated: {deprecated}" diff --git a/mypy/typeanal.py b/mypy/typeanal.py index a1fe014f73ea2..360e761438299 100644 --- a/mypy/typeanal.py +++ b/mypy/typeanal.py @@ -512,6 +512,9 @@ def visit_unbound_type_nonoptional(self, t: UnboundType, defining_literal: bool) res = get_proper_type(res) return res elif isinstance(node, TypeInfo): + # Check if this is a type operator (decorated with @_type_operator) + if node.is_type_operator: + return self.analyze_type_operator(t, node) return self.analyze_type_with_type_info(node, t.args, t, t.empty_tuple_index) elif node.fullname in TYPE_ALIAS_NAMES: return AnyType(TypeOfAny.special_form) @@ -1114,6 +1117,21 @@ def visit_type_alias_type(self, t: TypeAliasType) -> Type: # TODO: should we do something here? return t + def analyze_type_operator(self, t: UnboundType, type_info: TypeInfo) -> Type: + """Analyze a type operator application like GetArg[T, Base, 0]. + + Returns a TypeOperatorType that will be expanded later. + """ + # Analyze all type arguments + an_args = self.anal_array( + t.args, + allow_param_spec=True, + allow_param_spec_literals=type_info.has_param_spec_type, + allow_unpack=True, + ) + + return TypeOperatorType(type_info, an_args, t.line, t.column) + def visit_type_operator_type(self, t: TypeOperatorType) -> Type: # Type operators are analyzed elsewhere return t diff --git a/mypy/types.py b/mypy/types.py index f46ffd5b2fc37..d735b0c23fa40 100644 --- a/mypy/types.py +++ b/mypy/types.py @@ -158,6 +158,9 @@ # Supported @disjoint_base decorator names DISJOINT_BASE_DECORATOR_NAMES: Final = ("typing.disjoint_base", "typing_extensions.disjoint_base") +# Supported @_type_operator decorator names (for type-level computation) +TYPE_OPERATOR_NAMES: Final = ("typing._type_operator",) + # We use this constant in various places when checking `tuple` subtyping: TUPLE_LIKE_INSTANCE_NAMES: Final = ( "builtins.tuple", From ed51680a5ee08269d81537222c96baf47eb14003 Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Tue, 20 Jan 2026 17:32:47 -0800 Subject: [PATCH 020/161] [typemap] Fix issues with type operator and ternary syntax Several fixes to make type operator detection and ternary syntax work correctly: 1. typelevel.py: Return AnyType instead of self in stub implementations to avoid infinite loops in get_proper_type() 2. types_utils.py: Handle ComputedType in is_invalid_recursive_alias() by calling get_proper_type() before the ProperType assertion 3. typeanal.py: Add special handling for "typing._Cond" to use lookup_fully_qualified_or_none() so ternary syntax works without explicit import of _Cond 4. semanal.py: Add suppress_errors=True to lookup_qualified() calls in is_classvar() and is_final_type() to prevent errors when qualified names like "typing._Cond" are encountered 5. fastparse.py: Add clarifying comments to visit_IfExp Co-Authored-By: Claude Opus 4.5 --- mypy/fastparse.py | 2 ++ mypy/semanal.py | 8 ++++++-- mypy/typeanal.py | 7 ++++++- mypy/typelevel.py | 12 +++++++----- mypy/types_utils.py | 2 ++ 5 files changed, 23 insertions(+), 8 deletions(-) diff --git a/mypy/fastparse.py b/mypy/fastparse.py index 901e1d7cebf18..984a5c839ff16 100644 --- a/mypy/fastparse.py +++ b/mypy/fastparse.py @@ -2168,11 +2168,13 @@ def visit_IfExp(self, n: ast3.IfExp) -> Type: """Handle ternary expressions in type contexts. Convert `X if Cond else Y` to `_Cond[Cond, X, Y]`. + The _Cond type operator is resolved during type analysis. """ condition = self.visit(n.test) true_type = self.visit(n.body) false_type = self.visit(n.orelse) + # Use fully qualified name so it can be resolved without explicit import return UnboundType( "typing._Cond", [condition, true_type, false_type], diff --git a/mypy/semanal.py b/mypy/semanal.py index 0b87c928dac89..4dd97198528cc 100644 --- a/mypy/semanal.py +++ b/mypy/semanal.py @@ -5280,7 +5280,9 @@ def check_classvar(self, s: AssignmentStmt) -> None: def is_classvar(self, typ: Type) -> bool: if not isinstance(typ, UnboundType): return False - sym = self.lookup_qualified(typ.name, typ) + # Use suppress_errors=True because the type name may contain qualified names + # like "typing._Cond" that can't be looked up without importing the module + sym = self.lookup_qualified(typ.name, typ, suppress_errors=True) if not sym or not sym.node: return False return sym.node.fullname == "typing.ClassVar" @@ -5288,7 +5290,9 @@ def is_classvar(self, typ: Type) -> bool: def is_final_type(self, typ: Type | None) -> bool: if not isinstance(typ, UnboundType): return False - sym = self.lookup_qualified(typ.name, typ) + # Use suppress_errors=True because the type name may contain qualified names + # like "typing._Cond" that can't be looked up without importing the module + sym = self.lookup_qualified(typ.name, typ, suppress_errors=True) if not sym or not sym.node: return False return sym.node.fullname in FINAL_TYPE_NAMES diff --git a/mypy/typeanal.py b/mypy/typeanal.py index 360e761438299..b733bf49edef4 100644 --- a/mypy/typeanal.py +++ b/mypy/typeanal.py @@ -299,7 +299,12 @@ def not_declared_in_type_params(self, tvar_name: str) -> bool: ) def visit_unbound_type_nonoptional(self, t: UnboundType, defining_literal: bool) -> Type: - sym = self.lookup_qualified(t.name, t) + # Handle typing._Cond specially for ternary type syntax support + # This allows `X if Cond else Y` to work without explicit import of _Cond + if t.name == "typing._Cond": + sym = self.api.lookup_fully_qualified_or_none("typing._Cond") + else: + sym = self.lookup_qualified(t.name, t) param_spec_name = None if t.name.endswith((".args", ".kwargs")): param_spec_name = t.name.rsplit(".", 1)[0] diff --git a/mypy/typelevel.py b/mypy/typelevel.py index 68d289573a5ac..f0763906bba2a 100644 --- a/mypy/typelevel.py +++ b/mypy/typelevel.py @@ -11,7 +11,7 @@ from __future__ import annotations -from mypy.types import Type, TypeForComprehension, TypeOperatorType +from mypy.types import AnyType, Type, TypeForComprehension, TypeOfAny, TypeOperatorType def evaluate_type_operator(typ: TypeOperatorType) -> Type: @@ -19,8 +19,9 @@ def evaluate_type_operator(typ: TypeOperatorType) -> Type: Returns the type unchanged if evaluation is not yet possible. """ - # Stub implementation - return the type unchanged - return typ + # Stub implementation - return Any to avoid infinite loops in get_proper_type + # The real implementation will evaluate the type operator and return a concrete type + return AnyType(TypeOfAny.special_form) def evaluate_comprehension(typ: TypeForComprehension) -> Type: @@ -28,5 +29,6 @@ def evaluate_comprehension(typ: TypeForComprehension) -> Type: Returns the type unchanged if evaluation is not yet possible. """ - # Stub implementation - return the type unchanged - return typ + # Stub implementation - return Any to avoid infinite loops in get_proper_type + # The real implementation will evaluate the comprehension and return a concrete type + return AnyType(TypeOfAny.special_form) diff --git a/mypy/types_utils.py b/mypy/types_utils.py index 86caa3e2e42e6..f811086ca68b6 100644 --- a/mypy/types_utils.py +++ b/mypy/types_utils.py @@ -66,6 +66,8 @@ def is_invalid_recursive_alias(seen_nodes: set[TypeAlias], target: Type) -> bool return True assert target.alias, f"Unfixed type alias {target.type_ref}" return is_invalid_recursive_alias(seen_nodes | {target.alias}, get_proper_type(target)) + # Handle ComputedType (TypeOperatorType, TypeForComprehension) by expanding to ProperType + target = get_proper_type(target) assert isinstance(target, ProperType) if not isinstance(target, (UnionType, TupleType)): return False From 7827de5401144b22a7a76a5d4551d45cdc40ce69 Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Tue, 20 Jan 2026 17:37:37 -0800 Subject: [PATCH 021/161] [typemap] Add tests for type operator detection and ternary syntax Add test cases for phases 2.1 and 2.2: - testTypeOperatorBasic: Direct use of IsSub and _Cond type operators - testTypeOperatorTernarySyntax: Ternary syntax conversion to _Cond - testTypeOperatorTernarySyntaxWithExplicitImport: Both forms together - testTypeOperatorInGenericClass: Type operators in generic class context - testTypeOperatorNestedTernary: Nested ternary expressions - testTypeOperatorMultipleTypeArgs: Complex conditionals with multiple type vars - testTypeOperatorInFunctionSignature: Type operators in function signatures - testTypeOperatorInTypeAlias: Type operators in type aliases Co-Authored-By: Claude Opus 4.5 --- test-data/unit/check-typelevel-basic.test | 231 ++++++++++++++++++++++ 1 file changed, 231 insertions(+) create mode 100644 test-data/unit/check-typelevel-basic.test diff --git a/test-data/unit/check-typelevel-basic.test b/test-data/unit/check-typelevel-basic.test new file mode 100644 index 0000000000000..586b8ce4d2b27 --- /dev/null +++ b/test-data/unit/check-typelevel-basic.test @@ -0,0 +1,231 @@ +[case testTypeOperatorBasic] +# Test basic type operator detection and construction +from typing import Generic, TypeVar +from m import IsSub, _Cond + +T = TypeVar('T') + +# Direct use of IsSub type operator +def test_issub(x: IsSub[int, object]) -> None: + pass + +# Direct use of _Cond type operator +def test_cond(x: _Cond[IsSub[int, object], str, int]) -> None: + pass + +[file m.pyi] +from typing import TypeVar, Generic + +def _type_operator(cls: type[T]) -> type[T]: ... +T = TypeVar('T') +_T = TypeVar('_T') +_Base = TypeVar('_Base') +_TrueType = TypeVar('_TrueType') +_FalseType = TypeVar('_FalseType') + +@_type_operator +class IsSub(Generic[_T, _Base]): ... + +@_type_operator +class _Cond(Generic[_T, _TrueType, _FalseType]): ... + +[builtins fixtures/tuple.pyi] + +[case testTypeOperatorTernarySyntax] +# Test ternary syntax conversion to _Cond +from m import IsSub + +# Ternary syntax should be converted to _Cond[condition, true_type, false_type] +x: str if IsSub[int, object] else int + +[file m.pyi] +from typing import TypeVar, Generic + +def _type_operator(cls: type[T]) -> type[T]: ... +T = TypeVar('T') +_T = TypeVar('_T') +_Base = TypeVar('_Base') +_TrueType = TypeVar('_TrueType') +_FalseType = TypeVar('_FalseType') + +@_type_operator +class IsSub(Generic[_T, _Base]): ... + +@_type_operator +class _Cond(Generic[_T, _TrueType, _FalseType]): ... + +[builtins fixtures/tuple.pyi] + +[case testTypeOperatorTernarySyntaxWithExplicitImport] +# Test ternary syntax alongside explicit _Cond usage +from m import IsSub, _Cond + +# Both forms should work +x: str if IsSub[int, object] else int +y: _Cond[IsSub[int, object], str, int] + +[file m.pyi] +from typing import TypeVar, Generic + +def _type_operator(cls: type[T]) -> type[T]: ... +T = TypeVar('T') +_T = TypeVar('_T') +_Base = TypeVar('_Base') +_TrueType = TypeVar('_TrueType') +_FalseType = TypeVar('_FalseType') + +@_type_operator +class IsSub(Generic[_T, _Base]): ... + +@_type_operator +class _Cond(Generic[_T, _TrueType, _FalseType]): ... + +[builtins fixtures/tuple.pyi] + +[case testTypeOperatorInGenericClass] +# Test type operators in generic class context +from typing import Generic, TypeVar +from m import IsSub, _Cond + +T = TypeVar('T') + +class MyClass(Generic[T]): + # Ternary syntax in class attribute + attr: str if IsSub[T, int] else float + + # _Cond in method signature + def method(self, x: _Cond[IsSub[T, str], list[T], T]) -> None: + pass + +[file m.pyi] +from typing import TypeVar, Generic + +def _type_operator(cls: type[T]) -> type[T]: ... +T = TypeVar('T') +_T = TypeVar('_T') +_Base = TypeVar('_Base') +_TrueType = TypeVar('_TrueType') +_FalseType = TypeVar('_FalseType') + +@_type_operator +class IsSub(Generic[_T, _Base]): ... + +@_type_operator +class _Cond(Generic[_T, _TrueType, _FalseType]): ... + +[builtins fixtures/tuple.pyi] + +[case testTypeOperatorNestedTernary] +# Test nested ternary expressions +from m import IsSub + +# Nested ternary should work +z: int if IsSub[int, str] else (float if IsSub[float, object] else str) + +[file m.pyi] +from typing import TypeVar, Generic + +def _type_operator(cls: type[T]) -> type[T]: ... +T = TypeVar('T') +_T = TypeVar('_T') +_Base = TypeVar('_Base') +_TrueType = TypeVar('_TrueType') +_FalseType = TypeVar('_FalseType') + +@_type_operator +class IsSub(Generic[_T, _Base]): ... + +@_type_operator +class _Cond(Generic[_T, _TrueType, _FalseType]): ... + +[builtins fixtures/tuple.pyi] + +[case testTypeOperatorMultipleTypeArgs] +# Test type operators with various argument types +from typing import Generic, TypeVar +from m import IsSub, _Cond + +T = TypeVar('T') +U = TypeVar('U') + +class Container(Generic[T, U]): + # Complex conditional with multiple type variables + value: _Cond[IsSub[T, int], list[U], tuple[T, U]] + +[file m.pyi] +from typing import TypeVar, Generic + +def _type_operator(cls: type[T]) -> type[T]: ... +T = TypeVar('T') +_T = TypeVar('_T') +_Base = TypeVar('_Base') +_TrueType = TypeVar('_TrueType') +_FalseType = TypeVar('_FalseType') + +@_type_operator +class IsSub(Generic[_T, _Base]): ... + +@_type_operator +class _Cond(Generic[_T, _TrueType, _FalseType]): ... + +[builtins fixtures/tuple.pyi] + +[case testTypeOperatorInFunctionSignature] +# Test type operators in function signatures +from typing import TypeVar +from m import IsSub, _Cond + +T = TypeVar('T') + +def process( + x: _Cond[IsSub[T, str], bytes, T], + y: str if IsSub[T, int] else float +) -> _Cond[IsSub[T, list[object]], int, str]: + ... + +[file m.pyi] +from typing import TypeVar, Generic + +def _type_operator(cls: type[T]) -> type[T]: ... +T = TypeVar('T') +_T = TypeVar('_T') +_Base = TypeVar('_Base') +_TrueType = TypeVar('_TrueType') +_FalseType = TypeVar('_FalseType') + +@_type_operator +class IsSub(Generic[_T, _Base]): ... + +@_type_operator +class _Cond(Generic[_T, _TrueType, _FalseType]): ... + +[builtins fixtures/tuple.pyi] + +[case testTypeOperatorInTypeAlias] +# Test type operators in type aliases (using concrete types to avoid unbound typevar issues) +from m import IsSub, _Cond + +# Type alias using _Cond with concrete types +ConditionalType = _Cond[IsSub[int, object], str, bytes] + +# Note: Ternary syntax at module level without TypeAlias annotation is treated as +# a runtime expression, not a type alias. The ternary syntax works best in +# annotations (function signatures, variable annotations, class attributes). + +[file m.pyi] +from typing import TypeVar, Generic + +def _type_operator(cls: type[T]) -> type[T]: ... +T = TypeVar('T') +_T = TypeVar('_T') +_Base = TypeVar('_Base') +_TrueType = TypeVar('_TrueType') +_FalseType = TypeVar('_FalseType') + +@_type_operator +class IsSub(Generic[_T, _Base]): ... + +@_type_operator +class _Cond(Generic[_T, _TrueType, _FalseType]): ... + +[builtins fixtures/tuple.pyi] From ac39c519110eedc56d3a094b326f29a8bc2f0f9f Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Tue, 20 Jan 2026 17:40:29 -0800 Subject: [PATCH 022/161] [typemap] Add context variable for API access in type-level evaluation Implement the decision for Open Question #5: use a context variable to provide API access during type operator expansion. - Add TypeLevelContext class in typelevel.py with: - api property to get the current SemanticAnalyzerCoreInterface - set_api() context manager to temporarily set the API - Add typelevel_ctx global instance - Update implementation plan to mark question #5 as resolved The context will be set during type analysis via: with typelevel_ctx.set_api(self.api): # type operators can now access the API Co-Authored-By: Claude Opus 4.5 --- TYPEMAP_IMPLEMENTATION_PLAN.md | 3 +- mypy/typelevel.py | 57 ++++++++++++++++++++++++++++++++-- 2 files changed, 57 insertions(+), 3 deletions(-) diff --git a/TYPEMAP_IMPLEMENTATION_PLAN.md b/TYPEMAP_IMPLEMENTATION_PLAN.md index 2c9fed87e7d5b..570afa0a07691 100644 --- a/TYPEMAP_IMPLEMENTATION_PLAN.md +++ b/TYPEMAP_IMPLEMENTATION_PLAN.md @@ -1513,6 +1513,7 @@ Port examples from the PEP: 4. **Caching strategy**: How aggressively to cache evaluated type-level computations? -5. **API access in expand()**: How does `TypeOperatorType.expand()` get access to the semantic analyzer API? Options: context variable, stored reference, or lazy creation. +5. **API access in expand()**: ~~How does `TypeOperatorType.expand()` get access to the semantic analyzer API? Options: context variable, stored reference, or lazy creation.~~ + **RESOLVED**: Use a context variable (`typelevel_ctx` in `mypy/typelevel.py`). The `TypeLevelContext` class holds a reference to the `SemanticAnalyzerCoreInterface` API, set via a context manager (`typelevel_ctx.set_api(api)`) during type analysis. The evaluation functions access it via `typelevel_ctx.api`. 6. **Type variable handling in operators**: When should type variables in operator arguments block evaluation vs. be substituted first? diff --git a/mypy/typelevel.py b/mypy/typelevel.py index f0763906bba2a..173255a243972 100644 --- a/mypy/typelevel.py +++ b/mypy/typelevel.py @@ -11,8 +11,52 @@ from __future__ import annotations +from collections.abc import Iterator +from contextlib import contextmanager +from typing import TYPE_CHECKING, Final + from mypy.types import AnyType, Type, TypeForComprehension, TypeOfAny, TypeOperatorType +if TYPE_CHECKING: + from mypy.semanal_shared import SemanticAnalyzerCoreInterface + + +class TypeLevelContext: + """Holds the context for type-level computation evaluation. + + This is a global mutable state that provides access to the semantic analyzer + API during type operator expansion. The context is set via a context manager + before type analysis and cleared afterward. + """ + + def __init__(self) -> None: + self._api: SemanticAnalyzerCoreInterface | None = None + + @property + def api(self) -> SemanticAnalyzerCoreInterface | None: + """Get the current semantic analyzer API, or None if not in context.""" + return self._api + + @contextmanager + def set_api(self, api: SemanticAnalyzerCoreInterface) -> Iterator[None]: + """Context manager to set the API for type-level evaluation. + + Usage: + with typelevel_ctx.set_api(self.api): + # Type operators can now access the API via typelevel_ctx.api + result = get_proper_type(some_type) + """ + saved = self._api + self._api = api + try: + yield + finally: + self._api = saved + + +# Global context instance for type-level computation +typelevel_ctx: Final = TypeLevelContext() + def evaluate_type_operator(typ: TypeOperatorType) -> Type: """Evaluate a TypeOperatorType. Called from TypeOperatorType.expand(). @@ -20,7 +64,16 @@ def evaluate_type_operator(typ: TypeOperatorType) -> Type: Returns the type unchanged if evaluation is not yet possible. """ # Stub implementation - return Any to avoid infinite loops in get_proper_type - # The real implementation will evaluate the type operator and return a concrete type + # The real implementation will: + # 1. Check if typelevel_ctx.api is available + # 2. If so, evaluate the type operator using the API + # 3. If not, return the type unchanged (will be evaluated later) + # + # Example future implementation: + # if typelevel_ctx.api is not None: + # evaluator = TypeLevelEvaluator(typelevel_ctx.api) + # return evaluator.eval_operator(typ) + # return typ # Can't evaluate yet, return unchanged return AnyType(TypeOfAny.special_form) @@ -30,5 +83,5 @@ def evaluate_comprehension(typ: TypeForComprehension) -> Type: Returns the type unchanged if evaluation is not yet possible. """ # Stub implementation - return Any to avoid infinite loops in get_proper_type - # The real implementation will evaluate the comprehension and return a concrete type + # The real implementation will use typelevel_ctx.api similar to evaluate_type_operator return AnyType(TypeOfAny.special_form) From 0b8fb618f80854c4a8e4d4dc12a889b79040d62c Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Tue, 20 Jan 2026 18:21:08 -0800 Subject: [PATCH 023/161] Get rid of some dodgy hacks around _Cond --- TYPEMAP_IMPLEMENTATION_PLAN.md | 6 +++--- mypy/fastparse.py | 2 +- mypy/semanal.py | 8 ++------ mypy/typeanal.py | 7 +------ mypy/typeshed/stdlib/builtins.pyi | 15 +++++++++++++++ mypy/typeshed/stdlib/typing.pyi | 13 ------------- 6 files changed, 22 insertions(+), 29 deletions(-) diff --git a/TYPEMAP_IMPLEMENTATION_PLAN.md b/TYPEMAP_IMPLEMENTATION_PLAN.md index 570afa0a07691..2590df3f854df 100644 --- a/TYPEMAP_IMPLEMENTATION_PLAN.md +++ b/TYPEMAP_IMPLEMENTATION_PLAN.md @@ -515,7 +515,7 @@ def expr_to_unanalyzed_type( true_type = expr_to_unanalyzed_type(expr.body, options, ...) false_type = expr_to_unanalyzed_type(expr.orelse, options, ...) return UnboundType( - "typing._Cond", + "builtins._Cond", [condition, true_type, false_type], line=expr.lineno, column=expr.col_offset, @@ -789,7 +789,7 @@ class TypeLevelEvaluator: # --- Operator Implementations --- -@register_operator('typing._Cond') +@register_operator('builtins._Cond') def eval_cond(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: """Evaluate _Cond[condition, TrueType, FalseType]""" return evaluator.eval_conditional(typ) @@ -1203,7 +1203,7 @@ class SubtypeVisitor(TypeVisitor[bool]): def visit_type_operator_type(self, left: TypeOperatorType) -> bool: # For _Cond[condition, TrueType, FalseType]: subtype if both branches are subtypes # OR if we can evaluate the condition - if left.fullname == 'typing._Cond' and len(left.args) == 3: + if left.fullname == 'builtins._Cond' and len(left.args) == 3: condition, true_type, false_type = left.args evaluator = TypeLevelEvaluator(...) result = evaluator.eval_condition(condition) diff --git a/mypy/fastparse.py b/mypy/fastparse.py index 984a5c839ff16..6520672bec3cd 100644 --- a/mypy/fastparse.py +++ b/mypy/fastparse.py @@ -2176,7 +2176,7 @@ def visit_IfExp(self, n: ast3.IfExp) -> Type: # Use fully qualified name so it can be resolved without explicit import return UnboundType( - "typing._Cond", + "__builtins__._Cond", [condition, true_type, false_type], line=self.line, column=self.convert_column(n.col_offset), diff --git a/mypy/semanal.py b/mypy/semanal.py index 4dd97198528cc..0b87c928dac89 100644 --- a/mypy/semanal.py +++ b/mypy/semanal.py @@ -5280,9 +5280,7 @@ def check_classvar(self, s: AssignmentStmt) -> None: def is_classvar(self, typ: Type) -> bool: if not isinstance(typ, UnboundType): return False - # Use suppress_errors=True because the type name may contain qualified names - # like "typing._Cond" that can't be looked up without importing the module - sym = self.lookup_qualified(typ.name, typ, suppress_errors=True) + sym = self.lookup_qualified(typ.name, typ) if not sym or not sym.node: return False return sym.node.fullname == "typing.ClassVar" @@ -5290,9 +5288,7 @@ def is_classvar(self, typ: Type) -> bool: def is_final_type(self, typ: Type | None) -> bool: if not isinstance(typ, UnboundType): return False - # Use suppress_errors=True because the type name may contain qualified names - # like "typing._Cond" that can't be looked up without importing the module - sym = self.lookup_qualified(typ.name, typ, suppress_errors=True) + sym = self.lookup_qualified(typ.name, typ) if not sym or not sym.node: return False return sym.node.fullname in FINAL_TYPE_NAMES diff --git a/mypy/typeanal.py b/mypy/typeanal.py index b733bf49edef4..360e761438299 100644 --- a/mypy/typeanal.py +++ b/mypy/typeanal.py @@ -299,12 +299,7 @@ def not_declared_in_type_params(self, tvar_name: str) -> bool: ) def visit_unbound_type_nonoptional(self, t: UnboundType, defining_literal: bool) -> Type: - # Handle typing._Cond specially for ternary type syntax support - # This allows `X if Cond else Y` to work without explicit import of _Cond - if t.name == "typing._Cond": - sym = self.api.lookup_fully_qualified_or_none("typing._Cond") - else: - sym = self.lookup_qualified(t.name, t) + sym = self.lookup_qualified(t.name, t) param_spec_name = None if t.name.endswith((".args", ".kwargs")): param_spec_name = t.name.rsplit(".", 1)[0] diff --git a/mypy/typeshed/stdlib/builtins.pyi b/mypy/typeshed/stdlib/builtins.pyi index 03c3bd2e17c74..67d1402cc0953 100644 --- a/mypy/typeshed/stdlib/builtins.pyi +++ b/mypy/typeshed/stdlib/builtins.pyi @@ -58,6 +58,7 @@ from typing import ( # noqa: Y022,UP035,RUF100 final, overload, type_check_only, + _type_operator, ) # we can't import `Literal` from typing or mypy crashes: see #11247 @@ -2230,3 +2231,17 @@ if sys.version_info >= (3, 11): if sys.version_info >= (3, 13): class PythonFinalizationError(RuntimeError): ... + + +_TrueType = TypeVar("_TrueType") +_FalseType = TypeVar("_FalseType") + +@_type_operator +class _Cond(Generic[_T, _TrueType, _FalseType]): + """ + Type-level conditional expression. + _Cond[IsSub[T, Base], TrueType, FalseType] evaluates to TrueType if T is a subtype of Base, + otherwise FalseType. + """ + + ... diff --git a/mypy/typeshed/stdlib/typing.pyi b/mypy/typeshed/stdlib/typing.pyi index 5bb2d66d9ab66..5fe1f39033972 100644 --- a/mypy/typeshed/stdlib/typing.pyi +++ b/mypy/typeshed/stdlib/typing.pyi @@ -1358,19 +1358,6 @@ class IsSub(Generic[_T, _Base]): ... -_TrueType = TypeVar("_TrueType") -_FalseType = TypeVar("_FalseType") - -@_type_operator -class _Cond(Generic[_T, _TrueType, _FalseType]): - """ - Type-level conditional expression. - _Cond[IsSub[T, Base], TrueType, FalseType] evaluates to TrueType if T is a subtype of Base, - otherwise FalseType. - """ - - ... - @_type_operator class Iter(Generic[_T]): """ From 2a50be8d59ed1dec31e69bda9b40f0af8a83bb82 Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Wed, 21 Jan 2026 10:25:29 -0800 Subject: [PATCH 024/161] [typemap] Split Phase 3 into 3A (core conditionals) and 3B (remaining operators) Update implementation plan to split Phase 3 into two parts: Phase 3A: Core Conditional Types (_Cond and IsSub) - Minimal TypeLevelEvaluator with eval_condition and eval_conditional - Register typing._Cond operator - IsSub evaluated as a condition (not a separate operator) - Foundation for conditional type expressions Phase 3B: Remaining Type Operators - Helper methods (get_type_args_for_base, extract_literal_*, etc.) - Introspection operators (GetArg, GetArgs, Members, Attrs, etc.) - String operations (Slice, Concat, Uppercase, etc.) - Type construction (NewProtocol, NewTypedDict) - Type comprehension evaluation Also updated milestones to: - Mark Milestones 1-2 as completed - Add Milestone 3A and 3B - Renumber subsequent milestones Co-Authored-By: Claude Opus 4.5 --- TYPEMAP_IMPLEMENTATION_PLAN.md | 198 ++++++++++++++++++++++++++------- 1 file changed, 156 insertions(+), 42 deletions(-) diff --git a/TYPEMAP_IMPLEMENTATION_PLAN.md b/TYPEMAP_IMPLEMENTATION_PLAN.md index 2590df3f854df..55222a4647b9d 100644 --- a/TYPEMAP_IMPLEMENTATION_PLAN.md +++ b/TYPEMAP_IMPLEMENTATION_PLAN.md @@ -568,29 +568,26 @@ def analyze_callable_type(self, t: UnboundType) -> Type: --- -## Phase 3: Type Evaluation Engine (`mypy/typelevel.py` - new file) +## Phase 3A: Core Conditional Types (`_Cond` and `IsSub`) -### 3.1 Create Type Evaluator +This phase implements the core conditional type evaluation - just `_Cond` and `IsSub`. +This is the foundation that enables conditional type expressions to work. -A new module for evaluating type-level computations. The evaluator dispatches -based on the operator's fullname rather than using isinstance checks for each -operator type. +### 3A.1 Create Type Evaluator Core ```python -"""Type-level computation evaluation.""" +"""Type-level computation evaluation - Core conditional types.""" from __future__ import annotations from typing import Callable from mypy.types import ( - Type, ProperType, Instance, TupleType, UnionType, LiteralType, - TypedDictType, CallableType, NoneType, AnyType, UninhabitedType, - TypeOperatorType, TypeForComprehension, + Type, ProperType, TypeOperatorType, TypeVarType, ) from mypy.subtypes import is_subtype from mypy.typeops import get_proper_type -from mypy.nodes import TypeInfo +from mypy.type_visitor import TypeQuery # Registry mapping operator fullnames to their evaluation functions @@ -608,16 +605,13 @@ def register_operator(fullname: str): class TypeLevelEvaluator: """Evaluates type-level computations to concrete types.""" - def __init__(self, api: SemanticAnalyzerInterface): + def __init__(self, api: SemanticAnalyzerCoreInterface): self.api = api def evaluate(self, typ: Type) -> Type: """Main entry point: evaluate a type to its simplified form.""" if isinstance(typ, TypeOperatorType): return self.eval_operator(typ) - elif isinstance(typ, TypeForComprehension): - return self.eval_comprehension(typ) - return typ # Already a concrete type or can't be evaluated def eval_operator(self, typ: TypeOperatorType) -> Type: @@ -661,6 +655,114 @@ class TypeLevelEvaluator: # Undecidable - keep as _Cond TypeOperatorType return typ + def contains_unresolved_typevar(self, typ: Type) -> bool: + """Check if type contains unresolved type variables.""" + + class HasTypeVar(TypeQuery[bool]): + def __init__(self): + super().__init__(any) + + def visit_type_var(self, t: TypeVarType) -> bool: + return True + + return typ.accept(HasTypeVar()) + + +# --- Operator Implementations for Phase 3A --- + +@register_operator('typing._Cond') +def eval_cond(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: + """Evaluate _Cond[condition, TrueType, FalseType]""" + return evaluator.eval_conditional(typ) + + +# Note: IsSub is not registered as an operator because it's not meant to be +# expanded directly - it's evaluated as a condition within _Cond. + + +# --- Public API --- + +def evaluate_type_operator(typ: TypeOperatorType) -> Type: + """Evaluate a TypeOperatorType. Called from TypeOperatorType.expand(). + + Uses typelevel_ctx.api to access the semantic analyzer. + """ + if typelevel_ctx.api is None: + # No context available - can't evaluate yet + return AnyType(TypeOfAny.special_form) + + evaluator = TypeLevelEvaluator(typelevel_ctx.api) + return evaluator.eval_operator(typ) +``` + +--- + +## Phase 3B: Remaining Type Operators + +This phase implements the remaining type operators after the core conditional types +are working. These include introspection, construction, and utility operators. + +### 3B.1 Type Introspection Operators + +Extend `TypeLevelEvaluator` with helper methods and implement the introspection operators: + +```python +# --- Additional helper methods for TypeLevelEvaluator --- + +class TypeLevelEvaluator: + # ... methods from Phase 3A ... + + def get_type_args_for_base(self, instance: Instance, base: TypeInfo) -> list[Type] | None: + """Get type args when viewing instance as base class.""" + for base_instance in instance.type.mro: + if base_instance == base: + return self.map_type_args_to_base(instance, base) + return None + + def map_type_args_to_base(self, instance: Instance, base: TypeInfo) -> list[Type]: + """Map instance's type args through inheritance chain to base.""" + from mypy.expandtype import expand_type_by_instance + for b in instance.type.bases: + b_proper = get_proper_type(b) + if isinstance(b_proper, Instance) and b_proper.type == base: + return list(expand_type_by_instance(b_proper, instance).args) + return [] + + def extract_literal_string(self, typ: Type) -> str | None: + """Extract string value from LiteralType.""" + typ = get_proper_type(typ) + if isinstance(typ, LiteralType) and isinstance(typ.value, str): + return typ.value + return None + + def extract_literal_int(self, typ: Type) -> int | None: + """Extract int value from LiteralType.""" + typ = get_proper_type(typ) + if isinstance(typ, LiteralType) and isinstance(typ.value, int): + return typ.value + return None + + def make_member_instance( + self, + name: str, + member_type: Type, + quals: Type, + init: Type, + definer: Type, + ) -> Instance: + """Create a Member[...] instance type.""" + member_info = self.api.lookup_qualified('typing.Member', ...).node + return Instance( + member_info, + [ + LiteralType(name, self.api.named_type('builtins.str')), + member_type, + quals, + init, + definer, + ], + ) + def eval_comprehension(self, typ: TypeForComprehension) -> Type: """Evaluate *[Expr for x in Iter[T] if Cond]""" # First, evaluate the iter_type to get what we're iterating over @@ -1391,55 +1493,67 @@ Port examples from the PEP: ## Phase 9: Incremental Implementation Order -### Milestone 1: Core Type Operators (Weeks 1-3) -1. Add `MemberType`, `ParamType` type classes -2. Add `GetArg`, `GetArgs`, `FromUnion` operators +### Milestone 1: Foundation (Weeks 1-2) ✓ COMPLETED +1. Add `ComputedType` base class, `TypeOperatorType`, `TypeForComprehension` +2. Add `is_type_operator` flag to `TypeInfo` +3. Declare type operators in typeshed with `@_type_operator` +4. Update type visitors for new types + +### Milestone 2: Type Analysis (Weeks 3-4) ✓ COMPLETED +1. Detect `@_type_operator` decorated classes in semanal.py +2. Construct `TypeOperatorType` in typeanal.py when encountering type operators +3. Parse ternary syntax `X if Cond else Y` to `_Cond[Cond, X, Y]` +4. Add context variable for API access (`typelevel_ctx`) +5. Tests for type operator detection and ternary parsing + +### Milestone 3A: Core Conditional Evaluation (Week 5) +1. Implement `TypeLevelEvaluator` core with `eval_condition` and `eval_conditional` +2. Register `typing._Cond` operator +3. Implement `IsSub` condition evaluation using `is_subtype()` +4. Wire up `typelevel_ctx` in type analysis +5. Tests for conditional type evaluation + +### Milestone 3B: Introspection Operators (Weeks 6-7) +1. Add `GetArg`, `GetArgs`, `FromUnion` operators +2. Add `GetAttr` operator 3. Add `Members`, `Attrs` operators -4. Basic type evaluator for these operators -5. Tests for basic operations - -### Milestone 2: Conditional Types (Weeks 4-5) -1. Add `_Cond` type operator (conditional types are TypeOperatorType, not a separate class) -2. Add `IsSub` condition operator -3. Implement ternary syntax parsing to `_Cond` conversion -4. Integrate with type evaluator -5. Tests for conditionals - -### Milestone 3: Type Comprehensions (Weeks 6-7) -1. Add `TypeForComprehension`, `IterType` -2. Parser support for comprehension syntax -3. Evaluator support for comprehensions +4. Tests for introspection operators + +### Milestone 4: Type Comprehensions (Weeks 8-9) +1. Add `TypeForComprehension` evaluation +2. Parser support for comprehension syntax `*[... for x in Iter[T]]` +3. Add `Iter` operator support 4. Tests for comprehensions -### Milestone 4: NewProtocol/NewTypedDict (Weeks 8-10) -1. Add `NewProtocolType`, `NewTypedDictType` -2. Implement synthetic TypeInfo creation +### Milestone 5: Type Construction (Weeks 10-12) +1. Add `NewProtocol` - synthetic protocol creation +2. Add `NewTypedDict` - synthetic TypedDict creation 3. Integration with type checking 4. Tests for type construction -### Milestone 5: Extended Callables (Weeks 11-12) +### Milestone 6: Extended Callables (Weeks 13-14) 1. Full `Param` type support 2. Callable introspection 3. Extended callable construction 4. Tests for callables -### Milestone 6: String Operations (Week 13) -1. Add string operation types -2. Implement string evaluators +### Milestone 7: String Operations (Week 15) +1. Add `Slice`, `Concat` operators +2. Add `Uppercase`, `Lowercase`, `Capitalize`, `Uncapitalize` 3. Tests for string ops -### Milestone 7: Annotated & InitField (Weeks 14-15) +### Milestone 8: Annotated & InitField (Weeks 16-17) 1. Preserve Annotated metadata -2. GetAnnotations/DropAnnotations +2. `GetAnnotations`/`DropAnnotations` 3. InitField support 4. Tests -### Milestone 8: TypedDict kwargs inference (Week 16) +### Milestone 9: TypedDict kwargs inference (Week 18) 1. `Unpack[K]` for TypeVar K 2. Inference from kwargs 3. Tests -### Milestone 9: Integration & Polish (Weeks 17-20) +### Milestone 10: Integration & Polish (Weeks 19-20) 1. Full PEP examples working 2. Error messages 3. Documentation From 28a516f9923ac03d4d801075ae791b71df6e9b53 Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Wed, 21 Jan 2026 13:12:32 -0800 Subject: [PATCH 025/161] Make the proper_plugin complain less --- mypy/plugins/proper_plugin.py | 3 +++ mypy/types.py | 6 ++---- mypy/types_utils.py | 5 +++-- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/mypy/plugins/proper_plugin.py b/mypy/plugins/proper_plugin.py index 4221e5f7c0756..d7e50fab48806 100644 --- a/mypy/plugins/proper_plugin.py +++ b/mypy/plugins/proper_plugin.py @@ -86,6 +86,9 @@ def is_special_target(right: ProperType) -> bool: "mypy.types.Type", "mypy.types.ProperType", "mypy.types.TypeAliasType", + "mypy.types.ComputedType", + "mypy.types.TypeOperatorType", + "mypy.types.TypeForComprehension", ): # Special case: things like assert isinstance(typ, ProperType) are always OK. return True diff --git a/mypy/types.py b/mypy/types.py index d735b0c23fa40..6f566a4722ae5 100644 --- a/mypy/types.py +++ b/mypy/types.py @@ -3882,9 +3882,8 @@ def get_proper_type(typ: Type | None) -> ProperType | None: while True: if isinstance(typ, TypeAliasType): typ = typ._expand_once() - elif isinstance(typ, ComputedType): # type: ignore[misc] + elif isinstance(typ, ComputedType): # Handles TypeOperatorType, TypeForComprehension - # Note: This isinstance check is intentional - this function does the expansion typ = typ.expand() else: break @@ -3909,8 +3908,7 @@ def get_proper_types( typelist = types # Optimize for the common case so that we don't need to allocate anything if not any( - isinstance(t, (TypeAliasType, TypeGuardedType, ComputedType)) # type: ignore[misc] - for t in typelist + isinstance(t, (TypeAliasType, TypeGuardedType, ComputedType)) for t in typelist ): return cast("list[ProperType]", typelist) return [get_proper_type(t) for t in typelist] diff --git a/mypy/types_utils.py b/mypy/types_utils.py index f811086ca68b6..8a803ca4cef9c 100644 --- a/mypy/types_utils.py +++ b/mypy/types_utils.py @@ -66,8 +66,9 @@ def is_invalid_recursive_alias(seen_nodes: set[TypeAlias], target: Type) -> bool return True assert target.alias, f"Unfixed type alias {target.type_ref}" return is_invalid_recursive_alias(seen_nodes | {target.alias}, get_proper_type(target)) - # Handle ComputedType (TypeOperatorType, TypeForComprehension) by expanding to ProperType - target = get_proper_type(target) + if isinstance(target, ComputedType): + # XXX: We need to do *something* useful here!! + return False assert isinstance(target, ProperType) if not isinstance(target, (UnionType, TupleType)): return False From 9890ef82a42a9f62b08ac43f99c76b49cf3cdc52 Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Wed, 21 Jan 2026 13:17:30 -0800 Subject: [PATCH 026/161] Make IsSub and Cond evaluation start working --- mypy/build.py | 4 +- mypy/checkexpr.py | 8 + mypy/exprtotype.py | 16 ++ mypy/fastparse.py | 2 +- mypy/messages.py | 8 + mypy/typelevel.py | 166 +++++++++++-- mypy/types.py | 28 +++ mypy/types_utils.py | 1 + test-data/unit/check-typelevel-basic.test | 275 +++++++++------------- test-data/unit/fixtures/typelevel.pyi | 66 ++++++ test-data/unit/fixtures/typing-full.pyi | 7 + 11 files changed, 390 insertions(+), 191 deletions(-) create mode 100644 test-data/unit/fixtures/typelevel.pyi diff --git a/mypy/build.py b/mypy/build.py index 4fe6f52f58287..d8e5602766293 100644 --- a/mypy/build.py +++ b/mypy/build.py @@ -120,6 +120,7 @@ from mypy.partially_defined import PossiblyUndefinedVariableVisitor from mypy.semanal import SemanticAnalyzer from mypy.semanal_pass1 import SemanticAnalyzerPreAnalysis +from mypy.typelevel import typelevel_ctx from mypy.util import ( DecodeError, decode_python_encoding, @@ -483,7 +484,8 @@ def build_inner( reset_global_state() try: - graph = dispatch(sources, manager, stdout) + with typelevel_ctx.set_api(manager.semantic_analyzer): + graph = dispatch(sources, manager, stdout) if not options.fine_grained_incremental: type_state.reset_all_subtype_caches() if options.timing_stats is not None: diff --git a/mypy/checkexpr.py b/mypy/checkexpr.py index c276329bc02f0..4274d3095d42c 100644 --- a/mypy/checkexpr.py +++ b/mypy/checkexpr.py @@ -168,6 +168,7 @@ TUPLE_LIKE_INSTANCE_NAMES, AnyType, CallableType, + ComputedType, DeletedType, ErasedType, ExtraAttrs, @@ -205,6 +206,7 @@ has_type_vars, is_named_instance, split_with_prefix_and_suffix, + try_expand, ) from mypy.types_utils import ( is_generic_instance, @@ -4797,6 +4799,12 @@ def visit_reveal_expr(self, expr: RevealExpr) -> Type: revealed_type = self.accept( expr.expr, type_context=self.type_context[-1], allow_none_return=True ) + + # XXX: We do this to expand ComputedTypes -- but should we *need* to? + # and do we want to? + if isinstance(revealed_type, ComputedType): + revealed_type = try_expand(revealed_type) + if not self.chk.current_node_deferred: self.msg.reveal_type(revealed_type, expr.expr) if not self.chk.in_checked_function(): diff --git a/mypy/exprtotype.py b/mypy/exprtotype.py index ae36fc8adde09..0d9d3ad3358c8 100644 --- a/mypy/exprtotype.py +++ b/mypy/exprtotype.py @@ -10,6 +10,7 @@ BytesExpr, CallExpr, ComplexExpr, + ConditionalExpr, Context, DictExpr, EllipsisExpr, @@ -285,5 +286,20 @@ def expr_to_unanalyzed_type( ) result.extra_items_from = extra_items_from return result + elif isinstance(expr, ConditionalExpr): + + # Use __builtins__ so it can be resolved without explicit import + return UnboundType( + "__builtins__._Cond", + [ + expr_to_unanalyzed_type( + arg, options, allow_new_syntax, expr, lookup_qualified=lookup_qualified + ) + for arg in [expr.cond, expr.if_expr, expr.else_expr] + ], + line=expr.line, + column=expr.column, + ) + else: raise TypeTranslationError() diff --git a/mypy/fastparse.py b/mypy/fastparse.py index 6520672bec3cd..51c43c947a3ca 100644 --- a/mypy/fastparse.py +++ b/mypy/fastparse.py @@ -2174,7 +2174,7 @@ def visit_IfExp(self, n: ast3.IfExp) -> Type: true_type = self.visit(n.body) false_type = self.visit(n.orelse) - # Use fully qualified name so it can be resolved without explicit import + # Use __builtins__ so it can be resolved without explicit import return UnboundType( "__builtins__._Cond", [condition, true_type, false_type], diff --git a/mypy/messages.py b/mypy/messages.py index 51bb0b7ee9be6..4c2f5a715e350 100644 --- a/mypy/messages.py +++ b/mypy/messages.py @@ -90,6 +90,7 @@ TypeAliasType, TypedDictType, TypeOfAny, + TypeOperatorType, TypeStrVisitor, TypeType, TypeVarLikeType, @@ -102,6 +103,7 @@ flatten_nested_unions, get_proper_type, get_proper_types, + try_expand, ) from mypy.typetraverser import TypeTraverserVisitor from mypy.util import plural_s, unmangle @@ -2608,6 +2610,12 @@ def format_literal_value(typ: LiteralType) -> str: type_str += f"[{format_list(typ.args)}]" return type_str + typ = try_expand(typ) + if isinstance(typ, TypeOperatorType): + # There are type arguments. Convert the arguments to strings. + base_str = typ.type.fullname if typ.type else "" + return f"{base_str}[{format_list(typ.args)}]" + # TODO: always mention type alias names in errors. typ = get_proper_type(typ) diff --git a/mypy/typelevel.py b/mypy/typelevel.py index 173255a243972..f2a11dcafaae3 100644 --- a/mypy/typelevel.py +++ b/mypy/typelevel.py @@ -5,20 +5,30 @@ Note: Conditional types are now represented as _Cond[...] TypeOperatorType. -Note: This is a stub implementation. The full implementation will be added -in a later phase. +Phase 3A implements: _Cond and IsSub evaluation """ from __future__ import annotations -from collections.abc import Iterator +from collections.abc import Callable, Iterator from contextlib import contextmanager from typing import TYPE_CHECKING, Final -from mypy.types import AnyType, Type, TypeForComprehension, TypeOfAny, TypeOperatorType +from mypy.subtypes import is_subtype +from mypy.types import ( + AnyType, + LiteralType, + Type, + TypeForComprehension, + TypeOfAny, + TypeOperatorType, + UninhabitedType, + get_proper_type, + has_type_vars, +) if TYPE_CHECKING: - from mypy.semanal_shared import SemanticAnalyzerCoreInterface + from mypy.semanal_shared import SemanticAnalyzerInterface class TypeLevelContext: @@ -30,15 +40,15 @@ class TypeLevelContext: """ def __init__(self) -> None: - self._api: SemanticAnalyzerCoreInterface | None = None + self._api: SemanticAnalyzerInterface | None = None @property - def api(self) -> SemanticAnalyzerCoreInterface | None: + def api(self) -> SemanticAnalyzerInterface | None: """Get the current semantic analyzer API, or None if not in context.""" return self._api @contextmanager - def set_api(self, api: SemanticAnalyzerCoreInterface) -> Iterator[None]: + def set_api(self, api: SemanticAnalyzerInterface) -> Iterator[None]: """Context manager to set the API for type-level evaluation. Usage: @@ -58,30 +68,138 @@ def set_api(self, api: SemanticAnalyzerCoreInterface) -> Iterator[None]: typelevel_ctx: Final = TypeLevelContext() +# Registry mapping operator fullnames to their evaluation functions +_OPERATOR_EVALUATORS: dict[str, Callable[[TypeLevelEvaluator, TypeOperatorType], Type]] = {} + + +EXPANSION_ANY = AnyType(TypeOfAny.expansion_stuck) + + +def register_operator( + fullname: str, +) -> Callable[ + [Callable[[TypeLevelEvaluator, TypeOperatorType], Type]], + Callable[[TypeLevelEvaluator, TypeOperatorType], Type], +]: + """Decorator to register an operator evaluator.""" + + def decorator( + func: Callable[[TypeLevelEvaluator, TypeOperatorType], Type], + ) -> Callable[[TypeLevelEvaluator, TypeOperatorType], Type]: + _OPERATOR_EVALUATORS[fullname] = func + return func + + return decorator + + +class TypeLevelEvaluator: + """Evaluates type-level computations to concrete types. + + Phase 3A: Core conditional type evaluation (_Cond and IsSub). + """ + + def __init__(self, api: SemanticAnalyzerInterface) -> None: + self.api = api + + def evaluate(self, typ: Type) -> Type: + """Main entry point: evaluate a type to its simplified form.""" + if isinstance(typ, TypeOperatorType): + return self.eval_operator(typ) + return typ # Already a concrete type or can't be evaluated + + def eval_operator(self, typ: TypeOperatorType) -> Type: + """Evaluate a type operator by dispatching to registered handler.""" + fullname = typ.fullname + evaluator = _OPERATOR_EVALUATORS.get(fullname) + + if evaluator is None: + # print("NO EVALUATOR", fullname) + + # Unknown operator - return Any for now + # In Phase 3B, unregistered operators will be handled appropriately + return EXPANSION_ANY + + return evaluator(self, typ) + + +# --- Operator Implementations for Phase 3A --- + + +@register_operator("builtins._Cond") +def _eval_cond(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: + """Evaluate _Cond[condition, TrueType, FalseType].""" + + if len(typ.args) != 3: + return UninhabitedType() + + condition, true_type, false_type = typ.args + result = extract_literal_bool(evaluator.evaluate(condition)) + + if result is True: + return true_type + elif result is False: + return false_type + else: + # Undecidable - return Any for now + # In the future, we might want to keep the conditional and defer evaluation + return EXPANSION_ANY + + +@register_operator("typing.IsSub") +def _eval_issub(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: + """Evaluate a type-level condition (IsSub[T, Base]).""" + + if len(typ.args) != 2: + return UninhabitedType() + + lhs, rhs = typ.args + + left = evaluator.evaluate(lhs) + right = evaluator.evaluate(rhs) + + # Get proper types for subtype check + left_proper = get_proper_type(left) + right_proper = get_proper_type(right) + + # Handle type variables - may be undecidable + # XXX: Do I care? + if has_type_vars(left_proper) or has_type_vars(right_proper): + return EXPANSION_ANY + + result = is_subtype(left_proper, right_proper) + + return LiteralType(result, evaluator.api.named_type("builtins.bool")) + + +def extract_literal_bool(typ: Type) -> bool | None: + """Extract int value from LiteralType.""" + typ = get_proper_type(typ) + if isinstance(typ, LiteralType) and isinstance(typ.value, bool): + return typ.value + return None + + +# --- Public API --- + + def evaluate_type_operator(typ: TypeOperatorType) -> Type: """Evaluate a TypeOperatorType. Called from TypeOperatorType.expand(). - Returns the type unchanged if evaluation is not yet possible. + Uses typelevel_ctx.api to access the semantic analyzer. """ - # Stub implementation - return Any to avoid infinite loops in get_proper_type - # The real implementation will: - # 1. Check if typelevel_ctx.api is available - # 2. If so, evaluate the type operator using the API - # 3. If not, return the type unchanged (will be evaluated later) - # - # Example future implementation: - # if typelevel_ctx.api is not None: - # evaluator = TypeLevelEvaluator(typelevel_ctx.api) - # return evaluator.eval_operator(typ) - # return typ # Can't evaluate yet, return unchanged - return AnyType(TypeOfAny.special_form) + if typelevel_ctx.api is None: + raise AssertionError("No access to semantic analyzer!") + + evaluator = TypeLevelEvaluator(typelevel_ctx.api) + res = evaluator.eval_operator(typ) + # print("EVALED!!", res) + return res def evaluate_comprehension(typ: TypeForComprehension) -> Type: """Evaluate a TypeForComprehension. Called from TypeForComprehension.expand(). - Returns the type unchanged if evaluation is not yet possible. + Returns Any for now - full implementation in Phase 3B. """ # Stub implementation - return Any to avoid infinite loops in get_proper_type - # The real implementation will use typelevel_ctx.api similar to evaluate_type_operator - return AnyType(TypeOfAny.special_form) + return EXPANSION_ANY diff --git a/mypy/types.py b/mypy/types.py index 6f566a4722ae5..ad8a6b9194fb5 100644 --- a/mypy/types.py +++ b/mypy/types.py @@ -240,6 +240,9 @@ class TypeOfAny: # used to ignore Anys inserted by the suggestion engine when # generating constraints. suggestion_engine: Final = 9 + # Does this Any comes from typelevel computation getting stuck by an + # unsubstituted type-variable + expansion_stuck: Final = 10 def deserialize_type(data: JsonDict | str) -> Type: @@ -3916,6 +3919,31 @@ def get_proper_types( return [get_proper_type(t) for t in types] +def is_stuck_expansion(typ: Type) -> bool: + return ( + isinstance(typ, ProperType) + and isinstance(typ, AnyType) + and typ.type_of_any == TypeOfAny.expansion_stuck + ) + + +def try_expand_or_none(type: Type) -> ProperType | None: + """Try to expand a type, but return None if it gets stuck""" + type2 = get_proper_type(type) + if is_stuck_expansion(type2): + return None + else: + return type2 + + +def try_expand(type: Type) -> Type: + """Try to expand a type, but return the original type if it gets stuck""" + if type2 := try_expand_or_none(type): + return type2 + else: + return type + + # We split off the type visitor base classes to another module # to make it easier to gradually get modules working with mypyc. # Import them here, after the types are defined. diff --git a/mypy/types_utils.py b/mypy/types_utils.py index 8a803ca4cef9c..7d8a08ea9b156 100644 --- a/mypy/types_utils.py +++ b/mypy/types_utils.py @@ -15,6 +15,7 @@ from mypy.types import ( AnyType, CallableType, + ComputedType, Instance, LiteralType, NoneType, diff --git a/test-data/unit/check-typelevel-basic.test b/test-data/unit/check-typelevel-basic.test index 586b8ce4d2b27..17d80188689e9 100644 --- a/test-data/unit/check-typelevel-basic.test +++ b/test-data/unit/check-typelevel-basic.test @@ -1,231 +1,176 @@ -[case testTypeOperatorBasic] -# Test basic type operator detection and construction -from typing import Generic, TypeVar -from m import IsSub, _Cond +[case testTypeOperatorIsSub] +from typing import IsSub -T = TypeVar('T') - -# Direct use of IsSub type operator -def test_issub(x: IsSub[int, object]) -> None: - pass - -# Direct use of _Cond type operator -def test_cond(x: _Cond[IsSub[int, object], str, int]) -> None: - pass - -[file m.pyi] -from typing import TypeVar, Generic - -def _type_operator(cls: type[T]) -> type[T]: ... -T = TypeVar('T') -_T = TypeVar('_T') -_Base = TypeVar('_Base') -_TrueType = TypeVar('_TrueType') -_FalseType = TypeVar('_FalseType') +# Ternary syntax should be converted to _Cond[condition, true_type, false_type] +x: IsSub[int, object] +reveal_type(x) # N: Revealed type is "Literal[True]" -@_type_operator -class IsSub(Generic[_T, _Base]): ... +y: IsSub[int, str] +reveal_type(y) # N: Revealed type is "Literal[False]" -@_type_operator -class _Cond(Generic[_T, _TrueType, _FalseType]): ... +[builtins fixtures/typelevel.pyi] +[typing fixtures/typing-full.pyi] -[builtins fixtures/tuple.pyi] [case testTypeOperatorTernarySyntax] -# Test ternary syntax conversion to _Cond -from m import IsSub +from typing import IsSub # Ternary syntax should be converted to _Cond[condition, true_type, false_type] x: str if IsSub[int, object] else int -[file m.pyi] -from typing import TypeVar, Generic +x = 0 # E: Incompatible types in assignment (expression has type "int", variable has type "str") -def _type_operator(cls: type[T]) -> type[T]: ... -T = TypeVar('T') -_T = TypeVar('_T') -_Base = TypeVar('_Base') -_TrueType = TypeVar('_TrueType') -_FalseType = TypeVar('_FalseType') - -@_type_operator -class IsSub(Generic[_T, _Base]): ... - -@_type_operator -class _Cond(Generic[_T, _TrueType, _FalseType]): ... - -[builtins fixtures/tuple.pyi] - -[case testTypeOperatorTernarySyntaxWithExplicitImport] -# Test ternary syntax alongside explicit _Cond usage -from m import IsSub, _Cond - -# Both forms should work -x: str if IsSub[int, object] else int -y: _Cond[IsSub[int, object], str, int] - -[file m.pyi] -from typing import TypeVar, Generic - -def _type_operator(cls: type[T]) -> type[T]: ... -T = TypeVar('T') -_T = TypeVar('_T') -_Base = TypeVar('_Base') -_TrueType = TypeVar('_TrueType') -_FalseType = TypeVar('_FalseType') - -@_type_operator -class IsSub(Generic[_T, _Base]): ... - -@_type_operator -class _Cond(Generic[_T, _TrueType, _FalseType]): ... - -[builtins fixtures/tuple.pyi] +[builtins fixtures/typelevel.pyi] +[typing fixtures/typing-full.pyi] [case testTypeOperatorInGenericClass] # Test type operators in generic class context -from typing import Generic, TypeVar -from m import IsSub, _Cond +from typing import Generic, TypeVar, IsSub T = TypeVar('T') class MyClass(Generic[T]): - # Ternary syntax in class attribute attr: str if IsSub[T, int] else float - # _Cond in method signature - def method(self, x: _Cond[IsSub[T, str], list[T], T]) -> None: + def method(self, x: list[T] if IsSub[T, str] else T) -> None: pass -[file m.pyi] -from typing import TypeVar, Generic +x: MyClass[int] +reveal_type(x.attr) # N: Revealed type is "builtins.str" +x.method(0) +x.method([0]) # E: Argument 1 to "method" of "MyClass" has incompatible type "list[int]"; expected "int" -def _type_operator(cls: type[T]) -> type[T]: ... -T = TypeVar('T') -_T = TypeVar('_T') -_Base = TypeVar('_Base') -_TrueType = TypeVar('_TrueType') -_FalseType = TypeVar('_FalseType') +y: MyClass[object] +reveal_type(y.attr) # N: Revealed type is "builtins.float" +y.method(0) +y.method([0]) -@_type_operator -class IsSub(Generic[_T, _Base]): ... +z: MyClass[str] +reveal_type(z.attr) # N: Revealed type is "builtins.float" +z.method('0') # E: Argument 1 to "method" of "MyClass" has incompatible type "str"; expected "list[str]" +z.method(['0']) -@_type_operator -class _Cond(Generic[_T, _TrueType, _FalseType]): ... -[builtins fixtures/tuple.pyi] +[builtins fixtures/typelevel.pyi] +[typing fixtures/typing-full.pyi] [case testTypeOperatorNestedTernary] # Test nested ternary expressions -from m import IsSub +from typing import IsSub # Nested ternary should work z: int if IsSub[int, str] else (float if IsSub[float, object] else str) +z = 'xxx' # E: Incompatible types in assignment (expression has type "str", variable has type "float") -[file m.pyi] -from typing import TypeVar, Generic - -def _type_operator(cls: type[T]) -> type[T]: ... -T = TypeVar('T') -_T = TypeVar('_T') -_Base = TypeVar('_Base') -_TrueType = TypeVar('_TrueType') -_FalseType = TypeVar('_FalseType') - -@_type_operator -class IsSub(Generic[_T, _Base]): ... - -@_type_operator -class _Cond(Generic[_T, _TrueType, _FalseType]): ... - -[builtins fixtures/tuple.pyi] +[builtins fixtures/typelevel.pyi] +[typing fixtures/typing-full.pyi] [case testTypeOperatorMultipleTypeArgs] # Test type operators with various argument types -from typing import Generic, TypeVar -from m import IsSub, _Cond +from typing import Generic, TypeVar, IsSub T = TypeVar('T') U = TypeVar('U') class Container(Generic[T, U]): # Complex conditional with multiple type variables - value: _Cond[IsSub[T, int], list[U], tuple[T, U]] - -[file m.pyi] -from typing import TypeVar, Generic - -def _type_operator(cls: type[T]) -> type[T]: ... -T = TypeVar('T') -_T = TypeVar('_T') -_Base = TypeVar('_Base') -_TrueType = TypeVar('_TrueType') -_FalseType = TypeVar('_FalseType') + value: list[U] if IsSub[T, int] else tuple[T, U] -@_type_operator -class IsSub(Generic[_T, _Base]): ... +x: Container[int, bool] +reveal_type(x.value) # N: Revealed type is "builtins.list[builtins.bool]" -@_type_operator -class _Cond(Generic[_T, _TrueType, _FalseType]): ... +y: Container[str, bool] +reveal_type(y.value) # N: Revealed type is "tuple[builtins.str, builtins.bool]" -[builtins fixtures/tuple.pyi] +[builtins fixtures/typelevel.pyi] +[typing fixtures/typing-full.pyi] -[case testTypeOperatorInFunctionSignature] +[case testTypeOperatorCall0] # Test type operators in function signatures -from typing import TypeVar -from m import IsSub, _Cond +from typing import TypeVar, IsSub T = TypeVar('T') +# XXX: resolving this seems basically impossible!! def process( - x: _Cond[IsSub[T, str], bytes, T], + x: bytes if IsSub[T, str] else T, y: str if IsSub[T, int] else float -) -> _Cond[IsSub[T, list[object]], int, str]: +) -> int if IsSub[T, list[object]] else str: ... -[file m.pyi] -from typing import TypeVar, Generic +[builtins fixtures/typelevel.pyi] +[typing fixtures/typing-full.pyi] -def _type_operator(cls: type[T]) -> type[T]: ... -T = TypeVar('T') -_T = TypeVar('_T') -_Base = TypeVar('_Base') -_TrueType = TypeVar('_TrueType') -_FalseType = TypeVar('_FalseType') +[case testTypeOperatorCall1] +# flags: --python-version 3.14 +# Test type operators in function signatures +from typing import IsSub -@_type_operator -class IsSub(Generic[_T, _Base]): ... +def process[T](x: T) -> IsSub[T, str]: + ... -@_type_operator -class _Cond(Generic[_T, _TrueType, _FalseType]): ... +reveal_type(process(0)) # N: Revealed type is "Literal[False]" +reveal_type(process('test')) # N: Revealed type is "Literal[True]" -[builtins fixtures/tuple.pyi] +[builtins fixtures/typelevel.pyi] +[typing fixtures/typing-full.pyi] -[case testTypeOperatorInTypeAlias] -# Test type operators in type aliases (using concrete types to avoid unbound typevar issues) -from m import IsSub, _Cond +[case testTypeOperatorCall2] +# flags: --python-version 3.14 -# Type alias using _Cond with concrete types -ConditionalType = _Cond[IsSub[int, object], str, bytes] +# Test a type operator in an *argument*, but one that should be resolvable +# No sweat!! +from typing import Callable, IsSub, Literal -# Note: Ternary syntax at module level without TypeAlias annotation is treated as -# a runtime expression, not a type alias. The ternary syntax works best in -# annotations (function signatures, variable annotations, class attributes). +def process[T](x: T, y: IsSub[T, str]) -> None: + ... -[file m.pyi] -from typing import TypeVar, Generic +process(0, False) +process(0, True) # E: Argument 2 to "process" has incompatible type "Literal[True]"; expected "Literal[False]" +process('test', False) # E: Argument 2 to "process" has incompatible type "Literal[False]"; expected "Literal[True]" +process('test', True) + +x0: Callable[[int, Literal[False]], None] = process +x1: Callable[[int, Literal[True]], None] = process # E: Incompatible types in assignment (expression has type "Callable[[T, typing.IsSub[T, str]], None]", variable has type "Callable[[int, Literal[True]], None]") +x2: Callable[[str, Literal[False]], None] = process # E: Incompatible types in assignment (expression has type "Callable[[T, typing.IsSub[T, str]], None]", variable has type "Callable[[str, Literal[False]], None]") +x3: Callable[[str, Literal[True]], None] = process -def _type_operator(cls: type[T]) -> type[T]: ... -T = TypeVar('T') -_T = TypeVar('_T') -_Base = TypeVar('_Base') -_TrueType = TypeVar('_TrueType') -_FalseType = TypeVar('_FalseType') -@_type_operator -class IsSub(Generic[_T, _Base]): ... +[builtins fixtures/typelevel.pyi] +[typing fixtures/typing-full.pyi] + +[case testTypeOperatorCall3] +# flags: --python-version 3.14 + +# Test a type operator in an *argument*, but one that should be resolvable +# No sweat!! +from typing import Callable, IsSub, Literal + +def process[T](x: IsSub[T, str], y: T) -> None: + ... + +process(False, 0) +process(True, 0) # E: Argument 1 to "process" has incompatible type "Literal[True]"; expected "Literal[False]" +process(False, 'test') # E: Argument 1 to "process" has incompatible type "Literal[False]"; expected "Literal[True]" +process(True, 'test') + +x0: Callable[[Literal[False], int], None] = process +x1: Callable[[Literal[True], int], None] = process # E: Incompatible types in assignment (expression has type "Callable[[typing.IsSub[T, str], T], None]", variable has type "Callable[[Literal[True], int], None]") +x2: Callable[[Literal[False], str], None] = process # E: Incompatible types in assignment (expression has type "Callable[[typing.IsSub[T, str], T], None]", variable has type "Callable[[Literal[False], str], None]") +x3: Callable[[Literal[True], str], None] = process + +[builtins fixtures/typelevel.pyi] +[typing fixtures/typing-full.pyi] + +[case testTypeOperatorInTypeAlias] +# flags: --python-version 3.14 +# Test type operators in type aliases (using concrete types to avoid unbound typevar issues) +from typing import IsSub + +# Type alias using _Cond with concrete types +type ConditionalType = str if IsSub[int, object] else bytes +z: ConditionalType = b'lol' # E: Incompatible types in assignment (expression has type "bytes", variable has type "str") -@_type_operator -class _Cond(Generic[_T, _TrueType, _FalseType]): ... -[builtins fixtures/tuple.pyi] +[builtins fixtures/typelevel.pyi] +[typing fixtures/typing-full.pyi] diff --git a/test-data/unit/fixtures/typelevel.pyi b/test-data/unit/fixtures/typelevel.pyi new file mode 100644 index 0000000000000..10edb76265ca2 --- /dev/null +++ b/test-data/unit/fixtures/typelevel.pyi @@ -0,0 +1,66 @@ +# Builtins stub used in tuple-related test cases. + +import _typeshed +from typing import Iterable, Iterator, TypeVar, Generic, Sequence, Optional, overload, Tuple, Type, Self, type_check_only, _type_operator + +_T = TypeVar("_T") +_Tco = TypeVar('_Tco', covariant=True) + +class object: + def __init__(self) -> None: pass + def __new__(cls) -> Self: ... + +class type: + def __init__(self, *a: object) -> None: pass + def __call__(self, *a: object) -> object: pass +class tuple(Sequence[_Tco], Generic[_Tco]): + def __hash__(self) -> int: ... + def __new__(cls: Type[_T], iterable: Iterable[_Tco] = ...) -> _T: ... + def __iter__(self) -> Iterator[_Tco]: pass + def __contains__(self, item: object) -> bool: pass + @overload + def __getitem__(self, x: int) -> _Tco: pass + @overload + def __getitem__(self, x: slice) -> Tuple[_Tco, ...]: ... + def __mul__(self, n: int) -> Tuple[_Tco, ...]: pass + def __rmul__(self, n: int) -> Tuple[_Tco, ...]: pass + def __add__(self, x: Tuple[_Tco, ...]) -> Tuple[_Tco, ...]: pass + def count(self, obj: object) -> int: pass +class function: + __name__: str +class ellipsis: pass +class classmethod: pass + +# We need int and slice for indexing tuples. +class int: + def __neg__(self) -> 'int': pass + def __pos__(self) -> 'int': pass +class float: pass +class slice: pass +class bool(int): pass +class str: pass # For convenience +class bytes: pass +class bytearray: pass + +class list(Sequence[_T], Generic[_T]): + @overload + def __getitem__(self, i: int) -> _T: ... + @overload + def __getitem__(self, s: slice) -> list[_T]: ... + def __contains__(self, item: object) -> bool: ... + def __iter__(self) -> Iterator[_T]: ... + +def isinstance(x: object, t: type) -> bool: pass + +class BaseException: pass + +class dict: pass + +# Type-level computation stuff + +_TrueType = TypeVar('_TrueType') +_FalseType = TypeVar('_FalseType') + +@type_check_only +@_type_operator +class _Cond(Generic[_T, _TrueType, _FalseType]): ... diff --git a/test-data/unit/fixtures/typing-full.pyi b/test-data/unit/fixtures/typing-full.pyi index 886bad7312a0d..5b39ac069c505 100644 --- a/test-data/unit/fixtures/typing-full.pyi +++ b/test-data/unit/fixtures/typing-full.pyi @@ -232,3 +232,10 @@ class TypeAliasType: def __or__(self, other: Any) -> Any: ... def __ror__(self, other: Any) -> Any: ... + +# Type computation! + +def _type_operator(cls: type[T]) -> type[T]: ... + +@_type_operator +class IsSub(Generic[T, U]): ... From 2d1830ebe15efadf281da36130008b922c7009ef Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Wed, 21 Jan 2026 14:20:04 -0800 Subject: [PATCH 027/161] Make the typing.pyi stubs version conditional --- mypy/semanal.py | 7 + mypy/typeshed/stdlib/typing.pyi | 427 +++++++++++----------- test-data/unit/check-typelevel-basic.test | 16 + 3 files changed, 237 insertions(+), 213 deletions(-) diff --git a/mypy/semanal.py b/mypy/semanal.py index 0b87c928dac89..8242ee3160e8e 100644 --- a/mypy/semanal.py +++ b/mypy/semanal.py @@ -5177,6 +5177,13 @@ def process_typevartuple_declaration(self, s: AssignmentStmt) -> bool: # PEP 646 does not specify the behavior of variance, constraints, or bounds. if not call.analyzed: + # XXX: Since adding a TypeVarTuple to typing.pyi, sometimes + # they get processed before builtins.tuple is available. + # Fix this by deferring, I guess. + if self.lookup_fully_qualified_or_none("builtins.tuple") is None: + self.defer() + return True + tuple_fallback = self.named_type("builtins.tuple", [self.object_type()]) typevartuple_var = TypeVarTupleExpr( name, diff --git a/mypy/typeshed/stdlib/typing.pyi b/mypy/typeshed/stdlib/typing.pyi index 5fe1f39033972..e08532bf520cc 100644 --- a/mypy/typeshed/stdlib/typing.pyi +++ b/mypy/typeshed/stdlib/typing.pyi @@ -1193,253 +1193,254 @@ if sys.version_info >= (3, 13): # specially by the type checker as type-level computation operators. def _type_operator(cls: type[_T]) -> type[_T]: ... -# MemberQuals: qualifiers that can apply to a Member -MemberQuals: typing_extensions.TypeAlias = Literal["ClassVar", "Final"] - -# ParamQuals: qualifiers that can apply to a Param -ParamQuals: typing_extensions.TypeAlias = Literal["positional", "keyword", "default", "*", "**"] - -# --- Data Types (used in type computations) --- - -_Name = TypeVar("_Name") -_Type = TypeVar("_Type") -_Quals = TypeVar("_Quals") -_Init = TypeVar("_Init") -_Definer = TypeVar("_Definer") - -class Member(Generic[_Name, _Type, _Quals, _Init, _Definer]): - """ - Represents a class member with name, type, qualifiers, initializer, and definer. - - _Name: Literal[str] - the member name - - _Type: the member's type - - _Quals: Literal['ClassVar'] | Literal['Final'] | Never - qualifiers - - _Init: the literal type of the initializer expression - - _Definer: the class that defined this member - """ - - name: _Name - typ: _Type - quals: _Quals - init: _Init - definer: _Definer - -class Param(Generic[_Name, _Type, _Quals]): - """ - Represents a function parameter for extended callable syntax. - - _Name: Literal[str] | None - the parameter name - - _Type: the parameter's type - - _Quals: Literal['positional', 'keyword', 'default', '*', '**'] - qualifiers - """ - - name: _Name - typ: _Type - quals: _Quals - - -_N = TypeVar("_N", bound=str) - -# Convenience aliases for Param - -# XXX: For mysterious reasons, if I mark this as `: -# typing_extensions.TypeAlias`, mypy thinks _N and _T are unbound... -PosParam = Param[_N, _T, Literal["positional"]] -PosDefaultParam = Param[_N, _T, Literal["positional", "default"]] -DefaultParam = Param[_N, _T, Literal["default"]] -NamedParam = Param[_N, _T, Literal["keyword"]] -NamedDefaultParam = Param[_N, _T, Literal["keyword", "default"]] -ArgsParam = Param[None, _T, Literal["*"]] -KwargsParam = Param[None, _T, Literal["**"]] - -# --- Type Introspection Operators --- - -_Base = TypeVar("_Base") -_Idx = TypeVar("_Idx") -_S1 = TypeVar("_S1") -_S2 = TypeVar("_S2") -_Start = TypeVar("_Start") -_End = TypeVar("_End") - -@_type_operator -class GetArg(Generic[_T, _Base, _Idx]): - """ - Get type argument at index _Idx from _T when viewed as _Base. - Returns Never if _T does not inherit from _Base or index is out of bounds. - """ - - ... - -@_type_operator -class GetArgs(Generic[_T, _Base]): - """ - Get all type arguments from _T when viewed as _Base, as a tuple. - Returns Never if _T does not inherit from _Base. - """ - - ... - -@_type_operator -class GetAttr(Generic[_T, _Name]): - """ - Get the type of attribute _Name from type _T. - _Name must be a Literal[str]. - """ - - ... - -@_type_operator -class Members(Generic[_T]): - """ - Get all members of type _T as a tuple of Member types. - Includes methods, class variables, and instance attributes. - """ - - ... - -@_type_operator -class Attrs(Generic[_T]): - """ - Get annotated instance attributes of _T as a tuple of Member types. - Excludes methods and ClassVar members. - """ - - ... - -@_type_operator -class FromUnion(Generic[_T]): - """ - Convert a union type to a tuple of its constituent types. - If _T is not a union, returns a 1-tuple containing _T. - """ +if sys.version_info >= (3, 15): + # MemberQuals: qualifiers that can apply to a Member + MemberQuals: typing_extensions.TypeAlias = Literal["ClassVar", "Final"] + + # ParamQuals: qualifiers that can apply to a Param + ParamQuals: typing_extensions.TypeAlias = Literal["positional", "keyword", "default", "*", "**"] + + # --- Data Types (used in type computations) --- + + _Name = TypeVar("_Name") + _Type = TypeVar("_Type") + _Quals = TypeVar("_Quals") + _Init = TypeVar("_Init") + _Definer = TypeVar("_Definer") + + class Member(Generic[_Name, _Type, _Quals, _Init, _Definer]): + """ + Represents a class member with name, type, qualifiers, initializer, and definer. + - _Name: Literal[str] - the member name + - _Type: the member's type + - _Quals: Literal['ClassVar'] | Literal['Final'] | Never - qualifiers + - _Init: the literal type of the initializer expression + - _Definer: the class that defined this member + """ + + name: _Name + typ: _Type + quals: _Quals + init: _Init + definer: _Definer + + class Param(Generic[_Name, _Type, _Quals]): + """ + Represents a function parameter for extended callable syntax. + - _Name: Literal[str] | None - the parameter name + - _Type: the parameter's type + - _Quals: Literal['positional', 'keyword', 'default', '*', '**'] - qualifiers + """ + + name: _Name + typ: _Type + quals: _Quals + + + _N = TypeVar("_N", bound=str) + + # Convenience aliases for Param + + # XXX: For mysterious reasons, if I mark this as `: + # typing_extensions.TypeAlias`, mypy thinks _N and _T are unbound... + PosParam = Param[_N, _T, Literal["positional"]] + PosDefaultParam = Param[_N, _T, Literal["positional", "default"]] + DefaultParam = Param[_N, _T, Literal["default"]] + NamedParam = Param[_N, _T, Literal["keyword"]] + NamedDefaultParam = Param[_N, _T, Literal["keyword", "default"]] + ArgsParam = Param[None, _T, Literal["*"]] + KwargsParam = Param[None, _T, Literal["**"]] + + # --- Type Introspection Operators --- + + _Base = TypeVar("_Base") + _Idx = TypeVar("_Idx") + _S1 = TypeVar("_S1") + _S2 = TypeVar("_S2") + _Start = TypeVar("_Start") + _End = TypeVar("_End") + + @_type_operator + class GetArg(Generic[_T, _Base, _Idx]): + """ + Get type argument at index _Idx from _T when viewed as _Base. + Returns Never if _T does not inherit from _Base or index is out of bounds. + """ + + ... + + @_type_operator + class GetArgs(Generic[_T, _Base]): + """ + Get all type arguments from _T when viewed as _Base, as a tuple. + Returns Never if _T does not inherit from _Base. + """ + + ... + + @_type_operator + class GetAttr(Generic[_T, _Name]): + """ + Get the type of attribute _Name from type _T. + _Name must be a Literal[str]. + """ + + ... + + @_type_operator + class Members(Generic[_T]): + """ + Get all members of type _T as a tuple of Member types. + Includes methods, class variables, and instance attributes. + """ + + ... + + @_type_operator + class Attrs(Generic[_T]): + """ + Get annotated instance attributes of _T as a tuple of Member types. + Excludes methods and ClassVar members. + """ + + ... + + @_type_operator + class FromUnion(Generic[_T]): + """ + Convert a union type to a tuple of its constituent types. + If _T is not a union, returns a 1-tuple containing _T. + """ - ... + ... -# --- Member/Param Accessors (defined as type aliases using GetAttr) --- + # --- Member/Param Accessors (defined as type aliases using GetAttr) --- -_MP = TypeVar("_MP", bound=Member[Any, Any, Any, Any, Any] | Param[Any, Any, Any]) -_M = TypeVar("_M", bound=Member[Any, Any, Any, Any, Any]) + _MP = TypeVar("_MP", bound=Member[Any, Any, Any, Any, Any] | Param[Any, Any, Any]) + _M = TypeVar("_M", bound=Member[Any, Any, Any, Any, Any]) -GetName = GetAttr[_MP, Literal["name"]] -GetType = GetAttr[_MP, Literal["typ"]] -GetQuals = GetAttr[_MP, Literal["quals"]] -GetInit = GetAttr[_M, Literal["init"]] -GetDefiner = GetAttr[_M, Literal["definer"]] + GetName = GetAttr[_MP, Literal["name"]] + GetType = GetAttr[_MP, Literal["typ"]] + GetQuals = GetAttr[_MP, Literal["quals"]] + GetInit = GetAttr[_M, Literal["init"]] + GetDefiner = GetAttr[_M, Literal["definer"]] -# --- Type Construction Operators --- + # --- Type Construction Operators --- -_Ts = TypeVarTuple("_Ts") if sys.version_info >= (3, 11) else TypeVar("_Ts") # type: ignore[misc] + _Ts = TypeVarTuple("_Ts") -@_type_operator -class NewProtocol(Generic[Unpack[_Ts]] if sys.version_info >= (3, 11) else Generic[_Ts]): # type: ignore[misc] - """ - Construct a new structural (protocol) type from Member types. - NewProtocol[Member[...], Member[...], ...] creates an anonymous protocol. - """ + @_type_operator + class NewProtocol(Generic[Unpack[_Ts]]): + """ + Construct a new structural (protocol) type from Member types. + NewProtocol[Member[...], Member[...], ...] creates an anonymous protocol. + """ - ... + ... -@_type_operator -class NewTypedDict(Generic[Unpack[_Ts]] if sys.version_info >= (3, 11) else Generic[_Ts]): # type: ignore[misc] - """ - Construct a new TypedDict from Member types. - NewTypedDict[Member[...], Member[...], ...] creates an anonymous TypedDict. - """ + @_type_operator + class NewTypedDict(Generic[Unpack[_Ts]]): + """ + Construct a new TypedDict from Member types. + NewTypedDict[Member[...], Member[...], ...] creates an anonymous TypedDict. + """ - ... + ... -# --- Boolean/Conditional Operators --- + # --- Boolean/Conditional Operators --- -@_type_operator -class IsSub(Generic[_T, _Base]): - """ - Type-level subtype check. Evaluates to a type-level boolean. - Used in conditional type expressions: `_Cond[IsSub[T, Base], X, Y]` - """ + @_type_operator + class IsSub(Generic[_T, _Base]): + """ + Type-level subtype check. Evaluates to a type-level boolean. + Used in conditional type expressions: `_Cond[IsSub[T, Base], X, Y]` + """ - ... + ... -@_type_operator -class Iter(Generic[_T]): - """ - Marks a type for iteration in type comprehensions. - `for x in Iter[T]` iterates over elements of tuple type T. - """ + @_type_operator + class Iter(Generic[_T]): + """ + Marks a type for iteration in type comprehensions. + `for x in Iter[T]` iterates over elements of tuple type T. + """ - ... + ... -# --- String Operations --- + # --- String Operations --- -@_type_operator -class Slice(Generic[_S, _Start, _End]): - """ - Slice a literal string type. - Slice[Literal["hello"], Literal[1], Literal[3]] = Literal["el"] - """ + @_type_operator + class Slice(Generic[_S, _Start, _End]): + """ + Slice a literal string type. + Slice[Literal["hello"], Literal[1], Literal[3]] = Literal["el"] + """ - ... + ... -@_type_operator -class Concat(Generic[_S1, _S2]): - """ - Concatenate two literal string types. - Concat[Literal["hello"], Literal["world"]] = Literal["helloworld"] - """ + @_type_operator + class Concat(Generic[_S1, _S2]): + """ + Concatenate two literal string types. + Concat[Literal["hello"], Literal["world"]] = Literal["helloworld"] + """ - ... + ... -@_type_operator -class Uppercase(Generic[_S]): - """Convert literal string to uppercase.""" + @_type_operator + class Uppercase(Generic[_S]): + """Convert literal string to uppercase.""" - ... + ... -@_type_operator -class Lowercase(Generic[_S]): - """Convert literal string to lowercase.""" + @_type_operator + class Lowercase(Generic[_S]): + """Convert literal string to lowercase.""" - ... + ... -@_type_operator -class Capitalize(Generic[_S]): - """Capitalize first character of literal string.""" + @_type_operator + class Capitalize(Generic[_S]): + """Capitalize first character of literal string.""" - ... + ... -@_type_operator -class Uncapitalize(Generic[_S]): - """Lowercase first character of literal string.""" + @_type_operator + class Uncapitalize(Generic[_S]): + """Lowercase first character of literal string.""" - ... + ... -# --- Annotated Operations --- + # --- Annotated Operations --- -@_type_operator -class GetAnnotations(Generic[_T]): - """ - Extract Annotated metadata from a type. - GetAnnotations[Annotated[int, 'foo', 'bar']] = Literal['foo', 'bar'] - GetAnnotations[int] = Never - """ + @_type_operator + class GetAnnotations(Generic[_T]): + """ + Extract Annotated metadata from a type. + GetAnnotations[Annotated[int, 'foo', 'bar']] = Literal['foo', 'bar'] + GetAnnotations[int] = Never + """ - ... + ... -@_type_operator -class DropAnnotations(Generic[_T]): - """ - Strip Annotated wrapper from a type. - DropAnnotations[Annotated[int, 'foo']] = int - DropAnnotations[int] = int - """ + @_type_operator + class DropAnnotations(Generic[_T]): + """ + Strip Annotated wrapper from a type. + DropAnnotations[Annotated[int, 'foo']] = int + DropAnnotations[int] = int + """ - ... + ... -# --- Utility Operators --- + # --- Utility Operators --- -@_type_operator -class Length(Generic[_T]): - """ - Get the length of a tuple type as a Literal[int]. - Returns Literal[None] for unbounded tuples. - """ + @_type_operator + class Length(Generic[_T]): + """ + Get the length of a tuple type as a Literal[int]. + Returns Literal[None] for unbounded tuples. + """ - ... + ... diff --git a/test-data/unit/check-typelevel-basic.test b/test-data/unit/check-typelevel-basic.test index 17d80188689e9..a61feb0fb3459 100644 --- a/test-data/unit/check-typelevel-basic.test +++ b/test-data/unit/check-typelevel-basic.test @@ -172,5 +172,21 @@ type ConditionalType = str if IsSub[int, object] else bytes z: ConditionalType = b'lol' # E: Incompatible types in assignment (expression has type "bytes", variable has type "str") +[builtins fixtures/typelevel.pyi] +[typing fixtures/typing-full.pyi] + +[case testTypeOperatorGenericTypeAlias] +# flags: --python-version 3.14 +# Test generic type alias with conditional type +from typing import IsSub + +type IntOptional[T] = T | None if IsSub[T, int] else T + +x0: IntOptional[int] +reveal_type(x0) # N: Revealed type is "builtins.int | None" + +x1: IntOptional[str] +reveal_type(x1) # N: Revealed type is "builtins.str" + [builtins fixtures/typelevel.pyi] [typing fixtures/typing-full.pyi] From 579abb9b5a5ccfba71c9bd515bd52615bf9aa054 Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Wed, 21 Jan 2026 14:46:47 -0800 Subject: [PATCH 028/161] Tweak plan --- TYPEMAP_IMPLEMENTATION_PLAN.md | 48 ++-------------------------------- 1 file changed, 2 insertions(+), 46 deletions(-) diff --git a/TYPEMAP_IMPLEMENTATION_PLAN.md b/TYPEMAP_IMPLEMENTATION_PLAN.md index 55222a4647b9d..0a0fff13dce24 100644 --- a/TYPEMAP_IMPLEMENTATION_PLAN.md +++ b/TYPEMAP_IMPLEMENTATION_PLAN.md @@ -1251,24 +1251,11 @@ def evaluate_comprehension(typ: TypeForComprehension) -> Type: ## Phase 4: Integration Points -### 4.1 Integrate with Type Alias Expansion (`mypy/types.py`) - -Modify `TypeAliasType._expand_once()` to evaluate type-level computations: - -```python -def _expand_once(self) -> Type: - # ... existing expansion logic ... - - # After substitution, evaluate type-level computations - if self.alias is not None: - result = expand_type(self.alias.target, type_env) - evaluator = TypeLevelEvaluator(...) - result = evaluator.evaluate(result) - return result -``` ### 4.2 Integrate with `expand_type()` (`mypy/expandtype.py`) +I THINK THIS IS NOT NEEDED + Extend `ExpandTypeVisitor` to handle new types: ```python @@ -1294,37 +1281,6 @@ class ExpandTypeVisitor(TypeTransformVisitor): Note: Conditional types are now `_Cond[...]` TypeOperatorType, so they are handled by `visit_type_operator_type` along with all other type operators. -### 4.3 Subtype Checking (`mypy/subtypes.py`) - -Add subtype rules for new types: - -```python -class SubtypeVisitor(TypeVisitor[bool]): - # ... existing methods ... - - def visit_type_operator_type(self, left: TypeOperatorType) -> bool: - # For _Cond[condition, TrueType, FalseType]: subtype if both branches are subtypes - # OR if we can evaluate the condition - if left.fullname == 'builtins._Cond' and len(left.args) == 3: - condition, true_type, false_type = left.args - evaluator = TypeLevelEvaluator(...) - result = evaluator.eval_condition(condition) - - if result is True: - return is_subtype(true_type, self.right) - elif result is False: - return is_subtype(false_type, self.right) - else: - # Must be subtype in both cases - return (is_subtype(true_type, self.right) and - is_subtype(false_type, self.right)) - - # For other type operators, expand first then check subtype - expanded = left.expand() - if expanded is not left: - return is_subtype(expanded, self.right) - return False # Unevaluatable type operator -``` ### 4.4 Type Inference with `**kwargs` TypeVar From 6c913cbcc0cfc3f4cf00e3d4f9d013094d03dd3a Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Wed, 21 Jan 2026 15:31:10 -0800 Subject: [PATCH 029/161] Implement a lot of the operators --- mypy/typelevel.py | 322 +++++++++++++++++++++- test-data/unit/check-typelevel-basic.test | 70 +++++ test-data/unit/fixtures/typing-full.pyi | 33 +++ 3 files changed, 414 insertions(+), 11 deletions(-) diff --git a/mypy/typelevel.py b/mypy/typelevel.py index f2a11dcafaae3..716514002b762 100644 --- a/mypy/typelevel.py +++ b/mypy/typelevel.py @@ -5,7 +5,6 @@ Note: Conditional types are now represented as _Cond[...] TypeOperatorType. -Phase 3A implements: _Cond and IsSub evaluation """ from __future__ import annotations @@ -17,17 +16,26 @@ from mypy.subtypes import is_subtype from mypy.types import ( AnyType, + Instance, LiteralType, + NoneType, + ProperType, + TupleType, Type, TypeForComprehension, + TypeAliasType, TypeOfAny, + TypeVarType, TypeOperatorType, UninhabitedType, + UnionType, get_proper_type, has_type_vars, + is_stuck_expansion, ) if TYPE_CHECKING: + from mypy.nodes import TypeInfo from mypy.semanal_shared import SemanticAnalyzerInterface @@ -92,6 +100,10 @@ def decorator( return decorator +class EvaluationStuck(Exception): + pass + + class TypeLevelEvaluator: """Evaluates type-level computations to concrete types. @@ -107,6 +119,17 @@ def evaluate(self, typ: Type) -> Type: return self.eval_operator(typ) return typ # Already a concrete type or can't be evaluated + def eval_proper(self, typ: Type) -> ProperType: + """Main entry point: evaluate a type to its simplified form.""" + typ = get_proper_type(self.evaluate(typ)) + # A call to another expansion via an alias got stuck, reraise here + if is_stuck_expansion(typ): + raise EvaluationStuck + if isinstance(typ, TypeVarType): + raise EvaluationStuck + + return typ + def eval_operator(self, typ: TypeOperatorType) -> Type: """Evaluate a type operator by dispatching to registered handler.""" fullname = typ.fullname @@ -121,6 +144,24 @@ def eval_operator(self, typ: TypeOperatorType) -> Type: return evaluator(self, typ) + # --- Type construction helpers --- + + def literal_bool(self, value: bool) -> LiteralType: + """Create a Literal[True] or Literal[False] type.""" + return LiteralType(value, self.api.named_type("builtins.bool")) + + def literal_int(self, value: int) -> LiteralType: + """Create a Literal[int] type.""" + return LiteralType(value, self.api.named_type("builtins.int")) + + def literal_str(self, value: str) -> LiteralType: + """Create a Literal[str] type.""" + return LiteralType(value, self.api.named_type("builtins.str")) + + def tuple_type(self, items: list[Type]) -> TupleType: + """Create a tuple type with the given items.""" + return TupleType(items, self.api.named_type("builtins.tuple")) + # --- Operator Implementations for Phase 3A --- @@ -154,31 +195,287 @@ def _eval_issub(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: lhs, rhs = typ.args - left = evaluator.evaluate(lhs) - right = evaluator.evaluate(rhs) - - # Get proper types for subtype check - left_proper = get_proper_type(left) - right_proper = get_proper_type(right) + left_proper = evaluator.eval_proper(lhs) + right_proper = evaluator.eval_proper(rhs) # Handle type variables - may be undecidable - # XXX: Do I care? if has_type_vars(left_proper) or has_type_vars(right_proper): return EXPANSION_ANY result = is_subtype(left_proper, right_proper) - return LiteralType(result, evaluator.api.named_type("builtins.bool")) + return evaluator.literal_bool(result) def extract_literal_bool(typ: Type) -> bool | None: - """Extract int value from LiteralType.""" + """Extract bool value from LiteralType.""" typ = get_proper_type(typ) if isinstance(typ, LiteralType) and isinstance(typ.value, bool): return typ.value return None +def extract_literal_int(typ: Type) -> int | None: + """Extract int value from LiteralType.""" + typ = get_proper_type(typ) + if isinstance(typ, LiteralType) and isinstance(typ.value, int) and not isinstance(typ.value, bool): + return typ.value + return None + + +def extract_literal_string(typ: Type) -> str | None: + """Extract string value from LiteralType.""" + typ = get_proper_type(typ) + if isinstance(typ, LiteralType) and isinstance(typ.value, str): + return typ.value + return None + + +# --- Phase 3B: Type Introspection Operators --- + + +@register_operator("typing.GetArg") +def _eval_get_arg(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: + """Evaluate GetArg[T, Base, Idx] - get type argument at index from T as Base.""" + if len(typ.args) != 3: + return UninhabitedType() + + target = evaluator.eval_proper(typ.args[0]) + base = evaluator.eval_proper(typ.args[1]) + idx_type = evaluator.eval_proper(typ.args[2]) + + # Extract index as int + index = extract_literal_int(idx_type) + if index is None: + return UninhabitedType() # Can't evaluate without literal index + + if isinstance(target, Instance) and isinstance(base, Instance): + args = get_type_args_for_base(target, base.type) + if args is not None and 0 <= index < len(args): + return args[index] + return UninhabitedType() # Never - invalid index or not a subtype + + return UninhabitedType() + + +@register_operator("typing.GetArgs") +def _eval_get_args(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: + """Evaluate GetArgs[T, Base] -> tuple of all type args from T as Base.""" + if len(typ.args) != 2: + return UninhabitedType() + + target = evaluator.eval_proper(typ.args[0]) + base = evaluator.eval_proper(typ.args[1]) + + if isinstance(target, Instance) and isinstance(base, Instance): + args = get_type_args_for_base(target, base.type) + if args is not None: + return evaluator.tuple_type(list(args)) + return UninhabitedType() + + return UninhabitedType() + + +@register_operator("typing.FromUnion") +def _eval_from_union(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: + """Evaluate FromUnion[T] -> tuple of union elements.""" + if len(typ.args) != 1: + return UninhabitedType() + + target = evaluator.eval_proper(typ.args[0]) + + if isinstance(target, UnionType): + return evaluator.tuple_type(list(target.items)) + else: + # Non-union becomes 1-tuple + return evaluator.tuple_type([target]) + + +@register_operator("typing.GetAttr") +def _eval_get_attr(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: + """Evaluate GetAttr[T, Name] - get attribute type from T.""" + if len(typ.args) != 2: + return UninhabitedType() + + target = evaluator.eval_proper(typ.args[0]) + name_type = evaluator.eval_proper(typ.args[1]) + + name = extract_literal_string(name_type) + if name is None: + return UninhabitedType() + + if isinstance(target, Instance): + node = target.type.names.get(name) + if node is not None and node.type is not None: + return node.type + return UninhabitedType() + + return UninhabitedType() + + +# --- Phase 3B: String Operations --- + + +@register_operator("typing.Slice") +def _eval_slice(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: + """Evaluate Slice[S, Start, End] - slice a literal string.""" + if len(typ.args) != 3: + return UninhabitedType() + + s = extract_literal_string(evaluator.eval_proper(typ.args[0])) + + # Handle start - can be int or None + start_type = evaluator.eval_proper(typ.args[1]) + if isinstance(start_type, NoneType): + start: int | None = None + else: + start = extract_literal_int(start_type) + if start is None: + return UninhabitedType() + + # Handle end - can be int or None + end_type = evaluator.eval_proper(typ.args[2]) + if isinstance(end_type, NoneType): + end: int | None = None + else: + end = extract_literal_int(end_type) + if end is None: + return UninhabitedType() + + if s is not None: + result = s[start:end] + return evaluator.literal_str(result) + + return UninhabitedType() + + +@register_operator("typing.Concat") +def _eval_concat(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: + """Evaluate Concat[S1, S2] - concatenate two literal strings.""" + if len(typ.args) != 2: + return UninhabitedType() + + left = extract_literal_string(evaluator.eval_proper(typ.args[0])) + right = extract_literal_string(evaluator.eval_proper(typ.args[1])) + + if left is not None and right is not None: + return evaluator.literal_str(left + right) + + return UninhabitedType() + + +@register_operator("typing.Uppercase") +def _eval_uppercase(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: + """Evaluate Uppercase[S] - convert literal string to uppercase.""" + if len(typ.args) != 1: + return UninhabitedType() + + s = extract_literal_string(evaluator.eval_proper(typ.args[0])) + if s is not None: + return evaluator.literal_str(s.upper()) + + return UninhabitedType() + + +@register_operator("typing.Lowercase") +def _eval_lowercase(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: + """Evaluate Lowercase[S] - convert literal string to lowercase.""" + if len(typ.args) != 1: + return UninhabitedType() + + s = extract_literal_string(evaluator.eval_proper(typ.args[0])) + if s is not None: + return evaluator.literal_str(s.lower()) + + return UninhabitedType() + + +@register_operator("typing.Capitalize") +def _eval_capitalize(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: + """Evaluate Capitalize[S] - capitalize first character of literal string.""" + if len(typ.args) != 1: + return UninhabitedType() + + s = extract_literal_string(evaluator.eval_proper(typ.args[0])) + if s is not None: + return evaluator.literal_str(s.capitalize()) + + return UninhabitedType() + + +@register_operator("typing.Uncapitalize") +def _eval_uncapitalize(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: + """Evaluate Uncapitalize[S] - lowercase first character of literal string.""" + if len(typ.args) != 1: + return UninhabitedType() + + s = extract_literal_string(evaluator.eval_proper(typ.args[0])) + if s is not None: + result = s[0].lower() + s[1:] if s else s + return evaluator.literal_str(result) + + return UninhabitedType() + + +# --- Phase 3B: Utility Operators --- + + +@register_operator("typing.Length") +def _eval_length(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: + """Evaluate Length[T] -> Literal[int] for tuple length.""" + if len(typ.args) != 1: + return UninhabitedType() + + target = evaluator.eval_proper(typ.args[0]) + + if isinstance(target, TupleType): + # Check for unbounded tuple (has ..., represented by partial_fallback) + if target.partial_fallback and not target.items: + return NoneType() # Unbounded tuple returns None + return evaluator.literal_int(len(target.items)) + + return UninhabitedType() + + +# --- Helper Functions --- + + +def get_type_args_for_base(instance: Instance, base_type: "TypeInfo") -> list[Type] | None: + """Get type args when viewing instance as base class. + + Returns None if instance is not a subtype of base_type. + """ + # Direct match + if instance.type == base_type: + return list(instance.args) + + # Walk the MRO to find the base and map type arguments + for base in instance.type.mro: + if base == base_type: + return map_type_args_to_base(instance, base_type) + + return None + + +def map_type_args_to_base(instance: Instance, base: "TypeInfo") -> list[Type]: + """Map instance's type args through inheritance chain to base.""" + from mypy.expandtype import expand_type_by_instance + + # Find the base in the direct bases and expand + # XXX: I think this is wrong + for b_proper in instance.type.bases: + if b_proper.type == base: + expanded = expand_type_by_instance(b_proper, instance) + if isinstance(expanded, Instance): + return list(expanded.args) + return [] + + # Need to walk through intermediate bases + # This is a simplified version - a full implementation would need to + # recursively expand through the entire inheritance chain + return [] + + # --- Public API --- @@ -191,7 +488,10 @@ def evaluate_type_operator(typ: TypeOperatorType) -> Type: raise AssertionError("No access to semantic analyzer!") evaluator = TypeLevelEvaluator(typelevel_ctx.api) - res = evaluator.eval_operator(typ) + try: + res = evaluator.eval_operator(typ) + except EvaluationStuck: + res = EXPANSION_ANY # print("EVALED!!", res) return res diff --git a/test-data/unit/check-typelevel-basic.test b/test-data/unit/check-typelevel-basic.test index a61feb0fb3459..246f4669ce040 100644 --- a/test-data/unit/check-typelevel-basic.test +++ b/test-data/unit/check-typelevel-basic.test @@ -190,3 +190,73 @@ reveal_type(x1) # N: Revealed type is "builtins.str" [builtins fixtures/typelevel.pyi] [typing fixtures/typing-full.pyi] + +[case testTypeOperatorFromUnion] +# flags: --python-version 3.10 +# Test FromUnion operator +from typing import FromUnion + +x: FromUnion[int | str | float] +reveal_type(x) # N: Revealed type is "tuple[builtins.int, builtins.str, builtins.float]" + +# Non-union becomes 1-tuple +y: FromUnion[int] +reveal_type(y) # N: Revealed type is "tuple[builtins.int]" + +[builtins fixtures/typelevel.pyi] +[typing fixtures/typing-full.pyi] + +[case testTypeOperatorStringConcat] +# Test string concatenation operator +from typing import Concat, Literal + +x: Concat[Literal["hello"], Literal["world"]] +reveal_type(x) # N: Revealed type is "Literal['helloworld']" + +[builtins fixtures/typelevel.pyi] +[typing fixtures/typing-full.pyi] + +[case testTypeOperatorStringUppercase] +# Test string case operators +from typing import Uppercase, Lowercase, Capitalize, Uncapitalize, Literal + +x1: Uppercase[Literal["hello"]] +reveal_type(x1) # N: Revealed type is "Literal['HELLO']" + +x2: Lowercase[Literal["HELLO"]] +reveal_type(x2) # N: Revealed type is "Literal['hello']" + +x3: Capitalize[Literal["hello"]] +reveal_type(x3) # N: Revealed type is "Literal['Hello']" + +x4: Uncapitalize[Literal["HELLO"]] +reveal_type(x4) # N: Revealed type is "Literal['hELLO']" + +[builtins fixtures/typelevel.pyi] +[typing fixtures/typing-full.pyi] + +[case testTypeOperatorStringSlice] +# Test string slice operator +from typing import Slice, Literal + +x1: Slice[Literal["hello"], Literal[1], Literal[4]] +reveal_type(x1) # N: Revealed type is "Literal['ell']" + +x2: Slice[Literal["hello"], Literal[0], Literal[2]] +reveal_type(x2) # N: Revealed type is "Literal['he']" + +[builtins fixtures/typelevel.pyi] +[typing fixtures/typing-full.pyi] + +[case testTypeOperatorLength] +# Test Length operator +from typing import Length, Literal, Tuple + +x: Length[tuple[int, str, float]] +reveal_type(x) # N: Revealed type is "Literal[3]" + +y: Length[tuple[int]] +reveal_type(y) # N: Revealed type is "Literal[1]" + +[builtins fixtures/typelevel.pyi] +[typing fixtures/typing-full.pyi] diff --git a/test-data/unit/fixtures/typing-full.pyi b/test-data/unit/fixtures/typing-full.pyi index 5b39ac069c505..ebe043d89d80a 100644 --- a/test-data/unit/fixtures/typing-full.pyi +++ b/test-data/unit/fixtures/typing-full.pyi @@ -239,3 +239,36 @@ def _type_operator(cls: type[T]) -> type[T]: ... @_type_operator class IsSub(Generic[T, U]): ... + +@_type_operator +class GetArg(Generic[T, U, V]): ... + +@_type_operator +class GetArgs(Generic[T, U]): ... + +@_type_operator +class FromUnion(Generic[T]): ... + +@_type_operator +class GetAttr(Generic[T, U]): ... + +@_type_operator +class Slice(Generic[T, U, V]): ... + +@_type_operator +class Concat(Generic[T, U]): ... + +@_type_operator +class Uppercase(Generic[T]): ... + +@_type_operator +class Lowercase(Generic[T]): ... + +@_type_operator +class Capitalize(Generic[T]): ... + +@_type_operator +class Uncapitalize(Generic[T]): ... + +@_type_operator +class Length(Generic[T]): ... From e20eb41ce3c4a6fb28c960a740a2934bcc447c26 Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Wed, 21 Jan 2026 15:40:57 -0800 Subject: [PATCH 030/161] Add lift_over_unions decorator for type operators Implements automatic lifting of type operators over union types. When any argument is a union, the operator is applied to each combination of union elements and results are combined into a union. For example: Concat[Literal['a'] | Literal['b'], Literal['c']] becomes: Literal['ac'] | Literal['bc'] Applied to: GetArg, GetArgs, GetAttr, Slice, Concat, Uppercase, Lowercase, Capitalize, Uncapitalize, Length Not applied to _Cond, IsSub (special semantics), or FromUnion (explicitly handles unions). Co-Authored-By: Claude Opus 4.5 --- mypy/typelevel.py | 73 +++++++++++++++++++++-- test-data/unit/check-typelevel-basic.test | 7 +++ 2 files changed, 75 insertions(+), 5 deletions(-) diff --git a/mypy/typelevel.py b/mypy/typelevel.py index 716514002b762..8f7e3e7bf1078 100644 --- a/mypy/typelevel.py +++ b/mypy/typelevel.py @@ -9,6 +9,7 @@ from __future__ import annotations +import itertools from collections.abc import Callable, Iterator from contextlib import contextmanager from typing import TYPE_CHECKING, Final @@ -23,10 +24,9 @@ TupleType, Type, TypeForComprehension, - TypeAliasType, TypeOfAny, - TypeVarType, TypeOperatorType, + TypeVarType, UninhabitedType, UnionType, get_proper_type, @@ -100,6 +100,55 @@ def decorator( return decorator +def lift_over_unions( + func: Callable[[TypeLevelEvaluator, TypeOperatorType], Type], +) -> Callable[[TypeLevelEvaluator, TypeOperatorType], Type]: + """Decorator that lifts an operator to work over union types. + + If any argument is a union type, the operator is applied to each + combination of union elements and the results are combined into a union. + + For example, Concat[Literal['a'] | Literal['b'], Literal['c']] + becomes Literal['ac'] | Literal['bc']. + """ + + def wrapper(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: + # Expand each argument, collecting union alternatives + expanded_args: list[list[Type]] = [] + for arg in typ.args: + proper = get_proper_type(arg) + if isinstance(proper, UnionType): + expanded_args.append(list(proper.items)) + else: + expanded_args.append([arg]) + + # Generate all combinations + combinations = list(itertools.product(*expanded_args)) + + # If there's only one combination, just call the function directly + if len(combinations) == 1: + return func(evaluator, typ) + + # Apply the operator to each combination + results: list[Type] = [] + for combo in combinations: + new_typ = typ.copy_modified(args=list(combo)) + result = func(evaluator, new_typ) + # Don't include Never in unions + # XXX: or should we get_proper_type again?? + if not (isinstance(result, ProperType) and isinstance(result, UninhabitedType)): + results.append(result) + + if not results: + return UninhabitedType() + elif len(results) == 1: + return results[0] + else: + return UnionType.make_union(results) + + return wrapper + + class EvaluationStuck(Exception): pass @@ -218,7 +267,11 @@ def extract_literal_bool(typ: Type) -> bool | None: def extract_literal_int(typ: Type) -> int | None: """Extract int value from LiteralType.""" typ = get_proper_type(typ) - if isinstance(typ, LiteralType) and isinstance(typ.value, int) and not isinstance(typ.value, bool): + if ( + isinstance(typ, LiteralType) + and isinstance(typ.value, int) + and not isinstance(typ.value, bool) + ): return typ.value return None @@ -235,6 +288,7 @@ def extract_literal_string(typ: Type) -> str | None: @register_operator("typing.GetArg") +@lift_over_unions def _eval_get_arg(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: """Evaluate GetArg[T, Base, Idx] - get type argument at index from T as Base.""" if len(typ.args) != 3: @@ -259,6 +313,7 @@ def _eval_get_arg(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: @register_operator("typing.GetArgs") +@lift_over_unions def _eval_get_args(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: """Evaluate GetArgs[T, Base] -> tuple of all type args from T as Base.""" if len(typ.args) != 2: @@ -292,6 +347,7 @@ def _eval_from_union(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Ty @register_operator("typing.GetAttr") +@lift_over_unions def _eval_get_attr(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: """Evaluate GetAttr[T, Name] - get attribute type from T.""" if len(typ.args) != 2: @@ -317,6 +373,7 @@ def _eval_get_attr(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type @register_operator("typing.Slice") +@lift_over_unions def _eval_slice(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: """Evaluate Slice[S, Start, End] - slice a literal string.""" if len(typ.args) != 3: @@ -350,6 +407,7 @@ def _eval_slice(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: @register_operator("typing.Concat") +@lift_over_unions def _eval_concat(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: """Evaluate Concat[S1, S2] - concatenate two literal strings.""" if len(typ.args) != 2: @@ -365,6 +423,7 @@ def _eval_concat(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: @register_operator("typing.Uppercase") +@lift_over_unions def _eval_uppercase(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: """Evaluate Uppercase[S] - convert literal string to uppercase.""" if len(typ.args) != 1: @@ -378,6 +437,7 @@ def _eval_uppercase(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Typ @register_operator("typing.Lowercase") +@lift_over_unions def _eval_lowercase(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: """Evaluate Lowercase[S] - convert literal string to lowercase.""" if len(typ.args) != 1: @@ -391,6 +451,7 @@ def _eval_lowercase(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Typ @register_operator("typing.Capitalize") +@lift_over_unions def _eval_capitalize(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: """Evaluate Capitalize[S] - capitalize first character of literal string.""" if len(typ.args) != 1: @@ -404,6 +465,7 @@ def _eval_capitalize(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Ty @register_operator("typing.Uncapitalize") +@lift_over_unions def _eval_uncapitalize(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: """Evaluate Uncapitalize[S] - lowercase first character of literal string.""" if len(typ.args) != 1: @@ -421,6 +483,7 @@ def _eval_uncapitalize(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> @register_operator("typing.Length") +@lift_over_unions def _eval_length(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: """Evaluate Length[T] -> Literal[int] for tuple length.""" if len(typ.args) != 1: @@ -440,7 +503,7 @@ def _eval_length(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: # --- Helper Functions --- -def get_type_args_for_base(instance: Instance, base_type: "TypeInfo") -> list[Type] | None: +def get_type_args_for_base(instance: Instance, base_type: TypeInfo) -> list[Type] | None: """Get type args when viewing instance as base class. Returns None if instance is not a subtype of base_type. @@ -457,7 +520,7 @@ def get_type_args_for_base(instance: Instance, base_type: "TypeInfo") -> list[Ty return None -def map_type_args_to_base(instance: Instance, base: "TypeInfo") -> list[Type]: +def map_type_args_to_base(instance: Instance, base: TypeInfo) -> list[Type]: """Map instance's type args through inheritance chain to base.""" from mypy.expandtype import expand_type_by_instance diff --git a/test-data/unit/check-typelevel-basic.test b/test-data/unit/check-typelevel-basic.test index 246f4669ce040..1d9ca345295f2 100644 --- a/test-data/unit/check-typelevel-basic.test +++ b/test-data/unit/check-typelevel-basic.test @@ -207,12 +207,19 @@ reveal_type(y) # N: Revealed type is "tuple[builtins.int]" [typing fixtures/typing-full.pyi] [case testTypeOperatorStringConcat] +# flags: --python-version 3.10 # Test string concatenation operator from typing import Concat, Literal x: Concat[Literal["hello"], Literal["world"]] reveal_type(x) # N: Revealed type is "Literal['helloworld']" +y: Concat[Literal['a'] | Literal['b'], Literal['c'] | Literal['d']] +reveal_type(y) # N: Revealed type is "Literal['ac'] | Literal['ad'] | Literal['bc'] | Literal['bd']" + +z: Concat[Literal['a', 'b'], Literal['c', 'd']] +reveal_type(z) # N: Revealed type is "Literal['ac'] | Literal['ad'] | Literal['bc'] | Literal['bd']" + [builtins fixtures/typelevel.pyi] [typing fixtures/typing-full.pyi] From 0851b60cdfc039be55a3a506eb79e5d2b54d7fba Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Wed, 21 Jan 2026 16:04:26 -0800 Subject: [PATCH 031/161] Make GetArg{,s} look up the whole MRO --- mypy/typelevel.py | 37 +++-------- test-data/unit/check-typelevel-basic.test | 79 +++++++++++++++++++++++ 2 files changed, 87 insertions(+), 29 deletions(-) diff --git a/mypy/typelevel.py b/mypy/typelevel.py index 8f7e3e7bf1078..4eacb2e36eea0 100644 --- a/mypy/typelevel.py +++ b/mypy/typelevel.py @@ -15,6 +15,7 @@ from typing import TYPE_CHECKING, Final from mypy.subtypes import is_subtype +from mypy.maptype import map_instance_to_supertype from mypy.types import ( AnyType, Instance, @@ -503,40 +504,18 @@ def _eval_length(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: # --- Helper Functions --- -def get_type_args_for_base(instance: Instance, base_type: TypeInfo) -> list[Type] | None: +def get_type_args_for_base(instance: Instance, base_type: TypeInfo) -> tuple[Type, ...] | None: """Get type args when viewing instance as base class. Returns None if instance is not a subtype of base_type. """ - # Direct match - if instance.type == base_type: - return list(instance.args) + # Check if base_type is in the MRO. (map_instance_to_supertype + # doesn't have a way to signal when it isn't; it just fills the + # type with Anys) + if base_type not in instance.type.mro: + return None - # Walk the MRO to find the base and map type arguments - for base in instance.type.mro: - if base == base_type: - return map_type_args_to_base(instance, base_type) - - return None - - -def map_type_args_to_base(instance: Instance, base: TypeInfo) -> list[Type]: - """Map instance's type args through inheritance chain to base.""" - from mypy.expandtype import expand_type_by_instance - - # Find the base in the direct bases and expand - # XXX: I think this is wrong - for b_proper in instance.type.bases: - if b_proper.type == base: - expanded = expand_type_by_instance(b_proper, instance) - if isinstance(expanded, Instance): - return list(expanded.args) - return [] - - # Need to walk through intermediate bases - # This is a simplified version - a full implementation would need to - # recursively expand through the entire inheritance chain - return [] + return map_instance_to_supertype(instance, base_type).args # --- Public API --- diff --git a/test-data/unit/check-typelevel-basic.test b/test-data/unit/check-typelevel-basic.test index 1d9ca345295f2..750b7d4097a77 100644 --- a/test-data/unit/check-typelevel-basic.test +++ b/test-data/unit/check-typelevel-basic.test @@ -267,3 +267,82 @@ reveal_type(y) # N: Revealed type is "Literal[1]" [builtins fixtures/typelevel.pyi] [typing fixtures/typing-full.pyi] + +[case testTypeOperatorGetArg1] +# Test GetArg operator +from typing import Generic, TypeVar, GetArg, Literal + +T = TypeVar('T') +U = TypeVar('U') + +class MyGeneric(Generic[T, U]): + pass + +# Get first type arg +x: GetArg[MyGeneric[int, str], MyGeneric, Literal[0]] +reveal_type(x) # N: Revealed type is "builtins.int" + +# Get second type arg +y: GetArg[MyGeneric[int, str], MyGeneric, Literal[1]] +reveal_type(y) # N: Revealed type is "builtins.str" + +# Works with list +z: GetArg[list[float], list, Literal[0]] +reveal_type(z) # N: Revealed type is "builtins.float" + +[builtins fixtures/typelevel.pyi] +[typing fixtures/typing-full.pyi] + +[case testTypeOperatorGetArg2] +# Test GetArg operator +from typing import Generic, TypeVar, GetArg, Literal + +T = TypeVar('T') +U = TypeVar('U') + +class MyGeneric(Generic[T, U]): + pass + +class Concrete(MyGeneric[int, str]): + pass + +# Get first type arg +x: GetArg[Concrete, MyGeneric, Literal[0]] +reveal_type(x) # N: Revealed type is "builtins.int" + +# Get second type arg +y: GetArg[Concrete, MyGeneric, Literal[1]] +reveal_type(y) # N: Revealed type is "builtins.str" + +[builtins fixtures/typelevel.pyi] +[typing fixtures/typing-full.pyi] + +[case testTypeOperatorGetArg3] +# Test GetArg operator +from typing import Generic, TypeVar, GetArg, GetArgs, Literal + +T = TypeVar('T') +U = TypeVar('U') + +class MyGeneric(Generic[T, U]): + pass + +class MyGeneric2(MyGeneric[T, str]): + pass + +class Concrete(MyGeneric2[int]): + pass + +# Get first type arg +x: GetArg[Concrete, MyGeneric, Literal[0]] +reveal_type(x) # N: Revealed type is "builtins.int" + +# Get second type arg +y: GetArg[Concrete, MyGeneric, Literal[1]] +reveal_type(y) # N: Revealed type is "builtins.str" + +args: GetArgs[Concrete, MyGeneric] +reveal_type(args) # N: Revealed type is "tuple[builtins.int, builtins.str]" + +[builtins fixtures/typelevel.pyi] +[typing fixtures/typing-full.pyi] From 11833b649611d01e1689528ef5653debfb4d4480 Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Wed, 21 Jan 2026 17:38:54 -0800 Subject: [PATCH 032/161] Implement parsing and evaluation of TypeForComprehension Add support for type comprehension syntax: *[Expr for var in Iter if Cond] Parsing (mypy/fastparse.py): - Modified TypeConverter.visit_Starred to detect list comprehensions - Added visit_ListComp_as_type to convert comprehension syntax to TypeForComprehension - Updated return types from ProperType to Type to accommodate TypeForComprehension Type Analysis (mypy/typeanal.py): - Implemented visit_type_for_comprehension that: - Analyzes the iterable type to get a concrete TupleType - Substitutes the iteration variable with each tuple element - Evaluates conditions to filter elements - Expands computed types in the result - Returns UnpackType(TupleType([...])) with collected elements Type-Level Evaluation (mypy/typelevel.py): - Updated VarSubstitutionVisitor to substitute in nested UnboundType args - Added visit_type_alias_type to handle TypeAliasType correctly Co-Authored-By: Claude Opus 4.5 --- mypy/fastparse.py | 65 +++++++++++--- mypy/typeanal.py | 65 +++++++++++++- mypy/typelevel.py | 100 +++++++++++++++++++++- test-data/unit/check-typelevel-basic.test | 37 ++++++++ test-data/unit/fixtures/typing-full.pyi | 3 + 5 files changed, 251 insertions(+), 19 deletions(-) diff --git a/mypy/fastparse.py b/mypy/fastparse.py index 51c43c947a3ca..5374df5dd7cd2 100644 --- a/mypy/fastparse.py +++ b/mypy/fastparse.py @@ -117,11 +117,13 @@ TupleType, Type, TypedDictType, + TypeForComprehension, TypeList, TypeOfAny, UnboundType, UnionType, UnpackType, + get_proper_type, ) from mypy.util import bytes_to_human_readable_repr, unnamed_function @@ -292,7 +294,7 @@ def parse_type_ignore_tag(tag: str | None) -> list[str] | None: def parse_type_comment( type_comment: str, line: int, column: int, errors: Errors | None -) -> tuple[list[str] | None, ProperType | None]: +) -> tuple[list[str] | None, Type | None]: """Parse type portion of a type comment (+ optional type ignore). Return (ignore info, parsed type). @@ -338,12 +340,14 @@ def parse_type_string( """ try: _, node = parse_type_comment(f"({expr_string})", line=line, column=column, errors=None) - if isinstance(node, (UnboundType, UnionType)) and node.original_str_expr is None: - node.original_str_expr = expr_string - node.original_str_fallback = expr_fallback_name - return node - else: - return RawExpressionType(expr_string, expr_fallback_name, line, column) + # node is Type | None but we need to check for specific ProperTypes + if node is not None: + proper = get_proper_type(node) + if isinstance(proper, (UnboundType, UnionType)) and proper.original_str_expr is None: + proper.original_str_expr = expr_string + proper.original_str_fallback = expr_fallback_name + return proper + return RawExpressionType(expr_string, expr_fallback_name, line, column) except (SyntaxError, ValueError): # Note: the parser will raise a `ValueError` instead of a SyntaxError if # the string happens to contain things like \x00. @@ -528,7 +532,7 @@ def translate_stmt_list( def translate_type_comment( self, n: ast3.stmt | ast3.arg, type_comment: str | None - ) -> ProperType | None: + ) -> Type | None: if type_comment is None: return None else: @@ -1911,12 +1915,12 @@ def invalid_type(self, node: AST, note: str | None = None) -> RawExpressionType: ) @overload - def visit(self, node: ast3.expr) -> ProperType: ... + def visit(self, node: ast3.expr) -> Type: ... @overload - def visit(self, node: AST | None) -> ProperType | None: ... + def visit(self, node: AST | None) -> Type | None: ... - def visit(self, node: AST | None) -> ProperType | None: + def visit(self, node: AST | None) -> Type | None: """Modified visit -- keep track of the stack of nodes""" if node is None: return None @@ -1926,7 +1930,7 @@ def visit(self, node: AST | None) -> ProperType | None: visitor = getattr(self, method, None) if visitor is not None: typ = visitor(node) - assert isinstance(typ, ProperType) + assert isinstance(typ, Type) return typ else: return self.invalid_type(node) @@ -2132,11 +2136,14 @@ def visit_Dict(self, n: ast3.Dict) -> Type: if not n.keys: return self.invalid_type(n) items: dict[str, Type] = {} - extra_items_from = [] + extra_items_from: list[ProperType] = [] for item_name, value in zip(n.keys, n.values): if not isinstance(item_name, ast3.Constant) or not isinstance(item_name.value, str): if item_name is None: - extra_items_from.append(self.visit(value)) + visited = self.visit(value) + # TypedDict spread values should be ProperTypes + assert isinstance(visited, ProperType) + extra_items_from.append(visited) continue return self.invalid_type(n) items[item_name.value] = self.visit(value) @@ -2154,9 +2161,39 @@ def visit_Attribute(self, n: Attribute) -> Type: return self.invalid_type(n) # Used for Callable[[X *Ys, Z], R] etc. + # Also handles type comprehensions: *[Expr for var in Iter if Cond] def visit_Starred(self, n: ast3.Starred) -> Type: + # Check if this is a list comprehension (type comprehension syntax) + if isinstance(n.value, ast3.ListComp): + return self.visit_ListComp_as_type(n.value) return UnpackType(self.visit(n.value), from_star_syntax=True) + def visit_ListComp_as_type(self, n: ast3.ListComp) -> Type: + """Convert *[Expr for var in Iter if Cond] to TypeForComprehension.""" + # Currently only support single generator + if len(n.generators) != 1: + return self.invalid_type(n, note="Type comprehensions only support a single 'for' clause") + + gen = n.generators[0] + + # The target should be a simple name + if not isinstance(gen.target, ast3.Name): + return self.invalid_type(n, note="Type comprehension variable must be a simple name") + + iter_var = gen.target.id + element_expr = self.visit(n.elt) + iter_type = self.visit(gen.iter) + conditions = [self.visit(cond) for cond in gen.ifs] + + return TypeForComprehension( + element_expr=element_expr, + iter_var=iter_var, + iter_type=iter_type, + conditions=conditions, + line=self.line, + column=self.convert_column(n.col_offset), + ) + # List(expr* elts, expr_context ctx) def visit_List(self, n: ast3.List) -> Type: assert isinstance(n.ctx, ast3.Load) diff --git a/mypy/typeanal.py b/mypy/typeanal.py index 360e761438299..44512c0aed0cc 100644 --- a/mypy/typeanal.py +++ b/mypy/typeanal.py @@ -1137,8 +1137,69 @@ def visit_type_operator_type(self, t: TypeOperatorType) -> Type: return t def visit_type_for_comprehension(self, t: TypeForComprehension) -> Type: - # Type comprehensions are analyzed elsewhere - return t + """Analyze and evaluate a type comprehension. + + Type comprehensions are expanded during type analysis since we have + access to the semantic analyzer API at this point. The result is an + UnpackType wrapping a TupleType of all produced elements. + + The iteration variable (iter_var) is a local binding within the + comprehension, so we use special handling to substitute it with each + element from the iterable. + """ + from mypy.typelevel import ( + VarSubstitutionVisitor, + extract_literal_bool, + EvaluationStuck, + typelevel_ctx, + TypeLevelEvaluator, + ) + + # Analyze the iter_type first - this is outside the comprehension scope + analyzed_iter = self.anal_type(t.iter_type, allow_unpack=True) + iter_proper = get_proper_type(analyzed_iter) + + if not isinstance(iter_proper, TupleType): + # Can't evaluate - return Any (wrapped in Unpack) + return UnpackType( + TupleType([AnyType(TypeOfAny.from_error)], self.named_type("builtins.tuple")) + ) + + # Process each item in the tuple + result_items: list[Type] = [] + for item in iter_proper.items: + # Substitute iter_var with item in element_expr and conditions + substitutor = VarSubstitutionVisitor(t.iter_var, item) + substituted_expr = t.element_expr.accept(substitutor) + substituted_conditions = [cond.accept(substitutor) for cond in t.conditions] + + # Analyze the substituted expression and expand any computed types + analyzed_expr = self.anal_type(substituted_expr, allow_unpack=True) + analyzed_expr = get_proper_type(analyzed_expr) + + # Evaluate all conditions + try: + all_pass = True + for cond in substituted_conditions: + analyzed_cond = self.anal_type(cond, allow_unpack=True) + # Use typelevel_ctx.api if available for condition evaluation + if typelevel_ctx.api is not None: + evaluator = TypeLevelEvaluator(typelevel_ctx.api) + cond_result = extract_literal_bool(evaluator.evaluate(analyzed_cond)) + if cond_result is False: + all_pass = False + break + # If cond_result is None (undecidable), include the item + + if all_pass: + result_items.append(analyzed_expr) + except EvaluationStuck: + # Include item if evaluation gets stuck + result_items.append(analyzed_expr) + + # Return UnpackType wrapping a TupleType + tuple_fallback = self.named_type("builtins.tuple") + return UnpackType(TupleType(result_items, tuple_fallback)) def visit_type_var(self, t: TypeVarType) -> Type: return t diff --git a/mypy/typelevel.py b/mypy/typelevel.py index 4eacb2e36eea0..50b6645d0a414 100644 --- a/mypy/typelevel.py +++ b/mypy/typelevel.py @@ -22,14 +22,18 @@ LiteralType, NoneType, ProperType, + TrivialSyntheticTypeTranslator, TupleType, Type, + TypeAliasType, TypeForComprehension, TypeOfAny, TypeOperatorType, TypeVarType, + UnboundType, UninhabitedType, UnionType, + UnpackType, get_proper_type, has_type_vars, is_stuck_expansion, @@ -236,6 +240,22 @@ def _eval_cond(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: return EXPANSION_ANY +@register_operator("typing.Iter") +def _eval_iter(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: + """Evaluate a type-level iterator (Iter[T]).""" + if len(typ.args) != 1: + return UninhabitedType() # ??? + + target = evaluator.eval_proper(typ.args[0]) + if isinstance(target, TupleType): + # Check for unbounded tuple (has ..., represented by partial_fallback) + if target.partial_fallback and not target.items: + return UninhabitedType() + return target + else: + return UninhabitedType() + + @register_operator("typing.IsSub") def _eval_issub(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: """Evaluate a type-level condition (IsSub[T, Base]).""" @@ -541,7 +561,81 @@ def evaluate_type_operator(typ: TypeOperatorType) -> Type: def evaluate_comprehension(typ: TypeForComprehension) -> Type: """Evaluate a TypeForComprehension. Called from TypeForComprehension.expand(). - Returns Any for now - full implementation in Phase 3B. + Evaluates *[Expr for var in Iter if Cond] to UnpackType(TupleType([...])). """ - # Stub implementation - return Any to avoid infinite loops in get_proper_type - return EXPANSION_ANY + if typelevel_ctx.api is None: + # API not available yet - return stuck expansion marker + return EXPANSION_ANY + + evaluator = TypeLevelEvaluator(typelevel_ctx.api) + + try: + # Get the iterable type and expand it to a TupleType + iter_proper = evaluator.eval_proper(typ.iter_type) + except EvaluationStuck: + return EXPANSION_ANY + + if not isinstance(iter_proper, TupleType): + # Can only iterate over tuple types + return EXPANSION_ANY + + # Process each item in the tuple + result_items: list[Type] = [] + for item in iter_proper.items: + # Substitute iter_var with item in element_expr and conditions + substitutor = VarSubstitutionVisitor(typ.iter_var, item) + substituted_expr = typ.element_expr.accept(substitutor) + substituted_conditions = [cond.accept(substitutor) for cond in typ.conditions] + + # Evaluate all conditions + try: + all_pass = True + for cond in substituted_conditions: + cond_result = extract_literal_bool(evaluator.evaluate(cond)) + if cond_result is False: + all_pass = False + break + elif cond_result is None: + # Undecidable condition - skip this item + all_pass = False + break + + if all_pass: + # Include this element in the result + result_items.append(substituted_expr) + except EvaluationStuck: + # Skip items that cause stuck evaluation + continue + + return UnpackType(evaluator.tuple_type(result_items)) + + +class VarSubstitutionVisitor(TrivialSyntheticTypeTranslator): + """Type visitor that substitutes UnboundType references to a variable name.""" + + def __init__(self, var_name: str, replacement: Type) -> None: + super().__init__() + self.var_name = var_name + self.replacement = replacement + + def visit_unbound_type(self, t: UnboundType) -> Type: + if t.name == self.var_name and not t.args: + return self.replacement + # Also visit the args to substitute nested occurrences + if t.args: + new_args = [arg.accept(self) for arg in t.args] + return UnboundType( + t.name, + new_args, + t.line, + t.column, + t.optional, + t.empty_tuple_index, + t.original_str_expr, + t.original_str_fallback, + ) + return t + + def visit_type_alias_type(self, t: TypeAliasType) -> Type: + # Visit the args to substitute nested occurrences + return t.copy_modified(args=[arg.accept(self) for arg in t.args]) diff --git a/test-data/unit/check-typelevel-basic.test b/test-data/unit/check-typelevel-basic.test index 750b7d4097a77..959205ed81e95 100644 --- a/test-data/unit/check-typelevel-basic.test +++ b/test-data/unit/check-typelevel-basic.test @@ -346,3 +346,40 @@ reveal_type(args) # N: Revealed type is "tuple[builtins.int, builtins.str]" [builtins fixtures/typelevel.pyi] [typing fixtures/typing-full.pyi] + +[case testTypeComprehensionBasic] +# Test basic type comprehension +from typing import IsSub, Iter + +# Basic comprehension - maps over tuple elements +def f(x: tuple[*[T for T in Iter[tuple[int, str, float]]]]) -> None: # E: Name "T" is not defined + pass + +reveal_type(f) # N: Revealed type is "def (x: tuple[builtins.int, builtins.str, builtins.float])" + +[builtins fixtures/typelevel.pyi] +[typing fixtures/typing-full.pyi] + +[case testTypeComprehensionWithCondition] +# Test type comprehension with filtering condition +from typing import IsSub, Iter + +# Filter elements - only keep subtypes of int (int, bool) +def f(x: tuple[*[T for T in Iter[tuple[int, str, bool, float]] if IsSub[T, int]]]) -> None: # E: Name "T" is not defined + pass + +reveal_type(f) # N: Revealed type is "def (x: tuple[builtins.int, builtins.bool])" + +[builtins fixtures/typelevel.pyi] +[typing fixtures/typing-full.pyi] + +[case testTypeComprehensionTransform] +# Test type comprehension with element transformation +from typing import Uppercase, Literal, Iter + +# Transform elements +x: tuple[*[Uppercase[T] for T in Iter[tuple[Literal["a"], Literal["b"], Literal["c"]]]]] +reveal_type(x) # N: Revealed type is "tuple[Literal['A'], Literal['B'], Literal['C']]" + +[builtins fixtures/typelevel.pyi] +[typing fixtures/typing-full.pyi] diff --git a/test-data/unit/fixtures/typing-full.pyi b/test-data/unit/fixtures/typing-full.pyi index ebe043d89d80a..8bc8cb546f0cc 100644 --- a/test-data/unit/fixtures/typing-full.pyi +++ b/test-data/unit/fixtures/typing-full.pyi @@ -237,6 +237,9 @@ class TypeAliasType: def _type_operator(cls: type[T]) -> type[T]: ... +@_type_operator +class Iter(Generic[T]): ... + @_type_operator class IsSub(Generic[T, U]): ... From 3bedcfe995138e9958dd597ff96516e49beedc4e Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Wed, 21 Jan 2026 21:52:54 -0800 Subject: [PATCH 033/161] Rework the TypeForComprehension stuff so it works --- mypy/checker.py | 2 + mypy/checkexpr.py | 7 -- mypy/expandtype.py | 25 +++++-- mypy/fastparse.py | 8 +- mypy/messages.py | 2 +- mypy/semanal.py | 1 + mypy/semanal_typeargs.py | 2 + mypy/server/astdiff.py | 2 +- mypy/typeanal.py | 91 ++++++++--------------- mypy/typelevel.py | 46 ++---------- mypy/types.py | 79 +++++++++++++++----- test-data/unit/check-typelevel-basic.test | 4 +- 12 files changed, 135 insertions(+), 134 deletions(-) diff --git a/mypy/checker.py b/mypy/checker.py index fa531daba798f..a1ec2c017b376 100644 --- a/mypy/checker.py +++ b/mypy/checker.py @@ -7460,6 +7460,8 @@ def check_subtype( if is_subtype(subtype, supertype, options=self.options): return True + is_subtype(subtype, supertype, options=self.options) + if isinstance(msg, str): msg = ErrorMessage(msg, code=code) diff --git a/mypy/checkexpr.py b/mypy/checkexpr.py index 4274d3095d42c..d136360cc3347 100644 --- a/mypy/checkexpr.py +++ b/mypy/checkexpr.py @@ -168,7 +168,6 @@ TUPLE_LIKE_INSTANCE_NAMES, AnyType, CallableType, - ComputedType, DeletedType, ErasedType, ExtraAttrs, @@ -206,7 +205,6 @@ has_type_vars, is_named_instance, split_with_prefix_and_suffix, - try_expand, ) from mypy.types_utils import ( is_generic_instance, @@ -4800,11 +4798,6 @@ def visit_reveal_expr(self, expr: RevealExpr) -> Type: expr.expr, type_context=self.type_context[-1], allow_none_return=True ) - # XXX: We do this to expand ComputedTypes -- but should we *need* to? - # and do we want to? - if isinstance(revealed_type, ComputedType): - revealed_type = try_expand(revealed_type) - if not self.chk.current_node_deferred: self.msg.reveal_type(revealed_type, expr.expr) if not self.chk.in_checked_function(): diff --git a/mypy/expandtype.py b/mypy/expandtype.py index 5790b717172ac..40fd7ac5d8fb2 100644 --- a/mypy/expandtype.py +++ b/mypy/expandtype.py @@ -50,6 +50,9 @@ # is_subtype(), meet_types(), join_types() etc. # TODO: add a static dependency test for this. +# XXX: The changes to get_proper_type to do type-level computation +# breaks this invariant! I think we need another layer of indirection! + @overload def expand_type(typ: CallableType, env: Mapping[TypeVarId, Type]) -> CallableType: ... @@ -387,8 +390,14 @@ def visit_unpack_type(self, t: UnpackType) -> Type: return UnpackType(t.type.accept(self)) def expand_unpack(self, t: UnpackType) -> list[Type]: - assert isinstance(t.type, TypeVarTupleType) - repl = get_proper_type(self.variables.get(t.type.id, t.type)) + if isinstance(t.type, TypeVarTupleType): + t2 = self.variables.get(t.type.id, t.type) + fallback = t.type.tuple_fallback + else: + assert isinstance(t.type, ProperType) and isinstance(t.type, TupleType) + t2 = t.type + fallback = t2.partial_fallback + repl = get_proper_type(t2) if isinstance(repl, UnpackType): repl = get_proper_type(repl.type) if isinstance(repl, TupleType): @@ -402,7 +411,7 @@ def expand_unpack(self, t: UnpackType) -> list[Type]: elif isinstance(repl, (AnyType, UninhabitedType)): # Replace *Ts = Any with *Ts = *tuple[Any, ...] and same for Never. # These types may appear here as a result of user error or failed inference. - return [UnpackType(t.type.tuple_fallback.copy_modified(args=[repl]))] + return [UnpackType(fallback.copy_modified(args=[repl]))] else: raise RuntimeError(f"Invalid type replacement to expand: {repl}") @@ -516,7 +525,10 @@ def expand_type_list_with_unpack(self, typs: list[Type]) -> list[Type]: """Expands a list of types that has an unpack.""" items: list[Type] = [] for item in typs: - if isinstance(item, UnpackType) and isinstance(item.type, TypeVarTupleType): + if isinstance(item, UnpackType) and ( + isinstance(item.type, TypeVarTupleType) + or (isinstance(item.type, ProperType) and isinstance(item.type, TupleType)) + ): items.extend(self.expand_unpack(item)) else: items.append(item.accept(self)) @@ -527,7 +539,10 @@ def expand_type_tuple_with_unpack(self, typs: tuple[Type, ...]) -> list[Type]: # Micro-optimization: Specialized variant of expand_type_list_with_unpack items: list[Type] = [] for item in typs: - if isinstance(item, UnpackType) and isinstance(item.type, TypeVarTupleType): + if isinstance(item, UnpackType) and ( + isinstance(item.type, TypeVarTupleType) + or (isinstance(item.type, ProperType) and isinstance(item.type, TupleType)) + ): items.extend(self.expand_unpack(item)) else: items.append(item.accept(self)) diff --git a/mypy/fastparse.py b/mypy/fastparse.py index 5374df5dd7cd2..30737eeaea475 100644 --- a/mypy/fastparse.py +++ b/mypy/fastparse.py @@ -2172,7 +2172,9 @@ def visit_ListComp_as_type(self, n: ast3.ListComp) -> Type: """Convert *[Expr for var in Iter if Cond] to TypeForComprehension.""" # Currently only support single generator if len(n.generators) != 1: - return self.invalid_type(n, note="Type comprehensions only support a single 'for' clause") + return self.invalid_type( + n, note="Type comprehensions only support a single 'for' clause" + ) gen = n.generators[0] @@ -2180,14 +2182,14 @@ def visit_ListComp_as_type(self, n: ast3.ListComp) -> Type: if not isinstance(gen.target, ast3.Name): return self.invalid_type(n, note="Type comprehension variable must be a simple name") - iter_var = gen.target.id + iter_name = gen.target.id element_expr = self.visit(n.elt) iter_type = self.visit(gen.iter) conditions = [self.visit(cond) for cond in gen.ifs] return TypeForComprehension( element_expr=element_expr, - iter_var=iter_var, + iter_name=iter_name, iter_type=iter_type, conditions=conditions, line=self.line, diff --git a/mypy/messages.py b/mypy/messages.py index 4c2f5a715e350..869616c3adb2f 100644 --- a/mypy/messages.py +++ b/mypy/messages.py @@ -1749,7 +1749,7 @@ def reveal_type(self, typ: Type, context: Context) -> None: return # Nothing special here; just create the note: - visitor = TypeStrVisitor(options=self.options) + visitor = TypeStrVisitor(expand=True, options=self.options) self.note(f'Revealed type is "{typ.accept(visitor)}"', context) def reveal_locals(self, type_map: dict[str, Type | None], context: Context) -> None: diff --git a/mypy/semanal.py b/mypy/semanal.py index 8242ee3160e8e..3ae981be94f71 100644 --- a/mypy/semanal.py +++ b/mypy/semanal.py @@ -7568,6 +7568,7 @@ def name_not_defined(self, name: str, ctx: Context, namespace: str | None = None # later on. Defer current target. self.record_incomplete_ref() return + message = f'Name "{name}" is not defined' self.fail(message, ctx, code=codes.NAME_DEFINED) diff --git a/mypy/semanal_typeargs.py b/mypy/semanal_typeargs.py index 6e13c60244849..46a30e5d7442f 100644 --- a/mypy/semanal_typeargs.py +++ b/mypy/semanal_typeargs.py @@ -237,6 +237,8 @@ def validate_args( ) elif isinstance(tvar, TypeVarTupleType): p_arg = get_proper_type(arg) + # XXX: get_proper_type could possibly mess things up + # now that it might evaluate assert isinstance(p_arg, TupleType) for it in p_arg.items: if self.check_non_paramspec(it, "TypeVarTuple", context): diff --git a/mypy/server/astdiff.py b/mypy/server/astdiff.py index b9d3473fdf726..7fc650ead3060 100644 --- a/mypy/server/astdiff.py +++ b/mypy/server/astdiff.py @@ -534,7 +534,7 @@ def visit_type_for_comprehension(self, typ: TypeForComprehension) -> SnapshotIte return ( "TypeForComprehension", snapshot_type(typ.element_expr), - typ.iter_var, + typ.iter_name, snapshot_type(typ.iter_type), snapshot_types(typ.conditions), ) diff --git a/mypy/typeanal.py b/mypy/typeanal.py index 44512c0aed0cc..a5ad6f8dcb3ef 100644 --- a/mypy/typeanal.py +++ b/mypy/typeanal.py @@ -1133,73 +1133,34 @@ def analyze_type_operator(self, t: UnboundType, type_info: TypeInfo) -> Type: return TypeOperatorType(type_info, an_args, t.line, t.column) def visit_type_operator_type(self, t: TypeOperatorType) -> Type: - # Type operators are analyzed elsewhere - return t + return t.copy_modified(args=self.anal_array(t.args, allow_unpack=True)) def visit_type_for_comprehension(self, t: TypeForComprehension) -> Type: - """Analyze and evaluate a type comprehension. + from mypy.semanal import SemanticAnalyzer - Type comprehensions are expanded during type analysis since we have - access to the semantic analyzer API at this point. The result is an - UnpackType wrapping a TupleType of all produced elements. + sem = self.api + assert isinstance(sem, SemanticAnalyzer) - The iteration variable (iter_var) is a local binding within the - comprehension, so we use special handling to substitute it with each - element from the iterable. - """ - from mypy.typelevel import ( - VarSubstitutionVisitor, - extract_literal_bool, - EvaluationStuck, - typelevel_ctx, - TypeLevelEvaluator, - ) + iter_type = self.anal_type(t.iter_type) - # Analyze the iter_type first - this is outside the comprehension scope - analyzed_iter = self.anal_type(t.iter_type, allow_unpack=True) - iter_proper = get_proper_type(analyzed_iter) + with self.tvar_scope_frame(""): + targs = [t.type_param()] + var_exprs = sem.push_type_args(targs, t) + assert var_exprs - if not isinstance(iter_proper, TupleType): - # Can't evaluate - return Any (wrapped in Unpack) - return UnpackType( - TupleType([AnyType(TypeOfAny.from_error)], self.named_type("builtins.tuple")) - ) + iter_var = self.tvar_scope.bind_new(t.iter_name, var_exprs[0][1], self.fail_func, t) + assert isinstance(iter_var, TypeVarType), type(iter_var) - # Process each item in the tuple - result_items: list[Type] = [] - for item in iter_proper.items: - # Substitute iter_var with item in element_expr and conditions - substitutor = VarSubstitutionVisitor(t.iter_var, item) - substituted_expr = t.element_expr.accept(substitutor) - substituted_conditions = [cond.accept(substitutor) for cond in t.conditions] + analt = t.copy_modified( + element_expr=self.anal_type(t.element_expr), + iter_type=iter_type, + conditions=self.anal_array(t.conditions), + iter_var=iter_var, + ) - # Analyze the substituted expression and expand any computed types - analyzed_expr = self.anal_type(substituted_expr, allow_unpack=True) - analyzed_expr = get_proper_type(analyzed_expr) + sem.pop_type_args(targs) - # Evaluate all conditions - try: - all_pass = True - for cond in substituted_conditions: - analyzed_cond = self.anal_type(cond, allow_unpack=True) - # Use typelevel_ctx.api if available for condition evaluation - if typelevel_ctx.api is not None: - evaluator = TypeLevelEvaluator(typelevel_ctx.api) - cond_result = extract_literal_bool(evaluator.evaluate(analyzed_cond)) - if cond_result is False: - all_pass = False - break - # If cond_result is None (undecidable), include the item - - if all_pass: - result_items.append(analyzed_expr) - except EvaluationStuck: - # Include item if evaluation gets stuck - result_items.append(analyzed_expr) - - # Return UnpackType wrapping a TupleType - tuple_fallback = self.named_type("builtins.tuple") - return UnpackType(TupleType(result_items, tuple_fallback)) + return analt def visit_type_var(self, t: TypeVarType) -> Type: return t @@ -2706,6 +2667,7 @@ def __init__(self, api: SemanticAnalyzerCoreInterface, scope: TypeVarLikeScope) self.type_var_likes: list[tuple[str, TypeVarLikeExpr]] = [] self.has_self_type = False self.include_callables = True + self.internal_vars: set[str] = set() def _seems_like_callable(self, type: UnboundType) -> bool: if not type.args: @@ -2714,6 +2676,10 @@ def _seems_like_callable(self, type: UnboundType) -> bool: def visit_unbound_type(self, t: UnboundType) -> None: name = t.name + # We don't want to collect the iterator variables, and we + # really don't want to bother to put them in the symbol table. + if name in self.internal_vars: + return node = self.api.lookup_qualified(name, t) if node and node.fullname in SELF_TYPE_NAMES: self.has_self_type = True @@ -2820,10 +2786,17 @@ def visit_type_operator_type(self, t: TypeOperatorType) -> None: self.process_types(t.args) def visit_type_for_comprehension(self, t: TypeForComprehension) -> None: - t.element_expr.accept(self) t.iter_type.accept(self) + + shadowed = t.iter_name in self.internal_vars + self.internal_vars.add(t.iter_name) + + t.element_expr.accept(self) self.process_types(t.conditions) + if not shadowed: + self.internal_vars.discard(t.iter_name) + def process_types(self, types: list[Type] | tuple[Type, ...]) -> None: # Redundant type check helps mypyc. if isinstance(types, list): diff --git a/mypy/typelevel.py b/mypy/typelevel.py index 50b6645d0a414..ac15982335ced 100644 --- a/mypy/typelevel.py +++ b/mypy/typelevel.py @@ -14,23 +14,21 @@ from contextlib import contextmanager from typing import TYPE_CHECKING, Final -from mypy.subtypes import is_subtype +from mypy.expandtype import expand_type from mypy.maptype import map_instance_to_supertype +from mypy.subtypes import is_subtype from mypy.types import ( AnyType, Instance, LiteralType, NoneType, ProperType, - TrivialSyntheticTypeTranslator, TupleType, Type, - TypeAliasType, TypeForComprehension, TypeOfAny, TypeOperatorType, TypeVarType, - UnboundType, UninhabitedType, UnionType, UnpackType, @@ -577,15 +575,16 @@ def evaluate_comprehension(typ: TypeForComprehension) -> Type: if not isinstance(iter_proper, TupleType): # Can only iterate over tuple types - return EXPANSION_ANY + return UninhabitedType() # Process each item in the tuple result_items: list[Type] = [] + assert typ.iter_var for item in iter_proper.items: # Substitute iter_var with item in element_expr and conditions - substitutor = VarSubstitutionVisitor(typ.iter_var, item) - substituted_expr = typ.element_expr.accept(substitutor) - substituted_conditions = [cond.accept(substitutor) for cond in typ.conditions] + env = {typ.iter_var.id: item} + substituted_expr = expand_type(typ.element_expr, env) + substituted_conditions = [expand_type(cond, env) for cond in typ.conditions] # Evaluate all conditions try: @@ -608,34 +607,3 @@ def evaluate_comprehension(typ: TypeForComprehension) -> Type: continue return UnpackType(evaluator.tuple_type(result_items)) - - -class VarSubstitutionVisitor(TrivialSyntheticTypeTranslator): - """Type visitor that substitutes UnboundType references to a variable name.""" - - def __init__(self, var_name: str, replacement: Type) -> None: - super().__init__() - self.var_name = var_name - self.replacement = replacement - - def visit_unbound_type(self, t: UnboundType) -> Type: - if t.name == self.var_name and not t.args: - return self.replacement - # Also visit the args to substitute nested occurrences - if t.args: - new_args = [arg.accept(self) for arg in t.args] - return UnboundType( - t.name, - new_args, - t.line, - t.column, - t.optional, - t.empty_tuple_index, - t.original_str_expr, - t.original_str_fallback, - ) - return t - - def visit_type_alias_type(self, t: TypeAliasType) -> Type: - # Visit the args to substitute nested occurrences - return t.copy_modified(args=[arg.accept(self) for arg in t.args]) diff --git a/mypy/types.py b/mypy/types.py index ad8a6b9194fb5..269fdd35818a0 100644 --- a/mypy/types.py +++ b/mypy/types.py @@ -511,7 +511,7 @@ def __init__( super().__init__(line, column) self.type = type self.args = args - self.type_ref: str | None = None + self.type_ref: str | None = None # XXX? def accept(self, visitor: TypeVisitor[T]) -> T: return visitor.visit_type_operator_type(self) @@ -585,22 +585,27 @@ class TypeForComprehension(ComputedType): Expands to a tuple of types. """ - __slots__ = ("element_expr", "iter_var", "iter_type", "conditions") + __slots__ = ("element_expr", "iter_name", "iter_type", "conditions", "iter_var") def __init__( self, element_expr: Type, - iter_var: str, + iter_name: str, iter_type: Type, # The type being iterated (should be a tuple type) conditions: list[Type], # Each should be IsSub[...] or boolean combo + iter_var: TypeVarType | None = None, # Typically populated by typeanal line: int = -1, column: int = -1, ) -> None: super().__init__(line, column) self.element_expr = element_expr - self.iter_var = iter_var + self.iter_name = iter_name self.iter_type = iter_type self.conditions = conditions + self.iter_var: TypeVarType | None = iter_var + + def type_param(self) -> mypy.nodes.TypeParam: + return mypy.nodes.TypeParam(self.iter_name, mypy.nodes.TYPE_VAR_KIND, None, [], None) def accept(self, visitor: TypeVisitor[T]) -> T: return visitor.visit_type_for_comprehension(self) @@ -612,54 +617,59 @@ def expand(self) -> Type: return evaluate_comprehension(self) def __hash__(self) -> int: - return hash((self.element_expr, self.iter_var, self.iter_type, tuple(self.conditions))) + return hash((self.element_expr, self.iter_name, self.iter_type, tuple(self.conditions))) def __eq__(self, other: object) -> bool: if not isinstance(other, TypeForComprehension): return NotImplemented return ( self.element_expr == other.element_expr - and self.iter_var == other.iter_var + and self.iter_name == other.iter_name and self.iter_type == other.iter_type and self.conditions == other.conditions ) def __repr__(self) -> str: conds = "".join(f" if {c}" for c in self.conditions) - return f"TypeForComprehension([{self.element_expr} for {self.iter_var} in {self.iter_type}{conds}])" + return f"TypeForComprehension([{self.element_expr} for {self.iter_name} in {self.iter_type}{conds}])" def serialize(self) -> JsonDict: return { ".class": "TypeForComprehension", "element_expr": self.element_expr.serialize(), - "iter_var": self.iter_var, + "iter_name": self.iter_name, "iter_type": self.iter_type.serialize(), "conditions": [c.serialize() for c in self.conditions], + "iter_var": self.iter_var.serialize() if self.iter_var else None, } @classmethod def deserialize(cls, data: JsonDict) -> TypeForComprehension: assert data[".class"] == "TypeForComprehension" + var = data["iter_var"] return TypeForComprehension( deserialize_type(data["element_expr"]), - data["iter_var"], + data["iter_name"], deserialize_type(data["iter_type"]), [deserialize_type(c) for c in data["conditions"]], + iter_var=cast(TypeVarType, deserialize_type(var)) if var else None, ) def copy_modified( self, *, element_expr: Type | None = None, - iter_var: str | None = None, + iter_name: str | None = None, iter_type: Type | None = None, conditions: list[Type] | None = None, + iter_var: TypeVarType | None = None, # Typically populated by typeanal ) -> TypeForComprehension: return TypeForComprehension( element_expr if element_expr is not None else self.element_expr, - iter_var if iter_var is not None else self.iter_var, + iter_name if iter_name is not None else self.iter_name, iter_type if iter_type is not None else self.iter_type, conditions if conditions is not None else self.conditions.copy(), + iter_var if iter_var is not None else self.iter_var, self.line, self.column, ) @@ -667,22 +677,25 @@ def copy_modified( def write(self, data: WriteBuffer) -> None: write_tag(data, TYPE_FOR_COMPREHENSION) self.element_expr.write(data) - write_str(data, self.iter_var) + write_str(data, self.iter_name) self.iter_type.write(data) write_int(data, len(self.conditions)) for cond in self.conditions: cond.write(data) + write_type_opt(data, self.iter_var) write_tag(data, END_TAG) @classmethod def read(cls, data: ReadBuffer) -> TypeForComprehension: element_expr = read_type(data) - iter_var = read_str(data) + iter_name = read_str(data) iter_type = read_type(data) num_conditions = read_int(data) conditions = [read_type(data) for _ in range(num_conditions)] + iter_var = cast(TypeVarType | None, read_type_opt(data)) + assert read_tag(data) == END_TAG - return TypeForComprehension(element_expr, iter_var, iter_type, conditions) + return TypeForComprehension(element_expr, iter_name, iter_type, conditions, iter_var) class TypeGuardedType(Type): @@ -3877,17 +3890,35 @@ def get_proper_type(typ: Type | None) -> ProperType | None: on the isinstance() call you pass on the original type (and not one of its components) it is recommended to *always* pass on the unexpanded alias. """ + from mypy.expandtype import expand_type + if typ is None: return None # TODO: this is an ugly hack, remove. if isinstance(typ, TypeGuardedType): typ = typ.type_guard + + trouble = False while True: if isinstance(typ, TypeAliasType): typ = typ._expand_once() elif isinstance(typ, ComputedType): # Handles TypeOperatorType, TypeForComprehension typ = typ.expand() + elif ( + isinstance(typ, ProperType) + and isinstance(typ, TupleType) + and any(isinstance(st, ComputedType) for st in typ.items) + and not trouble + ): + # XXX: need to get rid of full get_proper_type calls in expandtype + # and cover Instance, Callable + # also I'm really not sure about this at all! + # this is a lot of work to be doing in get_proper_type + typ2 = typ.copy_modified(items=[try_expand(st) for st in typ.items]) + typ = expand_type(typ2, {}) + trouble = True + else: break # TODO: store the name of original type alias on this type, so we can show it in errors. @@ -3972,10 +4003,13 @@ class TypeStrVisitor(SyntheticTypeVisitor[str]): - Represent Union[x, y] as x | y """ - def __init__(self, id_mapper: IdMapper | None = None, *, options: Options) -> None: + def __init__( + self, id_mapper: IdMapper | None = None, expand: bool = False, *, options: Options + ) -> None: self.id_mapper = id_mapper self.options = options self.dotted_aliases: set[TypeAliasType] | None = None + self.expand = expand def visit_unbound_type(self, t: UnboundType, /) -> str: s = t.name + "?" @@ -4171,7 +4205,7 @@ def visit_callable_type(self, t: CallableType, /) -> str: ) else: vs.append( - f"{var.name}{f' = {var.default.accept(self)}' if var.has_default() else ''}" + f"{var.name}{f' = {var.default.accept(self)}' if var.has_default() else ''}" ) else: # For other TypeVarLikeTypes, use the name and default @@ -4189,6 +4223,11 @@ def visit_overloaded(self, t: Overloaded, /) -> str: return f"Overload({', '.join(a)})" def visit_tuple_type(self, t: TupleType, /) -> str: + # Expand computed comprehensions + if self.expand: + if (nt := try_expand_or_none(t)) and nt != t: + return nt.accept(self) + s = self.list_str(t.items) or "()" if t.partial_fallback and t.partial_fallback.type: fallback_name = t.partial_fallback.type.fullname @@ -4272,6 +4311,9 @@ def visit_unpack_type(self, t: UnpackType, /) -> str: return f"Unpack[{t.type.accept(self)}]" def visit_type_operator_type(self, t: TypeOperatorType, /) -> str: + if self.expand and (t2 := try_expand_or_none(t)): + return t2.accept(self) + name = t.type.fullname if t.type else "" return f"{name}[{self.list_str(t.args)}]" @@ -4279,7 +4321,10 @@ def visit_type_for_comprehension(self, t: TypeForComprehension, /) -> str: conditions = "" if t.conditions: conditions = " if " + " if ".join(c.accept(self) for c in t.conditions) - return f"*[{t.element_expr.accept(self)} for {t.iter_var} in {t.iter_type.accept(self)}{conditions}]" + v = t.iter_var.accept(self) if t.iter_var else f"~{t.iter_name}" + return ( + f"*[{t.element_expr.accept(self)} for {v} in {t.iter_type.accept(self)}{conditions}]" + ) def list_str(self, a: Iterable[Type], *, use_or_syntax: bool = False) -> str: """Convert items of an array to strings (pretty-print types) diff --git a/test-data/unit/check-typelevel-basic.test b/test-data/unit/check-typelevel-basic.test index 959205ed81e95..75451a74fe025 100644 --- a/test-data/unit/check-typelevel-basic.test +++ b/test-data/unit/check-typelevel-basic.test @@ -352,7 +352,7 @@ reveal_type(args) # N: Revealed type is "tuple[builtins.int, builtins.str]" from typing import IsSub, Iter # Basic comprehension - maps over tuple elements -def f(x: tuple[*[T for T in Iter[tuple[int, str, float]]]]) -> None: # E: Name "T" is not defined +def f(x: tuple[*[T for T in Iter[tuple[int, str, float]]]]) -> None: pass reveal_type(f) # N: Revealed type is "def (x: tuple[builtins.int, builtins.str, builtins.float])" @@ -365,7 +365,7 @@ reveal_type(f) # N: Revealed type is "def (x: tuple[builtins.int, builtins.str, from typing import IsSub, Iter # Filter elements - only keep subtypes of int (int, bool) -def f(x: tuple[*[T for T in Iter[tuple[int, str, bool, float]] if IsSub[T, int]]]) -> None: # E: Name "T" is not defined +def f(x: tuple[*[T for T in Iter[tuple[int, str, bool, float]] if IsSub[T, int]]]) -> None: pass reveal_type(f) # N: Revealed type is "def (x: tuple[builtins.int, builtins.bool])" From ff0ae0ddbb9976ebc25603d1754e7ec6fc5c12be Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Wed, 21 Jan 2026 22:15:35 -0800 Subject: [PATCH 034/161] Add TypeForComprehension parsing to expr_to_unanalyzed_type Support the *[Expr for var in Iter if Cond] type comprehension syntax in expr_to_unanalyzed_type in addition to fastparse. Changes: - Import ListComprehension from mypy.nodes - Import TypeForComprehension from mypy.types - Add handling for StarExpr containing ListComprehension - Update return type from ProperType to Type - Update SemanticAnalyzer.expr_to_unanalyzed_type return type - Add assertion for _promote to ensure ProperType Co-Authored-By: Claude Opus 4.5 --- mypy/exprtotype.py | 54 +++++++++++++++++++++++++++++++++++++--------- mypy/semanal.py | 4 +++- 2 files changed, 47 insertions(+), 11 deletions(-) diff --git a/mypy/exprtotype.py b/mypy/exprtotype.py index 0d9d3ad3358c8..77061be160eb5 100644 --- a/mypy/exprtotype.py +++ b/mypy/exprtotype.py @@ -18,6 +18,7 @@ FloatExpr, IndexExpr, IntExpr, + ListComprehension, ListExpr, MemberExpr, NameExpr, @@ -41,6 +42,7 @@ RawExpressionType, Type, TypedDictType, + TypeForComprehension, TypeList, TypeOfAny, UnboundType, @@ -69,7 +71,7 @@ def expr_to_unanalyzed_type( _parent: Expression | None = None, allow_unpack: bool = False, lookup_qualified: Callable[[str, Context], SymbolTableNode | None] | None = None, -) -> ProperType: +) -> Type: """Translate an expression to the corresponding type. The result is not semantically analyzed. It can be UnboundType or TypeList. @@ -253,6 +255,37 @@ def expr_to_unanalyzed_type( elif isinstance(expr, EllipsisExpr): return EllipsisType(expr.line) elif allow_unpack and isinstance(expr, StarExpr): + # Check if this is a type comprehension: *[Expr for var in Iter if Cond] + if isinstance(expr.expr, ListComprehension): + gen = expr.expr.generator + # Only support single generator + if len(gen.sequences) != 1: + raise TypeTranslationError() + # The index should be a simple name + index = gen.indices[0] + if not isinstance(index, NameExpr): + raise TypeTranslationError() + iter_name = index.name + element_expr = expr_to_unanalyzed_type( + gen.left_expr, options, allow_new_syntax, lookup_qualified=lookup_qualified + ) + iter_type = expr_to_unanalyzed_type( + gen.sequences[0], options, allow_new_syntax, lookup_qualified=lookup_qualified + ) + conditions: list[Type] = [ + expr_to_unanalyzed_type( + cond, options, allow_new_syntax, lookup_qualified=lookup_qualified + ) + for cond in gen.condlists[0] + ] + return TypeForComprehension( + element_expr=element_expr, + iter_name=iter_name, + iter_type=iter_type, + conditions=conditions, + line=expr.line, + column=expr.column, + ) return UnpackType( expr_to_unanalyzed_type( expr.expr, options, allow_new_syntax, lookup_qualified=lookup_qualified @@ -263,19 +296,20 @@ def expr_to_unanalyzed_type( if not expr.items: raise TypeTranslationError() items: dict[str, Type] = {} - extra_items_from = [] + extra_items_from: list[ProperType] = [] for item_name, value in expr.items: if not isinstance(item_name, StrExpr): if item_name is None: - extra_items_from.append( - expr_to_unanalyzed_type( - value, - options, - allow_new_syntax, - expr, - lookup_qualified=lookup_qualified, - ) + typ = expr_to_unanalyzed_type( + value, + options, + allow_new_syntax, + expr, + lookup_qualified=lookup_qualified, ) + # TypedDict spread values should be ProperTypes + assert isinstance(typ, ProperType) + extra_items_from.append(typ) continue raise TypeTranslationError() items[item_name.value] = expr_to_unanalyzed_type( diff --git a/mypy/semanal.py b/mypy/semanal.py index 3ae981be94f71..0241af4015854 100644 --- a/mypy/semanal.py +++ b/mypy/semanal.py @@ -6060,6 +6060,8 @@ def visit_call_expr(self, expr: CallExpr) -> None: except TypeTranslationError: self.fail("Argument 1 to _promote is not a type", expr) return + # _promote should only be used with proper types + assert isinstance(target, ProperType) expr.analyzed = PromoteExpr(target) expr.analyzed.line = expr.line expr.analyzed.accept(self) @@ -7800,7 +7802,7 @@ def type_analyzer( tpan.global_scope = not self.type and not self.function_stack return tpan - def expr_to_unanalyzed_type(self, node: Expression, allow_unpack: bool = False) -> ProperType: + def expr_to_unanalyzed_type(self, node: Expression, allow_unpack: bool = False) -> Type: return expr_to_unanalyzed_type( node, self.options, self.is_stub_file, allow_unpack=allow_unpack ) From 8c37f7babe4134b77a6673b3dbd74d0d7a9c48c4 Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Wed, 21 Jan 2026 23:11:10 -0800 Subject: [PATCH 035/161] Fix type comprehension analysis in PEP 695 type aliases When analyzing type aliases like `type A = tuple[*[Uppercase[T] for T in Iter[...]]]`, it is also analyzed as if it was an expression, for mypyc purposes. This was causing trouble because `T` was being showed up as a `Var`. the expression analysis path (intentionally, for mypyc) analyzes `Uppercase[T]` as a runtime expression. This caused the lookup to find `T` from the typing module (a Var) instead of recognizing it as a comprehension variable, resulting in "Variable T is not valid as a type" errors. The fix: 1. When analyzing a type as an expression, bind variables as TypeVars. 2. Add comprehension variables to allowed_alias_tvars to prevent "type parameter not declared" errors Claude did some really dumb stuff instead of 1. Co-Authored-By: Claude Opus 4.5 --- mypy/exprtotype.py | 6 +--- mypy/semanal.py | 39 ++++++++++++++++----- mypy/typeanal.py | 6 ++++ test-data/unit/check-type-aliases.test | 11 ++++++ test-data/unit/check-typelevel-basic.test | 42 +++++++++++++++++++++++ 5 files changed, 90 insertions(+), 14 deletions(-) diff --git a/mypy/exprtotype.py b/mypy/exprtotype.py index 77061be160eb5..b6e26ebf1bd2c 100644 --- a/mypy/exprtotype.py +++ b/mypy/exprtotype.py @@ -301,11 +301,7 @@ def expr_to_unanalyzed_type( if not isinstance(item_name, StrExpr): if item_name is None: typ = expr_to_unanalyzed_type( - value, - options, - allow_new_syntax, - expr, - lookup_qualified=lookup_qualified, + value, options, allow_new_syntax, expr, lookup_qualified=lookup_qualified ) # TypedDict spread values should be ProperTypes assert isinstance(typ, ProperType) diff --git a/mypy/semanal.py b/mypy/semanal.py index 0241af4015854..a243c04842a77 100644 --- a/mypy/semanal.py +++ b/mypy/semanal.py @@ -520,6 +520,12 @@ def __init__( # new uses of this, as this may cause leaking `UnboundType`s to type checking. self.allow_unbound_tvars = False + # Set when we are analyzing a type as an expression. + # We need to do that analysis mostly for mypyc. + # When doing this, we disable variable binding in `for`, which + # can now appear in expressions. + self.analyzing_type_expr = False + # Used to pass information about current overload index to visit_func_def(). self.current_overload_item: int | None = None @@ -575,6 +581,15 @@ def allow_unbound_tvars_set(self) -> Iterator[None]: finally: self.allow_unbound_tvars = old + @contextmanager + def analyzing_type_expr_set(self) -> Iterator[None]: + old = self.analyzing_type_expr + self.analyzing_type_expr = True + try: + yield + finally: + self.analyzing_type_expr = old + @contextmanager def inside_except_star_block_set( self, value: bool, entering_loop: bool = False @@ -1889,8 +1904,6 @@ def push_type_args( tvs: list[tuple[str, TypeVarLikeExpr]] = [] for p in type_args: tv = self.analyze_type_param(p, context) - if tv is None: - return None tvs.append((p.name, tv)) if self.is_defined_type_param(p.name): @@ -1912,9 +1925,7 @@ def is_defined_type_param(self, name: str) -> bool: return True return False - def analyze_type_param( - self, type_param: TypeParam, context: Context - ) -> TypeVarLikeExpr | None: + def analyze_type_param(self, type_param: TypeParam, context: Context) -> TypeVarLikeExpr: fullname = self.qualified_name(type_param.name) if type_param.upper_bound: upper_bound = self.anal_type(type_param.upper_bound, allow_placeholder=True) @@ -4515,9 +4526,15 @@ def analyze_name_lvalue( if (not existing or isinstance(existing.node, PlaceholderNode)) and not outer: # Define new variable. - var = self.make_name_lvalue_var( - lvalue, kind, not explicit_type, has_explicit_value, is_index_var - ) + var: SymbolNode + if self.analyzing_type_expr: + # When analyzing type expressions... the lvalues are type variable + param = TypeParam(lvalue.name, TYPE_VAR_KIND, None, [], None) + var = self.analyze_type_param(param, lvalue) + else: + var = self.make_name_lvalue_var( + lvalue, kind, not explicit_type, has_explicit_value, is_index_var + ) added = self.add_symbol(name, var, lvalue, escape_comprehensions=escape_comprehensions) # Only bind expression if we successfully added name to symbol table. if added: @@ -7758,7 +7775,11 @@ def analyze_type_expr(self, expr: Expression) -> None: # them semantically analyzed, however, if they need to treat it as an expression # and not a type. (Which is to say, mypyc needs to do this.) Do the analysis # in a fresh tvar scope in order to suppress any errors about using type variables. - with self.tvar_scope_frame(TypeVarLikeScope()), self.allow_unbound_tvars_set(): + with ( + self.tvar_scope_frame(TypeVarLikeScope()), + self.allow_unbound_tvars_set(), + self.analyzing_type_expr_set(), + ): expr.accept(self) def type_analyzer( diff --git a/mypy/typeanal.py b/mypy/typeanal.py index a5ad6f8dcb3ef..1dfc121c5a83c 100644 --- a/mypy/typeanal.py +++ b/mypy/typeanal.py @@ -1151,6 +1151,11 @@ def visit_type_for_comprehension(self, t: TypeForComprehension) -> Type: iter_var = self.tvar_scope.bind_new(t.iter_name, var_exprs[0][1], self.fail_func, t) assert isinstance(iter_var, TypeVarType), type(iter_var) + # Add the comprehension variable to allowed_alias_tvars so it doesn't + # trigger "type parameter not declared" errors when defining_alias=True + old_allowed = self.allowed_alias_tvars + self.allowed_alias_tvars = old_allowed + [iter_var] + analt = t.copy_modified( element_expr=self.anal_type(t.element_expr), iter_type=iter_type, @@ -1158,6 +1163,7 @@ def visit_type_for_comprehension(self, t: TypeForComprehension) -> Type: iter_var=iter_var, ) + self.allowed_alias_tvars = old_allowed sem.pop_type_args(targs) return analt diff --git a/test-data/unit/check-type-aliases.test b/test-data/unit/check-type-aliases.test index 69474e2850055..396fa55ba44eb 100644 --- a/test-data/unit/check-type-aliases.test +++ b/test-data/unit/check-type-aliases.test @@ -1449,3 +1449,14 @@ Alias1: TypeAlias = Union[C, int] class SomeClass: pass [builtins fixtures/tuple.pyi] + +[case testLooksLikeAlias] + +# Test that we correctly catch type errors that depend on an iterator +# inside something that *seems* like it could be an alias. + +# This was written because a workaround attempted for a different +# problem broke this at one point. + +x = [0] +y = x[[i for i in [None]][0]] # E: Invalid index type "None" for "list[int]"; expected type "int" diff --git a/test-data/unit/check-typelevel-basic.test b/test-data/unit/check-typelevel-basic.test index 75451a74fe025..df513f26c0363 100644 --- a/test-data/unit/check-typelevel-basic.test +++ b/test-data/unit/check-typelevel-basic.test @@ -383,3 +383,45 @@ reveal_type(x) # N: Revealed type is "tuple[Literal['A'], Literal['B'], Literal [builtins fixtures/typelevel.pyi] [typing fixtures/typing-full.pyi] + + +[case testTypeComprehensionAlias1] +# flags: --python-version 3.14 +# Test type comprehension with element transformation +from typing import Uppercase, Literal, Iter + +# Transform elements +type A = tuple[*[Uppercase[T] for T in Iter[tuple[Literal["a"], Literal["b"], Literal["c"]]]]] +x: A +reveal_type(x) # N: Revealed type is "tuple[Literal['A'], Literal['B'], Literal['C']]" + +[builtins fixtures/typelevel.pyi] +[typing fixtures/typing-full.pyi] + +[case testTypeComprehensionAlias2] +# flags: --python-version 3.14 +# Test type comprehension with element transformation +from typing import Uppercase, Literal, Iter + +# Transform elements +type Trans[U] = tuple[*[Uppercase[T] for T in Iter[U]]] +x: Trans[tuple[Literal["a"], Literal["b"], Literal["c"]]] +reveal_type(x) # N: Revealed type is "tuple[Literal['A'], Literal['B'], Literal['C']]" + +[builtins fixtures/typelevel.pyi] +[typing fixtures/typing-full.pyi] + +[case testTypeComprehensionAlias3] +# flags: --python-version 3.14 +# Test type comprehension with element transformation +from typing import Uppercase, Literal, Iter + +# XXX: DECISION: Should shadowing be allowed? + +# Transform elements +type Trans[T] = tuple[*[Uppercase[T] for T in Iter[T]]] # E: "T" already defined as a type parameter +x: Trans[tuple[Literal["a"], Literal["b"], Literal["c"]]] +reveal_type(x) # N: Revealed type is "tuple[Literal['A'], Literal['B'], Literal['C']]" + +[builtins fixtures/typelevel.pyi] +[typing fixtures/typing-full.pyi] From 5e60b2f4bdcf9de3b9b4ecc16a23705141b25b98 Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Thu, 22 Jan 2026 13:27:37 -0800 Subject: [PATCH 036/161] Add a notion of SemiProperType --- mypy/expandtype.py | 26 +++++++++--------- mypy/plugins/proper_plugin.py | 4 +-- mypy/test/testtypes.py | 8 +++++- mypy/types.py | 52 ++++++++++++++++++++++++++++++++--- 4 files changed, 70 insertions(+), 20 deletions(-) diff --git a/mypy/expandtype.py b/mypy/expandtype.py index 40fd7ac5d8fb2..9119cfb36c4bb 100644 --- a/mypy/expandtype.py +++ b/mypy/expandtype.py @@ -38,7 +38,7 @@ UnionType, UnpackType, flatten_nested_unions, - get_proper_type, + get_semi_proper_type, split_with_prefix_and_suffix, ) from mypy.typevartuples import split_with_instance @@ -50,8 +50,8 @@ # is_subtype(), meet_types(), join_types() etc. # TODO: add a static dependency test for this. -# XXX: The changes to get_proper_type to do type-level computation -# breaks this invariant! I think we need another layer of indirection! +# WARNING: WARNING: This *also* means that get_proper_type can't be used here! +# Since type evaluation can probably depend on all that stuff. @overload @@ -230,9 +230,9 @@ def visit_instance(self, t: Instance) -> Type: # Normalize Tuple[*Tuple[X, ...], ...] -> Tuple[X, ...] arg = args[0] if isinstance(arg, UnpackType): - unpacked = get_proper_type(arg.type) + unpacked = get_semi_proper_type(arg.type) if isinstance(unpacked, Instance): - # TODO: this and similar asserts below may be unsafe because get_proper_type() + # TODO: this and similar asserts below may be unsafe because get_semi_proper_type() # may be called during semantic analysis before all invalid types are removed. assert unpacked.type.fullname == "builtins.tuple" args = list(unpacked.args) @@ -397,9 +397,9 @@ def expand_unpack(self, t: UnpackType) -> list[Type]: assert isinstance(t.type, ProperType) and isinstance(t.type, TupleType) t2 = t.type fallback = t2.partial_fallback - repl = get_proper_type(t2) + repl = get_semi_proper_type(t2) if isinstance(repl, UnpackType): - repl = get_proper_type(repl.type) + repl = get_semi_proper_type(repl.type) if isinstance(repl, TupleType): return repl.items elif ( @@ -423,7 +423,7 @@ def interpolate_args_for_unpack(self, t: CallableType, var_arg: UnpackType) -> l prefix = self.expand_types(t.arg_types[:star_index]) suffix = self.expand_types(t.arg_types[star_index + 1 :]) - var_arg_type = get_proper_type(var_arg.type) + var_arg_type = get_semi_proper_type(var_arg.type) new_unpack: Type if isinstance(var_arg_type, TupleType): # We have something like Unpack[Tuple[Unpack[Ts], X1, X2]] @@ -437,7 +437,7 @@ def interpolate_args_for_unpack(self, t: CallableType, var_arg: UnpackType) -> l fallback = var_arg_type.tuple_fallback expanded_items = self.expand_unpack(var_arg) new_unpack = UnpackType(TupleType(expanded_items, fallback)) - # Since get_proper_type() may be called in semanal.py before callable + # Since get_semi_proper_type() may be called in semanal.py before callable # normalization happens, we need to also handle non-normal cases here. elif isinstance(var_arg_type, Instance): # we have something like Unpack[Tuple[Any, ...]] @@ -554,7 +554,7 @@ def visit_tuple_type(self, t: TupleType) -> Type: # Normalize Tuple[*Tuple[X, ...]] -> Tuple[X, ...] item = items[0] if isinstance(item, UnpackType): - unpacked = get_proper_type(item.type) + unpacked = get_semi_proper_type(item.type) if isinstance(unpacked, Instance): # expand_type() may be called during semantic analysis, before invalid unpacks are fixed. if unpacked.type.fullname != "builtins.tuple": @@ -595,12 +595,12 @@ def visit_union_type(self, t: UnionType) -> Type: simplified = UnionType.make_union( remove_trivial(flatten_nested_unions(expanded)), t.line, t.column ) - # This call to get_proper_type() is unfortunate but is required to preserve + # This call to get_semi_proper_type() is unfortunate but is required to preserve # the invariant that ProperType will stay ProperType after applying expand_type(), # otherwise a single item union of a type alias will break it. Note this should not # cause infinite recursion since pathological aliases like A = Union[A, B] are # banned at the semantic analysis level. - result = get_proper_type(simplified) + result = get_semi_proper_type(simplified) if use_cache: self.set_cached(t, result) @@ -659,7 +659,7 @@ def remove_trivial(types: Iterable[Type]) -> list[Type]: new_types = [] all_types = set() for t in types: - p_t = get_proper_type(t) + p_t = get_semi_proper_type(t) if isinstance(p_t, UninhabitedType): continue if isinstance(p_t, NoneType) and not state.strict_optional: diff --git a/mypy/plugins/proper_plugin.py b/mypy/plugins/proper_plugin.py index d7e50fab48806..d7199e9f786e0 100644 --- a/mypy/plugins/proper_plugin.py +++ b/mypy/plugins/proper_plugin.py @@ -122,11 +122,11 @@ def is_special_target(right: ProperType) -> bool: def is_improper_type(typ: Type) -> bool: - """Is this a type that is not a subtype of ProperType?""" + """Is this a type that is not a subtype of SemiProperType?""" typ = get_proper_type(typ) if isinstance(typ, Instance): info = typ.type - return info.has_base("mypy.types.Type") and not info.has_base("mypy.types.ProperType") + return info.has_base("mypy.types.Type") and not info.has_base("mypy.types.SemiProperType") if isinstance(typ, UnionType): return any(is_improper_type(t) for t in typ.items) return False diff --git a/mypy/test/testtypes.py b/mypy/test/testtypes.py index 6562f541d73bc..f48295477035c 100644 --- a/mypy/test/testtypes.py +++ b/mypy/test/testtypes.py @@ -1598,9 +1598,11 @@ def make_call(*items: tuple[str, str | None]) -> CallExpr: class TestExpandTypeLimitGetProperType(TestCase): + # WARNING: This should probably stay 0 forever. + ALLOWED_GET_PROPER_TYPES = 0 # WARNING: do not increase this number unless absolutely necessary, # and you understand what you are doing. - ALLOWED_GET_PROPER_TYPES = 7 + ALLOWED_GET_SEMI_PROPER_TYPES = 7 @skipUnless(mypy.expandtype.__file__.endswith(".py"), "Skip for compiled mypy") def test_count_get_proper_type(self) -> None: @@ -1609,3 +1611,7 @@ def test_count_get_proper_type(self) -> None: get_proper_type_count = len(re.findall(r"get_proper_type\(", code)) get_proper_type_count -= len(re.findall(r"get_proper_type\(\)", code)) assert get_proper_type_count == self.ALLOWED_GET_PROPER_TYPES + + get_semi_proper_type_count = len(re.findall(r"get_semi_proper_type\(", code)) + get_semi_proper_type_count -= len(re.findall(r"get_semi_proper_type\(\)", code)) + assert get_semi_proper_type_count == self.ALLOWED_GET_SEMI_PROPER_TYPES diff --git a/mypy/types.py b/mypy/types.py index 269fdd35818a0..1be21c9910645 100644 --- a/mypy/types.py +++ b/mypy/types.py @@ -468,10 +468,25 @@ def read(cls, data: ReadBuffer) -> TypeAliasType: return alias -class ComputedType(Type): +class SemiProperType(Type): + """Not a type alias. + + *Can* be a ComputedType! + + Most places should keep using get_proper_type(), though, I believe. + + Every type except TypeAliasType (and its subclasses) + must inherit from this type. + """ + + __slots__ = () + + +class ComputedType(SemiProperType): """Base class for types that represent unevaluated type-level computations. - NOT a ProperType - must be expanded/evaluated before use in most type + NOT a ProperType or even a SemiProperType + - must be expanded/evaluated before use in most type operations. Analogous to TypeAliasType in that it wraps a computation that produces a concrete type. @@ -748,7 +763,7 @@ def accept(self, visitor: TypeVisitor[T]) -> T: return self.item.accept(visitor) -class ProperType(Type): +class ProperType(SemiProperType): """Not a type alias or computed type. Every type except TypeAliasType and ComputedType (and its subclasses) @@ -3873,6 +3888,34 @@ def serialize(self) -> str: assert False, f"Internal error: unresolved placeholder type {self.fullname}" +@overload +def get_semi_proper_type(typ: None) -> None: ... + + +@overload +def get_semi_proper_type(typ: Type) -> SemiProperType: ... + + +def get_semi_proper_type(typ: Type | None) -> SemiProperType | None: + """Get the expansion of a type alias type. + + If the type is already a proper type, this is a no-op. Use this function + wherever a decision is made on a call like e.g. 'if isinstance(typ, UnionType): ...', + because 'typ' in this case may be an alias to union. Note: if after making the decision + on the isinstance() call you pass on the original type (and not one of its components) + it is recommended to *always* pass on the unexpanded alias. + """ + if typ is None: + return None + # TODO: this is an ugly hack, remove. + if isinstance(typ, TypeGuardedType): + typ = typ.type_guard + while isinstance(typ, TypeAliasType): + typ = typ._expand_once() + # TODO: store the name of original type alias on this type, so we can show it in errors. + return cast(SemiProperType, typ) + + @overload def get_proper_type(typ: None) -> None: ... @@ -4489,7 +4532,8 @@ def flatten_nested_unions( if not handle_recursive and t.is_recursive: tp: Type = t else: - tp = get_proper_type(t) + # N.B: Not get_proper_type(), because this is called from expand_type + tp = get_semi_proper_type(t) else: tp = t if isinstance(tp, ProperType) and isinstance(tp, UnionType): From 0e6a0b980b53f8cd3d2995431bf92a974f582841 Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Mon, 26 Jan 2026 13:01:11 -0800 Subject: [PATCH 037/161] Get rid of SemiProperType, which is an illformed concept --- mypy/expandtype.py | 22 ++++++++++----------- mypy/plugins/proper_plugin.py | 4 ++-- mypy/test/testtypes.py | 8 ++++---- mypy/types.py | 36 ++++++++++++----------------------- 4 files changed, 29 insertions(+), 41 deletions(-) diff --git a/mypy/expandtype.py b/mypy/expandtype.py index 9119cfb36c4bb..98b5eef474fc7 100644 --- a/mypy/expandtype.py +++ b/mypy/expandtype.py @@ -38,7 +38,7 @@ UnionType, UnpackType, flatten_nested_unions, - get_semi_proper_type, + get_proper_type_simple, split_with_prefix_and_suffix, ) from mypy.typevartuples import split_with_instance @@ -230,9 +230,9 @@ def visit_instance(self, t: Instance) -> Type: # Normalize Tuple[*Tuple[X, ...], ...] -> Tuple[X, ...] arg = args[0] if isinstance(arg, UnpackType): - unpacked = get_semi_proper_type(arg.type) + unpacked = get_proper_type_simple(arg.type) if isinstance(unpacked, Instance): - # TODO: this and similar asserts below may be unsafe because get_semi_proper_type() + # TODO: this and similar asserts below may be unsafe because get_proper_type_simple() # may be called during semantic analysis before all invalid types are removed. assert unpacked.type.fullname == "builtins.tuple" args = list(unpacked.args) @@ -397,9 +397,9 @@ def expand_unpack(self, t: UnpackType) -> list[Type]: assert isinstance(t.type, ProperType) and isinstance(t.type, TupleType) t2 = t.type fallback = t2.partial_fallback - repl = get_semi_proper_type(t2) + repl = get_proper_type_simple(t2) if isinstance(repl, UnpackType): - repl = get_semi_proper_type(repl.type) + repl = get_proper_type_simple(repl.type) if isinstance(repl, TupleType): return repl.items elif ( @@ -423,7 +423,7 @@ def interpolate_args_for_unpack(self, t: CallableType, var_arg: UnpackType) -> l prefix = self.expand_types(t.arg_types[:star_index]) suffix = self.expand_types(t.arg_types[star_index + 1 :]) - var_arg_type = get_semi_proper_type(var_arg.type) + var_arg_type = get_proper_type_simple(var_arg.type) new_unpack: Type if isinstance(var_arg_type, TupleType): # We have something like Unpack[Tuple[Unpack[Ts], X1, X2]] @@ -437,7 +437,7 @@ def interpolate_args_for_unpack(self, t: CallableType, var_arg: UnpackType) -> l fallback = var_arg_type.tuple_fallback expanded_items = self.expand_unpack(var_arg) new_unpack = UnpackType(TupleType(expanded_items, fallback)) - # Since get_semi_proper_type() may be called in semanal.py before callable + # Since get_proper_type_simple() may be called in semanal.py before callable # normalization happens, we need to also handle non-normal cases here. elif isinstance(var_arg_type, Instance): # we have something like Unpack[Tuple[Any, ...]] @@ -554,7 +554,7 @@ def visit_tuple_type(self, t: TupleType) -> Type: # Normalize Tuple[*Tuple[X, ...]] -> Tuple[X, ...] item = items[0] if isinstance(item, UnpackType): - unpacked = get_semi_proper_type(item.type) + unpacked = get_proper_type_simple(item.type) if isinstance(unpacked, Instance): # expand_type() may be called during semantic analysis, before invalid unpacks are fixed. if unpacked.type.fullname != "builtins.tuple": @@ -595,12 +595,12 @@ def visit_union_type(self, t: UnionType) -> Type: simplified = UnionType.make_union( remove_trivial(flatten_nested_unions(expanded)), t.line, t.column ) - # This call to get_semi_proper_type() is unfortunate but is required to preserve + # This call to get_proper_type_simple() is unfortunate but is required to preserve # the invariant that ProperType will stay ProperType after applying expand_type(), # otherwise a single item union of a type alias will break it. Note this should not # cause infinite recursion since pathological aliases like A = Union[A, B] are # banned at the semantic analysis level. - result = get_semi_proper_type(simplified) + result = get_proper_type_simple(simplified) if use_cache: self.set_cached(t, result) @@ -659,7 +659,7 @@ def remove_trivial(types: Iterable[Type]) -> list[Type]: new_types = [] all_types = set() for t in types: - p_t = get_semi_proper_type(t) + p_t = get_proper_type_simple(t) if isinstance(p_t, UninhabitedType): continue if isinstance(p_t, NoneType) and not state.strict_optional: diff --git a/mypy/plugins/proper_plugin.py b/mypy/plugins/proper_plugin.py index d7199e9f786e0..d7e50fab48806 100644 --- a/mypy/plugins/proper_plugin.py +++ b/mypy/plugins/proper_plugin.py @@ -122,11 +122,11 @@ def is_special_target(right: ProperType) -> bool: def is_improper_type(typ: Type) -> bool: - """Is this a type that is not a subtype of SemiProperType?""" + """Is this a type that is not a subtype of ProperType?""" typ = get_proper_type(typ) if isinstance(typ, Instance): info = typ.type - return info.has_base("mypy.types.Type") and not info.has_base("mypy.types.SemiProperType") + return info.has_base("mypy.types.Type") and not info.has_base("mypy.types.ProperType") if isinstance(typ, UnionType): return any(is_improper_type(t) for t in typ.items) return False diff --git a/mypy/test/testtypes.py b/mypy/test/testtypes.py index f48295477035c..259bcd8c6079c 100644 --- a/mypy/test/testtypes.py +++ b/mypy/test/testtypes.py @@ -1602,7 +1602,7 @@ class TestExpandTypeLimitGetProperType(TestCase): ALLOWED_GET_PROPER_TYPES = 0 # WARNING: do not increase this number unless absolutely necessary, # and you understand what you are doing. - ALLOWED_GET_SEMI_PROPER_TYPES = 7 + ALLOWED_GET_PROPER_TYPE_SIMPLE = 7 @skipUnless(mypy.expandtype.__file__.endswith(".py"), "Skip for compiled mypy") def test_count_get_proper_type(self) -> None: @@ -1612,6 +1612,6 @@ def test_count_get_proper_type(self) -> None: get_proper_type_count -= len(re.findall(r"get_proper_type\(\)", code)) assert get_proper_type_count == self.ALLOWED_GET_PROPER_TYPES - get_semi_proper_type_count = len(re.findall(r"get_semi_proper_type\(", code)) - get_semi_proper_type_count -= len(re.findall(r"get_semi_proper_type\(\)", code)) - assert get_semi_proper_type_count == self.ALLOWED_GET_SEMI_PROPER_TYPES + get_proper_type_simple_count = len(re.findall(r"get_proper_type_simple\(", code)) + get_proper_type_simple_count -= len(re.findall(r"get_proper_type_simple\(\)", code)) + assert get_proper_type_simple_count == self.ALLOWED_GET_PROPER_TYPE_SIMPLE diff --git a/mypy/types.py b/mypy/types.py index 1be21c9910645..6ac05f700313a 100644 --- a/mypy/types.py +++ b/mypy/types.py @@ -468,24 +468,20 @@ def read(cls, data: ReadBuffer) -> TypeAliasType: return alias -class SemiProperType(Type): - """Not a type alias. - - *Can* be a ComputedType! - - Most places should keep using get_proper_type(), though, I believe. +class ProperType(Type): + """Not a type alias or computed type. - Every type except TypeAliasType (and its subclasses) + Every type except TypeAliasType and ComputedType (and its subclasses) must inherit from this type. """ __slots__ = () -class ComputedType(SemiProperType): +class ComputedType(ProperType): """Base class for types that represent unevaluated type-level computations. - NOT a ProperType or even a SemiProperType + This is a ProperType, though it still should usually be expanded. - must be expanded/evaluated before use in most type operations. Analogous to TypeAliasType in that it wraps a computation that produces a concrete type. @@ -763,16 +759,6 @@ def accept(self, visitor: TypeVisitor[T]) -> T: return self.item.accept(visitor) -class ProperType(SemiProperType): - """Not a type alias or computed type. - - Every type except TypeAliasType and ComputedType (and its subclasses) - must inherit from this type. - """ - - __slots__ = () - - class TypeVarId: # A type variable is uniquely identified by its raw id and meta level. @@ -3889,14 +3875,14 @@ def serialize(self) -> str: @overload -def get_semi_proper_type(typ: None) -> None: ... +def get_proper_type_simple(typ: None) -> None: ... @overload -def get_semi_proper_type(typ: Type) -> SemiProperType: ... +def get_proper_type_simple(typ: Type) -> ProperType: ... -def get_semi_proper_type(typ: Type | None) -> SemiProperType | None: +def get_proper_type_simple(typ: Type | None) -> ProperType | None: """Get the expansion of a type alias type. If the type is already a proper type, this is a no-op. Use this function @@ -3913,7 +3899,7 @@ def get_semi_proper_type(typ: Type | None) -> SemiProperType | None: while isinstance(typ, TypeAliasType): typ = typ._expand_once() # TODO: store the name of original type alias on this type, so we can show it in errors. - return cast(SemiProperType, typ) + return cast(ProperType, typ) @overload @@ -3932,6 +3918,8 @@ def get_proper_type(typ: Type | None) -> ProperType | None: because 'typ' in this case may be an alias to union. Note: if after making the decision on the isinstance() call you pass on the original type (and not one of its components) it is recommended to *always* pass on the unexpanded alias. + + This also *attempts* to expand computed types, though it might fail. """ from mypy.expandtype import expand_type @@ -4533,7 +4521,7 @@ def flatten_nested_unions( tp: Type = t else: # N.B: Not get_proper_type(), because this is called from expand_type - tp = get_semi_proper_type(t) + tp = get_proper_type_simple(t) else: tp = t if isinstance(tp, ProperType) and isinstance(tp, UnionType): From ca0f041789d7d4c154429ff968839b63a00aaa8b Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Mon, 26 Jan 2026 13:40:55 -0800 Subject: [PATCH 038/161] Make get_proper_type able to produce stuck types --- mypy/constraints.py | 18 ++++++++++++------ mypy/messages.py | 8 +++----- mypy/types.py | 20 +++++++------------- test-data/unit/check-typelevel-basic.test | 2 ++ 4 files changed, 24 insertions(+), 24 deletions(-) diff --git a/mypy/constraints.py b/mypy/constraints.py index 78d8fca91fda3..b0b7fbcacf8bd 100644 --- a/mypy/constraints.py +++ b/mypy/constraints.py @@ -1431,14 +1431,20 @@ def visit_type_alias_type(self, template: TypeAliasType) -> list[Constraint]: assert False, f"This should be never called, got {template}" def visit_type_operator_type(self, template: TypeOperatorType) -> list[Constraint]: - assert ( - False - ), f"Computed types should be expanded before constraint inference, got {template}" + # TODO: Is this right? + # + # We don't really know how to resolve constraints here, so + # resolve none, and hope that when variables are substituted, + # we figure out if things are ok. + return [] def visit_type_for_comprehension(self, template: TypeForComprehension) -> list[Constraint]: - assert ( - False - ), f"Computed types should be expanded before constraint inference, got {template}" + # TODO: Is this right? + # + # We don't really know how to resolve constraints here, so + # resolve none, and hope that when variables are substituted, + # we figure out if things are ok. + return [] def infer_against_any(self, types: Iterable[Type], any_type: AnyType) -> list[Constraint]: res: list[Constraint] = [] diff --git a/mypy/messages.py b/mypy/messages.py index 869616c3adb2f..365b6dcb51d2c 100644 --- a/mypy/messages.py +++ b/mypy/messages.py @@ -103,7 +103,6 @@ flatten_nested_unions, get_proper_type, get_proper_types, - try_expand, ) from mypy.typetraverser import TypeTraverserVisitor from mypy.util import plural_s, unmangle @@ -2610,15 +2609,14 @@ def format_literal_value(typ: LiteralType) -> str: type_str += f"[{format_list(typ.args)}]" return type_str - typ = try_expand(typ) + # TODO: always mention type alias names in errors. + typ = get_proper_type(typ) + if isinstance(typ, TypeOperatorType): # There are type arguments. Convert the arguments to strings. base_str = typ.type.fullname if typ.type else "" return f"{base_str}[{format_list(typ.args)}]" - # TODO: always mention type alias names in errors. - typ = get_proper_type(typ) - if isinstance(typ, Instance): itype = typ # Get the short name of the type. diff --git a/mypy/types.py b/mypy/types.py index 6ac05f700313a..dddad846b86ff 100644 --- a/mypy/types.py +++ b/mypy/types.py @@ -3935,18 +3935,20 @@ def get_proper_type(typ: Type | None) -> ProperType | None: typ = typ._expand_once() elif isinstance(typ, ComputedType): # Handles TypeOperatorType, TypeForComprehension - typ = typ.expand() + if not is_stuck_expansion(ntyp := typ.expand()): + typ = ntyp + else: + break elif ( isinstance(typ, ProperType) and isinstance(typ, TupleType) and any(isinstance(st, ComputedType) for st in typ.items) and not trouble ): - # XXX: need to get rid of full get_proper_type calls in expandtype - # and cover Instance, Callable + # XXX: need to cover Instance, Callable # also I'm really not sure about this at all! # this is a lot of work to be doing in get_proper_type - typ2 = typ.copy_modified(items=[try_expand(st) for st in typ.items]) + typ2 = typ.copy_modified(items=[get_proper_type(st) for st in typ.items]) typ = expand_type(typ2, {}) trouble = True @@ -3992,20 +3994,12 @@ def is_stuck_expansion(typ: Type) -> bool: def try_expand_or_none(type: Type) -> ProperType | None: """Try to expand a type, but return None if it gets stuck""" type2 = get_proper_type(type) - if is_stuck_expansion(type2): + if type is type2 and isinstance(type, ComputedType): return None else: return type2 -def try_expand(type: Type) -> Type: - """Try to expand a type, but return the original type if it gets stuck""" - if type2 := try_expand_or_none(type): - return type2 - else: - return type - - # We split off the type visitor base classes to another module # to make it easier to gradually get modules working with mypyc. # Import them here, after the types are defined. diff --git a/test-data/unit/check-typelevel-basic.test b/test-data/unit/check-typelevel-basic.test index df513f26c0363..e6847f51ed706 100644 --- a/test-data/unit/check-typelevel-basic.test +++ b/test-data/unit/check-typelevel-basic.test @@ -159,6 +159,8 @@ x1: Callable[[Literal[True], int], None] = process # E: Incompatible types in a x2: Callable[[Literal[False], str], None] = process # E: Incompatible types in assignment (expression has type "Callable[[typing.IsSub[T, str], T], None]", variable has type "Callable[[Literal[False], str], None]") x3: Callable[[Literal[True], str], None] = process +reveal_type(process) # N: Revealed type is "def [T] (x: typing.IsSub[T`-1, builtins.str], y: T`-1)" + [builtins fixtures/typelevel.pyi] [typing fixtures/typing-full.pyi] From aef882a5d774efd2432dc81a179ff8311e6b327a Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Mon, 26 Jan 2026 14:05:22 -0800 Subject: [PATCH 039/161] Implement subtype checking using is_same_type checking... --- mypy/messages.py | 5 + mypy/subtypes.py | 25 ++++- test-data/unit/check-typelevel-basic.test | 111 ++++++++++++++++++++++ 3 files changed, 139 insertions(+), 2 deletions(-) diff --git a/mypy/messages.py b/mypy/messages.py index 365b6dcb51d2c..e4dc4b5cab581 100644 --- a/mypy/messages.py +++ b/mypy/messages.py @@ -89,6 +89,7 @@ Type, TypeAliasType, TypedDictType, + TypeForComprehension, TypeOfAny, TypeOperatorType, TypeStrVisitor, @@ -2617,6 +2618,10 @@ def format_literal_value(typ: LiteralType) -> str: base_str = typ.type.fullname if typ.type else "" return f"{base_str}[{format_list(typ.args)}]" + if isinstance(typ, TypeForComprehension): + conditions_str = "".join(f" if {format(c)}" for c in typ.conditions) + return f"*[{format(typ.element_expr)} for {typ.iter_name} in {format(typ.iter_type)}{conditions_str}]" + if isinstance(typ, Instance): itype = typ # Get the short name of the type. diff --git a/mypy/subtypes.py b/mypy/subtypes.py index ab4fa1101052d..74a371e327bfe 100644 --- a/mypy/subtypes.py +++ b/mypy/subtypes.py @@ -1163,10 +1163,31 @@ def visit_type_alias_type(self, left: TypeAliasType) -> bool: assert False, f"This should be never called, got {left}" def visit_type_operator_type(self, left: TypeOperatorType) -> bool: - assert False, f"Computed types should be expanded before subtype check, got {left}" + # PERF: Using is_same_type can mean exponential time checking... + if isinstance(self.right, TypeOperatorType): + if left.fullname == self.right.fullname and len(left.args) == len(self.right.args): + return all(is_same_type(la, ra) for la, ra in zip(left.args, self.right.args)) + return False def visit_type_for_comprehension(self, left: TypeForComprehension) -> bool: - assert False, f"Computed types should be expanded before subtype check, got {left}" + # PERF: Using is_same_type can mean exponential time checking... + if isinstance(self.right, TypeForComprehension): + if len(left.conditions) != len(self.right.conditions): + return False + if not is_same_type(left.iter_type, self.right.iter_type): + return False + + # Substitute left.iter_var for right.iter_var in right's expressions + assert self.right.iter_var is not None and left.iter_var is not None + env = {self.right.iter_var.id: left.iter_var} + right_element_expr = expand_type(self.right.element_expr, env) + right_conditions = [expand_type(c, env) for c in self.right.conditions] + + if is_same_type(left.element_expr, right_element_expr) and all( + is_same_type(lc, rc) for lc, rc in zip(left.conditions, right_conditions) + ): + return True + return False T = TypeVar("T", bound=Type) diff --git a/test-data/unit/check-typelevel-basic.test b/test-data/unit/check-typelevel-basic.test index e6847f51ed706..a37e479abbbdb 100644 --- a/test-data/unit/check-typelevel-basic.test +++ b/test-data/unit/check-typelevel-basic.test @@ -427,3 +427,114 @@ reveal_type(x) # N: Revealed type is "tuple[Literal['A'], Literal['B'], Literal [builtins fixtures/typelevel.pyi] [typing fixtures/typing-full.pyi] + +[case testTypeOperatorSubType0] +# flags: --python-version 3.14 + +from typing import IsSub + +def process[T](x: IsSub[T, str], y: IsSub[T, int]) -> None: + x = y # E: Incompatible types in assignment (expression has type "typing.IsSub[T, int]", variable has type "typing.IsSub[T, str]") + + +[builtins fixtures/typelevel.pyi] +[typing fixtures/typing-full.pyi] + +[case testTypeOperatorSubType1] +# flags: --python-version 3.14 + +from typing import Callable, IsSub, Literal + +class A: + def process[T](self, x: IsSub[T, str], y: T) -> None: + ... + + +class B(A): + pass + + +class C(A): + def process[T](self, x: IsSub[T, str], y: T) -> None: + ... + +class D(A): + def process[T](self, x: IsSub[T, int], y: T) -> None: # E: Argument 1 of "process" is incompatible with supertype "A"; supertype defines the argument type as "typing.IsSub[T, str]" \ + # N: This violates the Liskov substitution principle \ + # N: See https://mypy.readthedocs.io/en/stable/common_issues.html#incompatible-overrides + ... + + +class E(A): + def process[T](self, x: str, y: T) -> None: # E: Argument 1 of "process" is incompatible with supertype "A"; supertype defines the argument type as "typing.IsSub[T, str]" \ + # N: This violates the Liskov substitution principle \ + # N: See https://mypy.readthedocs.io/en/stable/common_issues.html#incompatible-overrides + ... + + +[builtins fixtures/typelevel.pyi] +[typing fixtures/typing-full.pyi] + +[case testTypeOperatorSubType2] +# flags: --python-version 3.14 + +from typing import Callable, IsSub, Literal + +class A: + def process[T](self, y: T) -> IsSub[T, str]: + ... + + +class B(A): + pass + + +class C(A): + def process[T](self, y: T) -> IsSub[T, str]: + ... + + +class D(A): + def process[T](self, y: T) -> IsSub[T, int]: # E: Return type "typing.IsSub[T, int]" of "process" incompatible with return type "typing.IsSub[T, str]" in supertype "A" + ... + + +class E(A): + def process[T](self, y: T) -> str: # E: Return type "str" of "process" incompatible with return type "typing.IsSub[T, str]" in supertype "A" + ... + + +[builtins fixtures/typelevel.pyi] +[typing fixtures/typing-full.pyi] + + +[case testTypeOperatorSubType3] +# flags: --python-version 3.14 + +from typing import Callable, IsSub, Literal, Iter + +class A: + def process[Ts](self, x: Ts) -> tuple[*[t for t in Iter[Ts]]]: + ... + + +class B(A): + pass + + +# alpha varying +class C(A): + def process[Us](self, x: Us) -> tuple[*[s for s in Iter[Us]]]: + ... + + +class D(A): + def process[Ts](self, x: Ts) -> tuple[*[int for s in Iter[Ts]]]: # E: Signature of "process" incompatible with supertype "A" \ + # N: Superclass: \ + # N: def [Ts] process(self, x: Ts) -> tuple[*[t for t in typing.Iter[Ts]]] \ + # N: Subclass: \ + # N: def [Ts] process(self, x: Ts) -> tuple[*[int for s in typing.Iter[Ts]]] + ... + +[builtins fixtures/typelevel.pyi] +[typing fixtures/typing-full.pyi] From ba001167f4704ab460feb903e017e9eed8ac1bf9 Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Mon, 26 Jan 2026 16:47:21 -0800 Subject: [PATCH 040/161] Add fallback attribute to TypeOperatorType Add a fallback: Instance attribute to TypeOperatorType for proper type system integration. Updated serialize/deserialize, write/read, and copy_modified methods to handle the new attribute. Co-Authored-By: Claude Opus 4.5 --- mypy/typeanal.py | 3 ++- mypy/types.py | 22 +++++++++++++++++----- 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/mypy/typeanal.py b/mypy/typeanal.py index 1dfc121c5a83c..0e2290bd0f60d 100644 --- a/mypy/typeanal.py +++ b/mypy/typeanal.py @@ -1130,7 +1130,8 @@ def analyze_type_operator(self, t: UnboundType, type_info: TypeInfo) -> Type: allow_unpack=True, ) - return TypeOperatorType(type_info, an_args, t.line, t.column) + fallback = self.named_type("builtins.object") + return TypeOperatorType(type_info, an_args, fallback, t.line, t.column) def visit_type_operator_type(self, t: TypeOperatorType) -> Type: return t.copy_modified(args=self.anal_array(t.args, allow_unpack=True)) diff --git a/mypy/types.py b/mypy/types.py index dddad846b86ff..2a5d00125e7dc 100644 --- a/mypy/types.py +++ b/mypy/types.py @@ -510,18 +510,20 @@ class TypeOperatorType(ComputedType): Type operators are generic classes in typeshed marked with @_type_operator. """ - __slots__ = ("type", "args", "type_ref") + __slots__ = ("type", "args", "type_ref", "fallback") def __init__( self, type: mypy.nodes.TypeInfo | None, # The TypeInfo for the operator (e.g., typing.GetArg) args: list[Type], # The type arguments + fallback: Instance, line: int = -1, column: int = -1, ) -> None: super().__init__(line, column) self.type = type self.args = args + self.fallback = fallback self.type_ref: str | None = None # XXX? def accept(self, visitor: TypeVisitor[T]) -> T: @@ -556,6 +558,7 @@ def serialize(self) -> JsonDict: ".class": "TypeOperatorType", "type_ref": self.fullname, "args": [arg.serialize() for arg in self.args], + "fallback": self.fallback.serialize(), } return data @@ -567,25 +570,34 @@ def deserialize(cls, data: JsonDict) -> TypeOperatorType: args_list = data["args"] assert isinstance(args_list, list) args = [deserialize_type(arg) for arg in args_list] - typ = TypeOperatorType(None, args) + fallback = Instance.deserialize(data["fallback"]) + typ = TypeOperatorType(None, args, fallback) typ.type_ref = data["type_ref"] return typ def copy_modified(self, *, args: list[Type] | None = None) -> TypeOperatorType: return TypeOperatorType( - self.type, args if args is not None else self.args.copy(), self.line, self.column + self.type, + args if args is not None else self.args.copy(), + self.fallback, + self.line, + self.column, ) def write(self, data: WriteBuffer) -> None: write_tag(data, TYPE_OPERATOR_TYPE) write_type_list(data, self.args) write_str(data, self.fullname) + self.fallback.write(data) write_tag(data, END_TAG) @classmethod def read(cls, data: ReadBuffer) -> TypeOperatorType: - typ = TypeOperatorType(None, read_type_list(data)) - typ.type_ref = read_str(data) + args = read_type_list(data) + type_ref = read_str(data) + fallback = Instance.read(data) + typ = TypeOperatorType(None, args, fallback) + typ.type_ref = type_ref assert read_tag(data) == END_TAG return typ From 9be682188902fd4ecad07bb8da5504e459a7f534 Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Wed, 28 Jan 2026 10:55:11 -0800 Subject: [PATCH 041/161] Fix TypeOperatorType cache read/write mismatch Instance.write() writes an INSTANCE tag followed by an inner tag, but Instance.read() expects the outer INSTANCE tag to already be consumed. TypeOperatorType.read() was calling Instance.read() directly without first reading the INSTANCE tag, causing deserialization failures. Co-Authored-By: Claude Opus 4.5 --- mypy/types.py | 1 + 1 file changed, 1 insertion(+) diff --git a/mypy/types.py b/mypy/types.py index 2a5d00125e7dc..c673aeae216f8 100644 --- a/mypy/types.py +++ b/mypy/types.py @@ -595,6 +595,7 @@ def write(self, data: WriteBuffer) -> None: def read(cls, data: ReadBuffer) -> TypeOperatorType: args = read_type_list(data) type_ref = read_str(data) + assert read_tag(data) == INSTANCE fallback = Instance.read(data) typ = TypeOperatorType(None, args, fallback) typ.type_ref = type_ref From 2d6be57291e3b5d898fd73683676e2cb4b5c29b9 Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Mon, 26 Jan 2026 16:59:30 -0800 Subject: [PATCH 042/161] Crappy meet and join implementations --- mypy/join.py | 10 ++++-- mypy/meet.py | 8 +++-- mypy/typeanal.py | 1 + test-data/unit/check-typelevel-basic.test | 44 +++++++++++++++++++++++ 4 files changed, 59 insertions(+), 4 deletions(-) diff --git a/mypy/join.py b/mypy/join.py index a7c6776872637..92ccec9fb00bf 100644 --- a/mypy/join.py +++ b/mypy/join.py @@ -668,10 +668,16 @@ def visit_type_alias_type(self, t: TypeAliasType) -> ProperType: assert False, f"This should be never called, got {t}" def visit_type_operator_type(self, t: TypeOperatorType) -> ProperType: - assert False, f"Computed types should be expanded before join, got {t}" + # TODO: This seems very unsatisfactory. Can we do better ever? + # (Do we need to do some self check also??) + if isinstance(self.s, TypeOperatorType): + return join_types(self.s.fallback, t.fallback) + else: + return join_types(self.s, t.fallback) def visit_type_for_comprehension(self, t: TypeForComprehension) -> ProperType: - assert False, f"Computed types should be expanded before join, got {t}" + # TODO: XXX: This is pretty dodgy + return UnpackType(AnyType(TypeOfAny.special_form)) def default(self, typ: Type) -> ProperType: typ = get_proper_type(typ) diff --git a/mypy/meet.py b/mypy/meet.py index 056fbcd03a2c7..f5a4235bd2c03 100644 --- a/mypy/meet.py +++ b/mypy/meet.py @@ -1138,10 +1138,14 @@ def visit_type_alias_type(self, t: TypeAliasType) -> ProperType: assert False, f"This should be never called, got {t}" def visit_type_operator_type(self, t: TypeOperatorType) -> ProperType: - assert False, f"Computed types should be expanded before meet, got {t}" + # TODO: This seems very unsatisfactory. Can we do better ever? + # (Do we need to do some self check also??) + return self.default(t) def visit_type_for_comprehension(self, t: TypeForComprehension) -> ProperType: - assert False, f"Computed types should be expanded before meet, got {t}" + # TODO: This seems very unsatisfactory. Can we do better ever? + # (Do we need to do some self check also??) + return self.default(t) def meet(self, s: Type, t: Type) -> ProperType: return meet_types(s, t) diff --git a/mypy/typeanal.py b/mypy/typeanal.py index 0e2290bd0f60d..91e6f95e0cc7f 100644 --- a/mypy/typeanal.py +++ b/mypy/typeanal.py @@ -1130,6 +1130,7 @@ def analyze_type_operator(self, t: UnboundType, type_info: TypeInfo) -> Type: allow_unpack=True, ) + # TODO: different fallbacks for different types fallback = self.named_type("builtins.object") return TypeOperatorType(type_info, an_args, fallback, t.line, t.column) diff --git a/test-data/unit/check-typelevel-basic.test b/test-data/unit/check-typelevel-basic.test index a37e479abbbdb..69b0fa2327d54 100644 --- a/test-data/unit/check-typelevel-basic.test +++ b/test-data/unit/check-typelevel-basic.test @@ -538,3 +538,47 @@ class D(A): [builtins fixtures/typelevel.pyi] [typing fixtures/typing-full.pyi] + + +[case testTypeOperatorJoin1] +# flags: --python-version 3.14 + +from typing import Callable, IsSub + + +def f0[T](x: IsSub[T, str], y: IsSub[T, str]) -> None: + z = [x, y] + reveal_type(z) # N: Revealed type is "builtins.list[typing.IsSub[T`-1, builtins.str]]" + +# # XXX: has another error +# def f1[T](x: IsSub[T, str], y: IsSub[T, str] | None) -> None: +# z = [x, y] +# reveal_type(z) + +def g[T](x: IsSub[T, str], y: IsSub[T, int]) -> None: + z = [x, y] + reveal_type(z) # N: Revealed type is "builtins.list[builtins.object]" + + +[builtins fixtures/typelevel.pyi] +[typing fixtures/typing-full.pyi] + + +[case testTypeOperatorMeet1] +# flags: --python-version 3.14 + +from typing import Callable, IsSub + +def g[T]( + x: Callable[[IsSub[T, str]], None], + x0: Callable[[IsSub[T, str]], None], + y: Callable[[IsSub[T, int]], None], +) -> None: + z = [x, y] + reveal_type(z) # N: Revealed type is "builtins.list[builtins.function]" + + w = [x, x0] + reveal_type(w) # N: Revealed type is "builtins.list[def (typing.IsSub[T`-1, builtins.str])]" + +[builtins fixtures/typelevel.pyi] +[typing fixtures/typing-full.pyi] From 8a92996fd5f1498dc10b4257111534a2343bbb7b Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Mon, 26 Jan 2026 17:04:36 -0800 Subject: [PATCH 043/161] Use fallback in subtyping --- mypy/join.py | 1 + mypy/meet.py | 2 ++ mypy/subtypes.py | 6 ++++-- test-data/unit/check-typelevel-basic.test | 7 +++---- 4 files changed, 10 insertions(+), 6 deletions(-) diff --git a/mypy/join.py b/mypy/join.py index 92ccec9fb00bf..53052036d8765 100644 --- a/mypy/join.py +++ b/mypy/join.py @@ -670,6 +670,7 @@ def visit_type_alias_type(self, t: TypeAliasType) -> ProperType: def visit_type_operator_type(self, t: TypeOperatorType) -> ProperType: # TODO: This seems very unsatisfactory. Can we do better ever? # (Do we need to do some self check also??) + # We could do union, maybe? if isinstance(self.s, TypeOperatorType): return join_types(self.s.fallback, t.fallback) else: diff --git a/mypy/meet.py b/mypy/meet.py index f5a4235bd2c03..c77fd095aec3d 100644 --- a/mypy/meet.py +++ b/mypy/meet.py @@ -1140,6 +1140,8 @@ def visit_type_alias_type(self, t: TypeAliasType) -> ProperType: def visit_type_operator_type(self, t: TypeOperatorType) -> ProperType: # TODO: This seems very unsatisfactory. Can we do better ever? # (Do we need to do some self check also??) + # + # If we had intersections, we could use those... return self.default(t) def visit_type_for_comprehension(self, t: TypeForComprehension) -> ProperType: diff --git a/mypy/subtypes.py b/mypy/subtypes.py index 74a371e327bfe..aebc8d4ef272a 100644 --- a/mypy/subtypes.py +++ b/mypy/subtypes.py @@ -1166,11 +1166,13 @@ def visit_type_operator_type(self, left: TypeOperatorType) -> bool: # PERF: Using is_same_type can mean exponential time checking... if isinstance(self.right, TypeOperatorType): if left.fullname == self.right.fullname and len(left.args) == len(self.right.args): - return all(is_same_type(la, ra) for la, ra in zip(left.args, self.right.args)) - return False + if all(is_same_type(la, ra) for la, ra in zip(left.args, self.right.args)): + return True + return self._is_subtype(left.fallback, self.right) def visit_type_for_comprehension(self, left: TypeForComprehension) -> bool: # PERF: Using is_same_type can mean exponential time checking... + # TODO: should we match some Unpacks? if isinstance(self.right, TypeForComprehension): if len(left.conditions) != len(self.right.conditions): return False diff --git a/test-data/unit/check-typelevel-basic.test b/test-data/unit/check-typelevel-basic.test index 69b0fa2327d54..5ed049473428c 100644 --- a/test-data/unit/check-typelevel-basic.test +++ b/test-data/unit/check-typelevel-basic.test @@ -550,10 +550,9 @@ def f0[T](x: IsSub[T, str], y: IsSub[T, str]) -> None: z = [x, y] reveal_type(z) # N: Revealed type is "builtins.list[typing.IsSub[T`-1, builtins.str]]" -# # XXX: has another error -# def f1[T](x: IsSub[T, str], y: IsSub[T, str] | None) -> None: -# z = [x, y] -# reveal_type(z) +def f1[T](x: IsSub[T, str], y: IsSub[T, str] | None) -> None: + z = [x, y] + reveal_type(z) # N: Revealed type is "builtins.list[typing.IsSub[T`-1, builtins.str] | None]" def g[T](x: IsSub[T, str], y: IsSub[T, int]) -> None: z = [x, y] From a6a08d28a64f53f07555f88c3c6ba0c4ef342c55 Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Tue, 27 Jan 2026 13:04:46 -0800 Subject: [PATCH 044/161] Evaluated ComputedTypes in Instance arguments too --- mypy/types.py | 55 ++++++++++++++++------- test-data/unit/check-typelevel-basic.test | 17 +++++++ 2 files changed, 56 insertions(+), 16 deletions(-) diff --git a/mypy/types.py b/mypy/types.py index c673aeae216f8..7060d8896a162 100644 --- a/mypy/types.py +++ b/mypy/types.py @@ -3915,6 +3915,35 @@ def get_proper_type_simple(typ: Type | None) -> ProperType | None: return cast(ProperType, typ) +def _expand_type_fors_in_args(typ: ProperType) -> ProperType: + """ + Expand any TypeForComprehensions in type arguments. + """ + # TODO: lots of perf optimizations available + # XXX: Callable + + # also I'm really not sure about this at all! + # this is a lot of work to be doing in get_proper_type + from mypy.expandtype import expand_type + + typ2: ProperType + + if isinstance(typ, TupleType) and any( + isinstance(st, TypeForComprehension) for st in typ.items + ): + typ2 = typ.copy_modified(items=[get_proper_type(st) for st in typ.items]) + # expanding the types might produce Unpacks, which we use + # expand_type to substitute in. + typ = expand_type(typ2, {}) + elif isinstance(typ, Instance) and any( + isinstance(st, TypeForComprehension) for st in typ.args + ): + typ2 = typ.copy_modified(args=[get_proper_type(st) for st in typ.args]) + typ = expand_type(typ2, {}) + + return typ + + @overload def get_proper_type(typ: None) -> None: ... @@ -3934,15 +3963,12 @@ def get_proper_type(typ: Type | None) -> ProperType | None: This also *attempts* to expand computed types, though it might fail. """ - from mypy.expandtype import expand_type - if typ is None: return None # TODO: this is an ugly hack, remove. if isinstance(typ, TypeGuardedType): typ = typ.type_guard - trouble = False while True: if isinstance(typ, TypeAliasType): typ = typ._expand_once() @@ -3952,23 +3978,16 @@ def get_proper_type(typ: Type | None) -> ProperType | None: typ = ntyp else: break - elif ( - isinstance(typ, ProperType) - and isinstance(typ, TupleType) - and any(isinstance(st, ComputedType) for st in typ.items) - and not trouble - ): - # XXX: need to cover Instance, Callable - # also I'm really not sure about this at all! - # this is a lot of work to be doing in get_proper_type - typ2 = typ.copy_modified(items=[get_proper_type(st) for st in typ.items]) - typ = expand_type(typ2, {}) - trouble = True else: break + + typ = cast(ProperType, typ) + + typ = _expand_type_fors_in_args(typ) + # TODO: store the name of original type alias on this type, so we can show it in errors. - return cast(ProperType, typ) + return typ @overload @@ -4084,6 +4103,10 @@ def visit_deleted_type(self, t: DeletedType, /) -> str: return f"" def visit_instance(self, t: Instance, /) -> str: + if self.expand: + if (nt := try_expand_or_none(t)) and nt != t: + return nt.accept(self) + fullname = t.type.fullname if not self.options.reveal_verbose_types and fullname.startswith("builtins."): fullname = t.type.name diff --git a/test-data/unit/check-typelevel-basic.test b/test-data/unit/check-typelevel-basic.test index 5ed049473428c..31efbd5e7004f 100644 --- a/test-data/unit/check-typelevel-basic.test +++ b/test-data/unit/check-typelevel-basic.test @@ -349,6 +349,23 @@ reveal_type(args) # N: Revealed type is "tuple[builtins.int, builtins.str]" [builtins fixtures/typelevel.pyi] [typing fixtures/typing-full.pyi] +[case testTypeComprehensionBasicTy] +# flags: --python-version 3.14 +# Test basic type comprehension +from typing import IsSub, Iter + +class A[*T]: + pass + +# Basic comprehension - maps over tuple elements +def f(x: A[*[T for T in Iter[tuple[int, str, float]]]]) -> None: + pass + +reveal_type(f) # N: Revealed type is "def (x: __main__.A[builtins.int, builtins.str, builtins.float])" + +[builtins fixtures/typelevel.pyi] +[typing fixtures/typing-full.pyi] + [case testTypeComprehensionBasic] # Test basic type comprehension from typing import IsSub, Iter From 14de29bb43a043b1f19d7ed1851be73401468ef7 Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Tue, 27 Jan 2026 13:35:35 -0800 Subject: [PATCH 045/161] Drop some stuff from the plan --- TYPEMAP_IMPLEMENTATION_PLAN.md | 33 ++------------------------------- 1 file changed, 2 insertions(+), 31 deletions(-) diff --git a/TYPEMAP_IMPLEMENTATION_PLAN.md b/TYPEMAP_IMPLEMENTATION_PLAN.md index 0a0fff13dce24..2c9175f3c92b5 100644 --- a/TYPEMAP_IMPLEMENTATION_PLAN.md +++ b/TYPEMAP_IMPLEMENTATION_PLAN.md @@ -1,6 +1,7 @@ # Implementation Plan: Type-Level Computation for Mypy -This document outlines a plan for implementing the type-level computation proposal described in `../typemap/pre-pep.rst` and `../typemap/spec-draft.rst`. +This document outlines a plan for implementing the type-level +computation proposal described in `../typemap/pre-pep.rst`. ## Overview @@ -1252,36 +1253,6 @@ def evaluate_comprehension(typ: TypeForComprehension) -> Type: ## Phase 4: Integration Points -### 4.2 Integrate with `expand_type()` (`mypy/expandtype.py`) - -I THINK THIS IS NOT NEEDED - -Extend `ExpandTypeVisitor` to handle new types: - -```python -class ExpandTypeVisitor(TypeTransformVisitor): - # ... existing methods ... - - def visit_type_operator_type(self, t: TypeOperatorType) -> Type: - # Expand type args, including _Cond which has condition, true_type, false_type as args - return t.copy_modified(args=[arg.accept(self) for arg in t.args]) - - def visit_type_for_comprehension(self, t: TypeForComprehension) -> Type: - # Don't substitute the iteration variable - return TypeForComprehension( - t.element_expr.accept(self), - t.iter_var, - t.iter_type.accept(self), - [c.accept(self) for c in t.conditions], - ) - - # ... more visit methods for other new types ... -``` - -Note: Conditional types are now `_Cond[...]` TypeOperatorType, so they are handled by -`visit_type_operator_type` along with all other type operators. - - ### 4.4 Type Inference with `**kwargs` TypeVar Handle `Unpack[K]` where K is bounded by TypedDict: From bed697794f8327d7add5f7096ceabf78122f61dd Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Tue, 27 Jan 2026 13:54:22 -0800 Subject: [PATCH 046/161] Implement Members and Attrs type operators Add Members[T] and Attrs[T] type operators that return a tuple of Member types representing class members: - Members[T]: Returns all public members (methods, class vars, attrs) - Attrs[T]: Returns only instance attributes (excludes methods, ClassVar) Each member is represented as Member[name, typ, quals, init, definer] where quals is ClassVar/Final/Never and init is currently always Never. Type variables are properly expanded for generic classes using expand_type_by_instance. Co-Authored-By: Claude Opus 4.5 --- mypy/typelevel.py | 124 +++++++++++++++++++++- test-data/unit/check-typelevel-basic.test | 88 +++++++++++++++ test-data/unit/fixtures/typing-full.pyi | 23 ++++ 3 files changed, 234 insertions(+), 1 deletion(-) diff --git a/mypy/typelevel.py b/mypy/typelevel.py index ac15982335ced..9468c98dbe00c 100644 --- a/mypy/typelevel.py +++ b/mypy/typelevel.py @@ -14,7 +14,7 @@ from contextlib import contextmanager from typing import TYPE_CHECKING, Final -from mypy.expandtype import expand_type +from mypy.expandtype import expand_type, expand_type_by_instance from mypy.maptype import map_instance_to_supertype from mypy.subtypes import is_subtype from mypy.types import ( @@ -498,6 +498,128 @@ def _eval_uncapitalize(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> return UninhabitedType() +# --- Phase 3B: Object Introspection Operators --- + + +@register_operator("typing.Members") +@lift_over_unions +def _eval_members(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: + """Evaluate Members[T] -> tuple of Member types for all members of T. + + Includes methods, class variables, and instance attributes. + """ + return _eval_members_impl(evaluator, typ, attrs_only=False) + + +@register_operator("typing.Attrs") +@lift_over_unions +def _eval_attrs(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: + """Evaluate Attrs[T] -> tuple of Member types for annotated instance attributes only. + + Excludes methods and ClassVar members. + """ + return _eval_members_impl(evaluator, typ, attrs_only=True) + + +def _eval_members_impl( + evaluator: TypeLevelEvaluator, typ: TypeOperatorType, *, attrs_only: bool +) -> Type: + """Common implementation for Members and Attrs operators. + + Args: + attrs_only: If True, filter to instance attributes only (excludes methods + and ClassVar members). If False, include all members. + """ + from mypy.nodes import Var + from mypy.types import CallableType + + if len(typ.args) != 1: + return UninhabitedType() + + target = evaluator.eval_proper(typ.args[0]) + + if not isinstance(target, Instance): + return UninhabitedType() + + # Get the Member TypeInfo + member_info = evaluator.api.named_type_or_none("typing.Member") + if member_info is None: + return UninhabitedType() + + members: list[Type] = [] + for name, sym in target.type.names.items(): + # Skip private/dunder names + if name.startswith("_"): + continue + + if sym.type is None: + continue + + if attrs_only: + # Attrs filters to instance attributes only: + # - Skip ClassVar members + # - Skip methods (CallableType that are not property types) + node = sym.node + if isinstance(node, Var) and node.is_classvar: + continue + if isinstance(get_proper_type(sym.type), CallableType): + continue + + # Expand the member type to substitute type variables with actual args + member_typ = expand_type_by_instance(sym.type, target) + + member_type = create_member_type( + evaluator, + member_info.type, + name=name, + typ=member_typ, + node=sym.node, + definer=target, + ) + members.append(member_type) + + return evaluator.tuple_type(members) + + +def create_member_type( + evaluator: TypeLevelEvaluator, + member_type_info: TypeInfo, + name: str, + typ: Type, + node: object, + definer: Instance, +) -> Instance: + """Create a Member[name, typ, quals, init, definer] instance type.""" + from mypy.nodes import Var + + # Determine qualifiers + quals: Type + if isinstance(node, Var): + if node.is_classvar: + quals = evaluator.literal_str("ClassVar") + elif node.is_final: + quals = evaluator.literal_str("Final") + else: + quals = UninhabitedType() # Never = no qualifiers + else: + quals = UninhabitedType() + + # For init, we currently don't track initializer literal types + # This would require changes to semantic analysis + init: Type = UninhabitedType() + + return Instance( + member_type_info, + [ + evaluator.literal_str(name), # name + typ, # typ + quals, # quals + init, # init + definer, # definer + ], + ) + + # --- Phase 3B: Utility Operators --- diff --git a/test-data/unit/check-typelevel-basic.test b/test-data/unit/check-typelevel-basic.test index 31efbd5e7004f..dfac52a167cdc 100644 --- a/test-data/unit/check-typelevel-basic.test +++ b/test-data/unit/check-typelevel-basic.test @@ -598,3 +598,91 @@ def g[T]( [builtins fixtures/typelevel.pyi] [typing fixtures/typing-full.pyi] + + +[case testTypeOperatorMembersBasic] +# Test Members operator - basic class +from typing import Members, Member, Literal + +class Point: + x: int + y: str + +m: Members[Point] +reveal_type(m) # N: Revealed type is "tuple[typing.Member[Literal['x'], builtins.int, Never, Never, __main__.Point], typing.Member[Literal['y'], builtins.str, Never, Never, __main__.Point]]" + +[builtins fixtures/typelevel.pyi] +[typing fixtures/typing-full.pyi] + +[case testTypeOperatorMembersWithClassVar] +# Test Members operator with ClassVar +from typing import Members, Member, ClassVar, Literal + +class Config: + name: str + count: ClassVar[int] + +m: Members[Config] +reveal_type(m) # N: Revealed type is "tuple[typing.Member[Literal['name'], builtins.str, Never, Never, __main__.Config], typing.Member[Literal['count'], builtins.int, Literal['ClassVar'], Never, __main__.Config]]" + +[builtins fixtures/typelevel.pyi] +[typing fixtures/typing-full.pyi] + +[case testTypeOperatorMembersWithFinal] +# Test Members operator with Final +from typing import Members, Member, Final, Literal + +class Constants: + PI: Final[float] = 3.14 + NAME: str + +m: Members[Constants] +reveal_type(m) # N: Revealed type is "tuple[typing.Member[Literal['PI'], builtins.float, Literal['Final'], Never, __main__.Constants], typing.Member[Literal['NAME'], builtins.str, Never, Never, __main__.Constants]]" + +[builtins fixtures/typelevel.pyi] +[typing fixtures/typing-full.pyi] + +[case testTypeOperatorAttrsBasic] +# Test Attrs operator - filters to instance attributes only +from typing import Attrs, Member, ClassVar, Literal + +class MyClass: + name: str + count: ClassVar[int] + def method(self) -> None: pass + +a: Attrs[MyClass] +# Should only include 'name', excluding ClassVar and methods +reveal_type(a) # N: Revealed type is "tuple[typing.Member[Literal['name'], builtins.str, Never, Never, __main__.MyClass]]" + +[builtins fixtures/typelevel.pyi] +[typing fixtures/typing-full.pyi] + +[case testTypeOperatorMembersEmpty] +# Test Members operator with no public members +from typing import Members, Literal + +class Empty: + _private: int + +m: Members[Empty] +reveal_type(m) # N: Revealed type is "tuple[()]" + +[builtins fixtures/typelevel.pyi] +[typing fixtures/typing-full.pyi] + +[case testTypeOperatorMembersGenericClass] +# Test Members operator with generic class +from typing import Members, Member, Generic, TypeVar, Literal + +T = TypeVar('T') + +class Container(Generic[T]): + value: T + label: str + +m: Members[Container[int]] +reveal_type(m) # N: Revealed type is "tuple[typing.Member[Literal['value'], builtins.int, Never, Never, __main__.Container[builtins.int]], typing.Member[Literal['label'], builtins.str, Never, Never, __main__.Container[builtins.int]]]" + +[builtins fixtures/typelevel.pyi] +[typing fixtures/typing-full.pyi] diff --git a/test-data/unit/fixtures/typing-full.pyi b/test-data/unit/fixtures/typing-full.pyi index 8bc8cb546f0cc..3503297bedc6a 100644 --- a/test-data/unit/fixtures/typing-full.pyi +++ b/test-data/unit/fixtures/typing-full.pyi @@ -275,3 +275,26 @@ class Uncapitalize(Generic[T]): ... @_type_operator class Length(Generic[T]): ... + +@_type_operator +class Members(Generic[T]): ... + +@_type_operator +class Attrs(Generic[T]): ... + +# Member data type for type-level computation +_Name = TypeVar('_Name') +_Type = TypeVar('_Type') +_Quals = TypeVar('_Quals') +_Init = TypeVar('_Init') +_Definer = TypeVar('_Definer') + +class Member(Generic[_Name, _Type, _Quals, _Init, _Definer]): + """ + Represents a class member with name, type, qualifiers, initializer, and definer. + """ + name: _Name + typ: _Type + quals: _Quals + init: _Init + definer: _Definer From 9d5690f5c1f8f48c2ddeed75750e06319fcc7aa9 Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Tue, 27 Jan 2026 13:55:33 -0800 Subject: [PATCH 047/161] Move Members/Attrs tests to check-typelevel-members.test Co-Authored-By: Claude Opus 4.5 --- test-data/unit/check-typelevel-basic.test | 88 --------------------- test-data/unit/check-typelevel-members.test | 86 ++++++++++++++++++++ 2 files changed, 86 insertions(+), 88 deletions(-) create mode 100644 test-data/unit/check-typelevel-members.test diff --git a/test-data/unit/check-typelevel-basic.test b/test-data/unit/check-typelevel-basic.test index dfac52a167cdc..31efbd5e7004f 100644 --- a/test-data/unit/check-typelevel-basic.test +++ b/test-data/unit/check-typelevel-basic.test @@ -598,91 +598,3 @@ def g[T]( [builtins fixtures/typelevel.pyi] [typing fixtures/typing-full.pyi] - - -[case testTypeOperatorMembersBasic] -# Test Members operator - basic class -from typing import Members, Member, Literal - -class Point: - x: int - y: str - -m: Members[Point] -reveal_type(m) # N: Revealed type is "tuple[typing.Member[Literal['x'], builtins.int, Never, Never, __main__.Point], typing.Member[Literal['y'], builtins.str, Never, Never, __main__.Point]]" - -[builtins fixtures/typelevel.pyi] -[typing fixtures/typing-full.pyi] - -[case testTypeOperatorMembersWithClassVar] -# Test Members operator with ClassVar -from typing import Members, Member, ClassVar, Literal - -class Config: - name: str - count: ClassVar[int] - -m: Members[Config] -reveal_type(m) # N: Revealed type is "tuple[typing.Member[Literal['name'], builtins.str, Never, Never, __main__.Config], typing.Member[Literal['count'], builtins.int, Literal['ClassVar'], Never, __main__.Config]]" - -[builtins fixtures/typelevel.pyi] -[typing fixtures/typing-full.pyi] - -[case testTypeOperatorMembersWithFinal] -# Test Members operator with Final -from typing import Members, Member, Final, Literal - -class Constants: - PI: Final[float] = 3.14 - NAME: str - -m: Members[Constants] -reveal_type(m) # N: Revealed type is "tuple[typing.Member[Literal['PI'], builtins.float, Literal['Final'], Never, __main__.Constants], typing.Member[Literal['NAME'], builtins.str, Never, Never, __main__.Constants]]" - -[builtins fixtures/typelevel.pyi] -[typing fixtures/typing-full.pyi] - -[case testTypeOperatorAttrsBasic] -# Test Attrs operator - filters to instance attributes only -from typing import Attrs, Member, ClassVar, Literal - -class MyClass: - name: str - count: ClassVar[int] - def method(self) -> None: pass - -a: Attrs[MyClass] -# Should only include 'name', excluding ClassVar and methods -reveal_type(a) # N: Revealed type is "tuple[typing.Member[Literal['name'], builtins.str, Never, Never, __main__.MyClass]]" - -[builtins fixtures/typelevel.pyi] -[typing fixtures/typing-full.pyi] - -[case testTypeOperatorMembersEmpty] -# Test Members operator with no public members -from typing import Members, Literal - -class Empty: - _private: int - -m: Members[Empty] -reveal_type(m) # N: Revealed type is "tuple[()]" - -[builtins fixtures/typelevel.pyi] -[typing fixtures/typing-full.pyi] - -[case testTypeOperatorMembersGenericClass] -# Test Members operator with generic class -from typing import Members, Member, Generic, TypeVar, Literal - -T = TypeVar('T') - -class Container(Generic[T]): - value: T - label: str - -m: Members[Container[int]] -reveal_type(m) # N: Revealed type is "tuple[typing.Member[Literal['value'], builtins.int, Never, Never, __main__.Container[builtins.int]], typing.Member[Literal['label'], builtins.str, Never, Never, __main__.Container[builtins.int]]]" - -[builtins fixtures/typelevel.pyi] -[typing fixtures/typing-full.pyi] diff --git a/test-data/unit/check-typelevel-members.test b/test-data/unit/check-typelevel-members.test new file mode 100644 index 0000000000000..b30a9c1580ec5 --- /dev/null +++ b/test-data/unit/check-typelevel-members.test @@ -0,0 +1,86 @@ +[case testTypeOperatorMembersBasic] +# Test Members operator - basic class +from typing import Members, Member, Literal + +class Point: + x: int + y: str + +m: Members[Point] +reveal_type(m) # N: Revealed type is "tuple[typing.Member[Literal['x'], builtins.int, Never, Never, __main__.Point], typing.Member[Literal['y'], builtins.str, Never, Never, __main__.Point]]" + +[builtins fixtures/typelevel.pyi] +[typing fixtures/typing-full.pyi] + +[case testTypeOperatorMembersWithClassVar] +# Test Members operator with ClassVar +from typing import Members, Member, ClassVar, Literal + +class Config: + name: str + count: ClassVar[int] + +m: Members[Config] +reveal_type(m) # N: Revealed type is "tuple[typing.Member[Literal['name'], builtins.str, Never, Never, __main__.Config], typing.Member[Literal['count'], builtins.int, Literal['ClassVar'], Never, __main__.Config]]" + +[builtins fixtures/typelevel.pyi] +[typing fixtures/typing-full.pyi] + +[case testTypeOperatorMembersWithFinal] +# Test Members operator with Final +from typing import Members, Member, Final, Literal + +class Constants: + PI: Final[float] = 3.14 + NAME: str + +m: Members[Constants] +reveal_type(m) # N: Revealed type is "tuple[typing.Member[Literal['PI'], builtins.float, Literal['Final'], Never, __main__.Constants], typing.Member[Literal['NAME'], builtins.str, Never, Never, __main__.Constants]]" + +[builtins fixtures/typelevel.pyi] +[typing fixtures/typing-full.pyi] + +[case testTypeOperatorAttrsBasic] +# Test Attrs operator - filters to instance attributes only +from typing import Attrs, Member, ClassVar, Literal + +class MyClass: + name: str + count: ClassVar[int] + def method(self) -> None: pass + +a: Attrs[MyClass] +# Should only include 'name', excluding ClassVar and methods +reveal_type(a) # N: Revealed type is "tuple[typing.Member[Literal['name'], builtins.str, Never, Never, __main__.MyClass]]" + +[builtins fixtures/typelevel.pyi] +[typing fixtures/typing-full.pyi] + +[case testTypeOperatorMembersEmpty] +# Test Members operator with no public members +from typing import Members, Literal + +class Empty: + _private: int + +m: Members[Empty] +reveal_type(m) # N: Revealed type is "tuple[()]" + +[builtins fixtures/typelevel.pyi] +[typing fixtures/typing-full.pyi] + +[case testTypeOperatorMembersGenericClass] +# Test Members operator with generic class +from typing import Members, Member, Generic, TypeVar, Literal + +T = TypeVar('T') + +class Container(Generic[T]): + value: T + label: str + +m: Members[Container[int]] +reveal_type(m) # N: Revealed type is "tuple[typing.Member[Literal['value'], builtins.int, Never, Never, __main__.Container[builtins.int]], typing.Member[Literal['label'], builtins.str, Never, Never, __main__.Container[builtins.int]]]" + +[builtins fixtures/typelevel.pyi] +[typing fixtures/typing-full.pyi] From 4fb25da586d725aa0aeb1038435c6f90f05b0d49 Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Tue, 27 Jan 2026 14:04:27 -0800 Subject: [PATCH 048/161] Include ClassVars in Attrs operator output Attrs now returns all attributes (including ClassVar), only excluding methods. This is more useful for type-level computation where you want to introspect all data attributes of a class. Co-Authored-By: Claude Opus 4.5 --- mypy/typelevel.py | 16 +++++----------- test-data/unit/check-typelevel-members.test | 6 +++--- 2 files changed, 8 insertions(+), 14 deletions(-) diff --git a/mypy/typelevel.py b/mypy/typelevel.py index 9468c98dbe00c..53592e289a07f 100644 --- a/mypy/typelevel.py +++ b/mypy/typelevel.py @@ -514,9 +514,9 @@ def _eval_members(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: @register_operator("typing.Attrs") @lift_over_unions def _eval_attrs(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: - """Evaluate Attrs[T] -> tuple of Member types for annotated instance attributes only. + """Evaluate Attrs[T] -> tuple of Member types for annotated attributes only. - Excludes methods and ClassVar members. + Excludes methods but includes ClassVar members. """ return _eval_members_impl(evaluator, typ, attrs_only=True) @@ -527,10 +527,9 @@ def _eval_members_impl( """Common implementation for Members and Attrs operators. Args: - attrs_only: If True, filter to instance attributes only (excludes methods - and ClassVar members). If False, include all members. + attrs_only: If True, filter to attributes only (excludes methods). + If False, include all members. """ - from mypy.nodes import Var from mypy.types import CallableType if len(typ.args) != 1: @@ -556,12 +555,7 @@ def _eval_members_impl( continue if attrs_only: - # Attrs filters to instance attributes only: - # - Skip ClassVar members - # - Skip methods (CallableType that are not property types) - node = sym.node - if isinstance(node, Var) and node.is_classvar: - continue + # Attrs filters to attributes only (excludes methods) if isinstance(get_proper_type(sym.type), CallableType): continue diff --git a/test-data/unit/check-typelevel-members.test b/test-data/unit/check-typelevel-members.test index b30a9c1580ec5..2ddd7d55da658 100644 --- a/test-data/unit/check-typelevel-members.test +++ b/test-data/unit/check-typelevel-members.test @@ -41,7 +41,7 @@ reveal_type(m) # N: Revealed type is "tuple[typing.Member[Literal['PI'], builti [typing fixtures/typing-full.pyi] [case testTypeOperatorAttrsBasic] -# Test Attrs operator - filters to instance attributes only +# Test Attrs operator - filters to attributes only (excludes methods) from typing import Attrs, Member, ClassVar, Literal class MyClass: @@ -50,8 +50,8 @@ class MyClass: def method(self) -> None: pass a: Attrs[MyClass] -# Should only include 'name', excluding ClassVar and methods -reveal_type(a) # N: Revealed type is "tuple[typing.Member[Literal['name'], builtins.str, Never, Never, __main__.MyClass]]" +# Should include 'name' and 'count', but exclude methods +reveal_type(a) # N: Revealed type is "tuple[typing.Member[Literal['name'], builtins.str, Never, Never, __main__.MyClass], typing.Member[Literal['count'], builtins.int, Literal['ClassVar'], Never, __main__.MyClass]]" [builtins fixtures/typelevel.pyi] [typing fixtures/typing-full.pyi] From 1fdab4341c46704e0ccb8003ed933772a222f3f0 Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Tue, 27 Jan 2026 14:07:57 -0800 Subject: [PATCH 049/161] Fix Attrs to include Callable-typed attributes Attrs should only exclude actual methods (FuncDef nodes), not attributes that have a Callable type. Check the node type instead of the type itself. Co-Authored-By: Claude Opus 4.5 --- mypy/typelevel.py | 7 ++++--- test-data/unit/check-typelevel-members.test | 14 ++++++++++++++ 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/mypy/typelevel.py b/mypy/typelevel.py index 53592e289a07f..5971ed4bbf343 100644 --- a/mypy/typelevel.py +++ b/mypy/typelevel.py @@ -530,7 +530,7 @@ def _eval_members_impl( attrs_only: If True, filter to attributes only (excludes methods). If False, include all members. """ - from mypy.types import CallableType + from mypy.nodes import FuncDef if len(typ.args) != 1: return UninhabitedType() @@ -555,8 +555,9 @@ def _eval_members_impl( continue if attrs_only: - # Attrs filters to attributes only (excludes methods) - if isinstance(get_proper_type(sym.type), CallableType): + # Attrs filters to attributes only (excludes methods). + # Methods are FuncDef nodes; Callable-typed attributes are Var nodes. + if isinstance(sym.node, FuncDef): continue # Expand the member type to substitute type variables with actual args diff --git a/test-data/unit/check-typelevel-members.test b/test-data/unit/check-typelevel-members.test index 2ddd7d55da658..249a0414b0e85 100644 --- a/test-data/unit/check-typelevel-members.test +++ b/test-data/unit/check-typelevel-members.test @@ -56,6 +56,20 @@ reveal_type(a) # N: Revealed type is "tuple[typing.Member[Literal['name'], buil [builtins fixtures/typelevel.pyi] [typing fixtures/typing-full.pyi] +[case testTypeOperatorAttrsCallable] +# Test Attrs operator - filters to instance attributes only +from typing import Attrs, Member, Callable, ClassVar, Literal + +class MyClass: + f: Callable[[int], int] + +a: Attrs[MyClass] +reveal_type(a) # N: Revealed type is "tuple[typing.Member[Literal['f'], def (builtins.int) -> builtins.int, Never, Never, __main__.MyClass]]" + +[builtins fixtures/typelevel.pyi] +[typing fixtures/typing-full.pyi] + + [case testTypeOperatorMembersEmpty] # Test Members operator with no public members from typing import Members, Literal From a8e7177932938f5bbe537da6570de005eb13cc13 Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Tue, 27 Jan 2026 14:09:51 -0800 Subject: [PATCH 050/161] Move function-level imports to module top level Co-Authored-By: Claude Opus 4.5 --- mypy/typelevel.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/mypy/typelevel.py b/mypy/typelevel.py index 5971ed4bbf343..74c0e1343d669 100644 --- a/mypy/typelevel.py +++ b/mypy/typelevel.py @@ -37,6 +37,8 @@ is_stuck_expansion, ) +from mypy.nodes import FuncDef, Var + if TYPE_CHECKING: from mypy.nodes import TypeInfo from mypy.semanal_shared import SemanticAnalyzerInterface @@ -530,8 +532,6 @@ def _eval_members_impl( attrs_only: If True, filter to attributes only (excludes methods). If False, include all members. """ - from mypy.nodes import FuncDef - if len(typ.args) != 1: return UninhabitedType() @@ -585,8 +585,6 @@ def create_member_type( definer: Instance, ) -> Instance: """Create a Member[name, typ, quals, init, definer] instance type.""" - from mypy.nodes import Var - # Determine qualifiers quals: Type if isinstance(node, Var): From ac45a50de88a6b94675880da648b7d82cf015985 Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Tue, 27 Jan 2026 14:14:30 -0800 Subject: [PATCH 051/161] Report methods with ClassVar qualifier in Members Methods are class-level attributes, so they should have the ClassVar qualifier in their Member representation. Co-Authored-By: Claude Opus 4.5 --- mypy/typelevel.py | 3 +++ test-data/unit/check-typelevel-members.test | 13 +++++++++++++ 2 files changed, 16 insertions(+) diff --git a/mypy/typelevel.py b/mypy/typelevel.py index 74c0e1343d669..18e8409338cb2 100644 --- a/mypy/typelevel.py +++ b/mypy/typelevel.py @@ -594,6 +594,9 @@ def create_member_type( quals = evaluator.literal_str("Final") else: quals = UninhabitedType() # Never = no qualifiers + elif isinstance(node, FuncDef): + # Methods are class-level, so they have ClassVar qualifier + quals = evaluator.literal_str("ClassVar") else: quals = UninhabitedType() diff --git a/test-data/unit/check-typelevel-members.test b/test-data/unit/check-typelevel-members.test index 249a0414b0e85..ea495cc876965 100644 --- a/test-data/unit/check-typelevel-members.test +++ b/test-data/unit/check-typelevel-members.test @@ -96,5 +96,18 @@ class Container(Generic[T]): m: Members[Container[int]] reveal_type(m) # N: Revealed type is "tuple[typing.Member[Literal['value'], builtins.int, Never, Never, __main__.Container[builtins.int]], typing.Member[Literal['label'], builtins.str, Never, Never, __main__.Container[builtins.int]]]" +[builtins fixtures/typelevel.pyi] +[typing fixtures/typing-full.pyi] + +[case testTypeOperatorMembersMethod] +from typing import Members, Member, ClassVar, Literal + +class MyClass: + def method(self) -> None: pass + +a: Members[MyClass] +reveal_type(a) # N: Revealed type is "tuple[typing.Member[Literal['method'], def (self: __main__.MyClass), Literal['ClassVar'], Never, __main__.MyClass]]" + + [builtins fixtures/typelevel.pyi] [typing fixtures/typing-full.pyi] From 5a31b896e2e7eb80edf66c233002d3e6dff62c1f Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Tue, 27 Jan 2026 14:17:23 -0800 Subject: [PATCH 052/161] Remove unused imports in check-typelevel-members.test Co-Authored-By: Claude Opus 4.5 --- test-data/unit/check-typelevel-members.test | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/test-data/unit/check-typelevel-members.test b/test-data/unit/check-typelevel-members.test index ea495cc876965..0aaa26f5d226f 100644 --- a/test-data/unit/check-typelevel-members.test +++ b/test-data/unit/check-typelevel-members.test @@ -1,6 +1,6 @@ [case testTypeOperatorMembersBasic] # Test Members operator - basic class -from typing import Members, Member, Literal +from typing import Members class Point: x: int @@ -14,7 +14,7 @@ reveal_type(m) # N: Revealed type is "tuple[typing.Member[Literal['x'], builtin [case testTypeOperatorMembersWithClassVar] # Test Members operator with ClassVar -from typing import Members, Member, ClassVar, Literal +from typing import Members, ClassVar class Config: name: str @@ -28,7 +28,7 @@ reveal_type(m) # N: Revealed type is "tuple[typing.Member[Literal['name'], buil [case testTypeOperatorMembersWithFinal] # Test Members operator with Final -from typing import Members, Member, Final, Literal +from typing import Members, Final class Constants: PI: Final[float] = 3.14 @@ -42,7 +42,7 @@ reveal_type(m) # N: Revealed type is "tuple[typing.Member[Literal['PI'], builti [case testTypeOperatorAttrsBasic] # Test Attrs operator - filters to attributes only (excludes methods) -from typing import Attrs, Member, ClassVar, Literal +from typing import Attrs, ClassVar class MyClass: name: str @@ -57,8 +57,8 @@ reveal_type(a) # N: Revealed type is "tuple[typing.Member[Literal['name'], buil [typing fixtures/typing-full.pyi] [case testTypeOperatorAttrsCallable] -# Test Attrs operator - filters to instance attributes only -from typing import Attrs, Member, Callable, ClassVar, Literal +# Test Attrs operator - includes Callable-typed attributes +from typing import Attrs, Callable class MyClass: f: Callable[[int], int] @@ -69,10 +69,9 @@ reveal_type(a) # N: Revealed type is "tuple[typing.Member[Literal['f'], def (bu [builtins fixtures/typelevel.pyi] [typing fixtures/typing-full.pyi] - [case testTypeOperatorMembersEmpty] # Test Members operator with no public members -from typing import Members, Literal +from typing import Members class Empty: _private: int @@ -85,7 +84,7 @@ reveal_type(m) # N: Revealed type is "tuple[()]" [case testTypeOperatorMembersGenericClass] # Test Members operator with generic class -from typing import Members, Member, Generic, TypeVar, Literal +from typing import Members, Generic, TypeVar T = TypeVar('T') @@ -100,7 +99,8 @@ reveal_type(m) # N: Revealed type is "tuple[typing.Member[Literal['value'], bui [typing fixtures/typing-full.pyi] [case testTypeOperatorMembersMethod] -from typing import Members, Member, ClassVar, Literal +# Test Members operator includes methods with ClassVar qualifier +from typing import Members class MyClass: def method(self) -> None: pass @@ -108,6 +108,5 @@ class MyClass: a: Members[MyClass] reveal_type(a) # N: Revealed type is "tuple[typing.Member[Literal['method'], def (self: __main__.MyClass), Literal['ClassVar'], Never, __main__.MyClass]]" - [builtins fixtures/typelevel.pyi] [typing fixtures/typing-full.pyi] From d04ab8a2c6df122489f2f1525e2092675741e6c3 Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Tue, 27 Jan 2026 14:27:29 -0800 Subject: [PATCH 053/161] Exclude inferred attributes from Members and Attrs Only include attributes with explicit type annotations. Attributes assigned without annotations (like self.y = 'test' in __init__) are inferred and should not be included in type-level introspection. Co-Authored-By: Claude Opus 4.5 --- mypy/typelevel.py | 4 ++++ test-data/unit/check-typelevel-members.test | 20 ++++++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/mypy/typelevel.py b/mypy/typelevel.py index 18e8409338cb2..36cb76a8ff50f 100644 --- a/mypy/typelevel.py +++ b/mypy/typelevel.py @@ -554,6 +554,10 @@ def _eval_members_impl( if sym.type is None: continue + # Skip inferred attributes (those without explicit type annotations) + if isinstance(sym.node, Var) and sym.node.is_inferred: + continue + if attrs_only: # Attrs filters to attributes only (excludes methods). # Methods are FuncDef nodes; Callable-typed attributes are Var nodes. diff --git a/test-data/unit/check-typelevel-members.test b/test-data/unit/check-typelevel-members.test index 0aaa26f5d226f..c26e12ef4573a 100644 --- a/test-data/unit/check-typelevel-members.test +++ b/test-data/unit/check-typelevel-members.test @@ -110,3 +110,23 @@ reveal_type(a) # N: Revealed type is "tuple[typing.Member[Literal['method'], de [builtins fixtures/typelevel.pyi] [typing fixtures/typing-full.pyi] + + +[case testTypeOperatorMembersNoInferred] +from typing import Attrs, Members + +class MyClass: + x: int + def __init__(self) -> None: + self.x = 0 + self.y = 'test' + +a: Members[MyClass] +reveal_type(a) # N: Revealed type is "tuple[typing.Member[Literal['x'], builtins.int, Never, Never, __main__.MyClass]]" + +b: Attrs[MyClass] +reveal_type(b) # N: Revealed type is "tuple[typing.Member[Literal['x'], builtins.int, Never, Never, __main__.MyClass]]" + + +[builtins fixtures/typelevel.pyi] +[typing fixtures/typing-full.pyi] From d7774d8335d99b0ffd7654c3958c5fd6235f69bb Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Tue, 27 Jan 2026 14:35:18 -0800 Subject: [PATCH 054/161] Include inherited members in Members/Attrs with correct definer Members and Attrs now iterate through the MRO (in reverse order, base classes first) to include inherited members. The definer field is set to the class that actually defined the member, not the class being introspected. Members already seen in derived classes are skipped to handle overrides correctly. Co-Authored-By: Claude Opus 4.5 --- mypy/typelevel.py | 62 ++++++++++++--------- test-data/unit/check-typelevel-members.test | 30 ++++++++++ 2 files changed, 66 insertions(+), 26 deletions(-) diff --git a/mypy/typelevel.py b/mypy/typelevel.py index 36cb76a8ff50f..cf8c1c0af53c3 100644 --- a/mypy/typelevel.py +++ b/mypy/typelevel.py @@ -545,39 +545,49 @@ def _eval_members_impl( if member_info is None: return UninhabitedType() - members: list[Type] = [] - for name, sym in target.type.names.items(): - # Skip private/dunder names - if name.startswith("_"): - continue - - if sym.type is None: - continue + members: dict[str, Type] = {} - # Skip inferred attributes (those without explicit type annotations) - if isinstance(sym.node, Var) and sym.node.is_inferred: + # Iterate through MRO in reverse (base classes first) to include inherited members + for type_info in reversed(target.type.mro): + # Skip builtins.object to avoid noise + if type_info.fullname == "builtins.object": continue - if attrs_only: - # Attrs filters to attributes only (excludes methods). - # Methods are FuncDef nodes; Callable-typed attributes are Var nodes. - if isinstance(sym.node, FuncDef): + for name, sym in type_info.names.items(): + # Skip private/dunder names + if name.startswith("_"): continue - # Expand the member type to substitute type variables with actual args - member_typ = expand_type_by_instance(sym.type, target) + if sym.type is None: + continue - member_type = create_member_type( - evaluator, - member_info.type, - name=name, - typ=member_typ, - node=sym.node, - definer=target, - ) - members.append(member_type) + # Skip inferred attributes (those without explicit type annotations) + if isinstance(sym.node, Var) and sym.node.is_inferred: + continue - return evaluator.tuple_type(members) + if attrs_only: + # Attrs filters to attributes only (excludes methods). + # Methods are FuncDef nodes; Callable-typed attributes are Var nodes. + if isinstance(sym.node, FuncDef): + continue + + # Expand the member type to substitute type variables with actual args + member_typ = expand_type_by_instance(sym.type, target) + + # Create definer instance for the class that defined this member + definer = Instance(type_info, target.args) if type_info.type_vars else Instance(type_info, []) + + member_type = create_member_type( + evaluator, + member_info.type, + name=name, + typ=member_typ, + node=sym.node, + definer=definer, + ) + members[name] = member_type + + return evaluator.tuple_type(list(members.values())) def create_member_type( diff --git a/test-data/unit/check-typelevel-members.test b/test-data/unit/check-typelevel-members.test index c26e12ef4573a..d460172f3efef 100644 --- a/test-data/unit/check-typelevel-members.test +++ b/test-data/unit/check-typelevel-members.test @@ -128,5 +128,35 @@ b: Attrs[MyClass] reveal_type(b) # N: Revealed type is "tuple[typing.Member[Literal['x'], builtins.int, Never, Never, __main__.MyClass]]" +[builtins fixtures/typelevel.pyi] +[typing fixtures/typing-full.pyi] + + +[case testTypeOperatorMembersDefiner] +from typing import Attrs, Members + +class Base: + x: int + + +class Child(Base): + y: str + + +class Child2(Base): + x: bool + y: str + + +a: Members[Child] +reveal_type(a) # N: Revealed type is "tuple[typing.Member[Literal['x'], builtins.int, Never, Never, __main__.Base], typing.Member[Literal['y'], builtins.str, Never, Never, __main__.Child]]" + +b: Attrs[Child] +reveal_type(b) # N: Revealed type is "tuple[typing.Member[Literal['x'], builtins.int, Never, Never, __main__.Base], typing.Member[Literal['y'], builtins.str, Never, Never, __main__.Child]]" + +c: Attrs[Child2] +reveal_type(c) # N: Revealed type is "tuple[typing.Member[Literal['x'], builtins.bool, Never, Never, __main__.Child2], typing.Member[Literal['y'], builtins.str, Never, Never, __main__.Child2]]" + + [builtins fixtures/typelevel.pyi] [typing fixtures/typing-full.pyi] From a9327975aa808ec7cf377e1dbbce8e2516ecb076 Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Tue, 27 Jan 2026 16:38:12 -0800 Subject: [PATCH 055/161] Skip stub-defined types in Members/Attrs operators Instead of only skipping builtins.object, now skip all types that are defined in stub files. This avoids including members from stdlib stubs and typeshed in the introspection results. Added modules attribute to SemanticAnalyzerInterface to enable checking if a TypeInfo's module is a stub file. Co-Authored-By: Claude Opus 4.5 --- mypy/semanal_shared.py | 2 ++ mypy/typelevel.py | 5 +++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/mypy/semanal_shared.py b/mypy/semanal_shared.py index a85d4ed00b5e6..041b234f6be18 100644 --- a/mypy/semanal_shared.py +++ b/mypy/semanal_shared.py @@ -17,6 +17,7 @@ Decorator, Expression, FuncDef, + MypyFile, NameExpr, Node, OverloadedFuncDef, @@ -155,6 +156,7 @@ class SemanticAnalyzerInterface(SemanticAnalyzerCoreInterface): """ tvar_scope: TypeVarLikeScope + modules: dict[str, MypyFile] @abstractmethod def lookup( diff --git a/mypy/typelevel.py b/mypy/typelevel.py index cf8c1c0af53c3..0d17ce1273015 100644 --- a/mypy/typelevel.py +++ b/mypy/typelevel.py @@ -549,8 +549,9 @@ def _eval_members_impl( # Iterate through MRO in reverse (base classes first) to include inherited members for type_info in reversed(target.type.mro): - # Skip builtins.object to avoid noise - if type_info.fullname == "builtins.object": + # Skip types defined in stub files + module = evaluator.api.modules.get(type_info.module_name) + if module is not None and module.is_stub: continue for name, sym in type_info.names.items(): From aff9edc63e458a5b06bcbc91ceb43c042094ae6c Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Tue, 27 Jan 2026 14:53:04 -0800 Subject: [PATCH 056/161] Fix type argument mapping for inherited generic class members Use map_instance_to_supertype to correctly map type arguments when getting members from generic ancestor classes. This ensures both the member type and definer have the correct type arguments substituted. Co-Authored-By: Claude Opus 4.5 --- mypy/typelevel.py | 11 +++++++---- test-data/unit/check-typelevel-members.test | 21 +++++++++++++++++++++ 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/mypy/typelevel.py b/mypy/typelevel.py index 0d17ce1273015..a7ac507b2d630 100644 --- a/mypy/typelevel.py +++ b/mypy/typelevel.py @@ -572,11 +572,14 @@ def _eval_members_impl( if isinstance(sym.node, FuncDef): continue - # Expand the member type to substitute type variables with actual args - member_typ = expand_type_by_instance(sym.type, target) + # Map type_info to get correct type args as seen from target + if type_info == target.type: + definer = target + else: + definer = map_instance_to_supertype(target, type_info) - # Create definer instance for the class that defined this member - definer = Instance(type_info, target.args) if type_info.type_vars else Instance(type_info, []) + # Expand the member type to substitute type variables with actual args + member_typ = expand_type_by_instance(sym.type, definer) member_type = create_member_type( evaluator, diff --git a/test-data/unit/check-typelevel-members.test b/test-data/unit/check-typelevel-members.test index d460172f3efef..5ed0e0b7ad715 100644 --- a/test-data/unit/check-typelevel-members.test +++ b/test-data/unit/check-typelevel-members.test @@ -98,6 +98,27 @@ reveal_type(m) # N: Revealed type is "tuple[typing.Member[Literal['value'], bui [builtins fixtures/typelevel.pyi] [typing fixtures/typing-full.pyi] +[case testTypeOperatorMembersGenericClassSub] +# Test Members operator with generic class +from typing import Members, Generic, TypeVar + +T = TypeVar('T') + +class Container(Generic[T]): + value: T + label: str + + +class Child(Container[int]): + pass + + +m: Members[Child] +reveal_type(m) # N: Revealed type is "tuple[typing.Member[Literal['value'], builtins.int, Never, Never, __main__.Container[builtins.int]], typing.Member[Literal['label'], builtins.str, Never, Never, __main__.Container[builtins.int]]]" + +[builtins fixtures/typelevel.pyi] +[typing fixtures/typing-full.pyi] + [case testTypeOperatorMembersMethod] # Test Members operator includes methods with ClassVar qualifier from typing import Members From ab75dc7263256e2233ed4051eca64e43859c599b Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Tue, 27 Jan 2026 15:14:59 -0800 Subject: [PATCH 057/161] Move the typemap stub decls to _typeshed.typemap --- mypy/typelevel.py | 41 ++-- mypy/types.py | 6 +- mypy/typeshed/stdlib/_typeshed/typemap.pyi | 267 +++++++++++++++++++++ mypy/typeshed/stdlib/typing.pyi | 255 +------------------- 4 files changed, 293 insertions(+), 276 deletions(-) create mode 100644 mypy/typeshed/stdlib/_typeshed/typemap.pyi diff --git a/mypy/typelevel.py b/mypy/typelevel.py index a7ac507b2d630..156150d1be36f 100644 --- a/mypy/typelevel.py +++ b/mypy/typelevel.py @@ -81,7 +81,7 @@ def set_api(self, api: SemanticAnalyzerInterface) -> Iterator[None]: typelevel_ctx: Final = TypeLevelContext() -# Registry mapping operator fullnames to their evaluation functions +# Registry mapping operator names (not full!) to their evaluation functions _OPERATOR_EVALUATORS: dict[str, Callable[[TypeLevelEvaluator, TypeOperatorType], Type]] = {} @@ -89,7 +89,7 @@ def set_api(self, api: SemanticAnalyzerInterface) -> Iterator[None]: def register_operator( - fullname: str, + name: str, ) -> Callable[ [Callable[[TypeLevelEvaluator, TypeOperatorType], Type]], Callable[[TypeLevelEvaluator, TypeOperatorType], Type], @@ -99,7 +99,7 @@ def register_operator( def decorator( func: Callable[[TypeLevelEvaluator, TypeOperatorType], Type], ) -> Callable[[TypeLevelEvaluator, TypeOperatorType], Type]: - _OPERATOR_EVALUATORS[fullname] = func + _OPERATOR_EVALUATORS[name] = func return func return decorator @@ -186,8 +186,7 @@ def eval_proper(self, typ: Type) -> ProperType: def eval_operator(self, typ: TypeOperatorType) -> Type: """Evaluate a type operator by dispatching to registered handler.""" - fullname = typ.fullname - evaluator = _OPERATOR_EVALUATORS.get(fullname) + evaluator = _OPERATOR_EVALUATORS.get(typ.name) if evaluator is None: # print("NO EVALUATOR", fullname) @@ -220,7 +219,7 @@ def tuple_type(self, items: list[Type]) -> TupleType: # --- Operator Implementations for Phase 3A --- -@register_operator("builtins._Cond") +@register_operator("_Cond") def _eval_cond(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: """Evaluate _Cond[condition, TrueType, FalseType].""" @@ -240,7 +239,7 @@ def _eval_cond(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: return EXPANSION_ANY -@register_operator("typing.Iter") +@register_operator("Iter") def _eval_iter(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: """Evaluate a type-level iterator (Iter[T]).""" if len(typ.args) != 1: @@ -256,7 +255,7 @@ def _eval_iter(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: return UninhabitedType() -@register_operator("typing.IsSub") +@register_operator("IsSub") def _eval_issub(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: """Evaluate a type-level condition (IsSub[T, Base]).""" @@ -308,7 +307,7 @@ def extract_literal_string(typ: Type) -> str | None: # --- Phase 3B: Type Introspection Operators --- -@register_operator("typing.GetArg") +@register_operator("GetArg") @lift_over_unions def _eval_get_arg(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: """Evaluate GetArg[T, Base, Idx] - get type argument at index from T as Base.""" @@ -333,7 +332,7 @@ def _eval_get_arg(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: return UninhabitedType() -@register_operator("typing.GetArgs") +@register_operator("GetArgs") @lift_over_unions def _eval_get_args(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: """Evaluate GetArgs[T, Base] -> tuple of all type args from T as Base.""" @@ -352,7 +351,7 @@ def _eval_get_args(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type return UninhabitedType() -@register_operator("typing.FromUnion") +@register_operator("FromUnion") def _eval_from_union(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: """Evaluate FromUnion[T] -> tuple of union elements.""" if len(typ.args) != 1: @@ -367,7 +366,7 @@ def _eval_from_union(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Ty return evaluator.tuple_type([target]) -@register_operator("typing.GetAttr") +@register_operator("GetAttr") @lift_over_unions def _eval_get_attr(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: """Evaluate GetAttr[T, Name] - get attribute type from T.""" @@ -393,7 +392,7 @@ def _eval_get_attr(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type # --- Phase 3B: String Operations --- -@register_operator("typing.Slice") +@register_operator("Slice") @lift_over_unions def _eval_slice(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: """Evaluate Slice[S, Start, End] - slice a literal string.""" @@ -427,7 +426,7 @@ def _eval_slice(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: return UninhabitedType() -@register_operator("typing.Concat") +@register_operator("Concat") @lift_over_unions def _eval_concat(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: """Evaluate Concat[S1, S2] - concatenate two literal strings.""" @@ -443,7 +442,7 @@ def _eval_concat(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: return UninhabitedType() -@register_operator("typing.Uppercase") +@register_operator("Uppercase") @lift_over_unions def _eval_uppercase(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: """Evaluate Uppercase[S] - convert literal string to uppercase.""" @@ -457,7 +456,7 @@ def _eval_uppercase(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Typ return UninhabitedType() -@register_operator("typing.Lowercase") +@register_operator("Lowercase") @lift_over_unions def _eval_lowercase(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: """Evaluate Lowercase[S] - convert literal string to lowercase.""" @@ -471,7 +470,7 @@ def _eval_lowercase(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Typ return UninhabitedType() -@register_operator("typing.Capitalize") +@register_operator("Capitalize") @lift_over_unions def _eval_capitalize(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: """Evaluate Capitalize[S] - capitalize first character of literal string.""" @@ -485,7 +484,7 @@ def _eval_capitalize(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Ty return UninhabitedType() -@register_operator("typing.Uncapitalize") +@register_operator("Uncapitalize") @lift_over_unions def _eval_uncapitalize(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: """Evaluate Uncapitalize[S] - lowercase first character of literal string.""" @@ -503,7 +502,7 @@ def _eval_uncapitalize(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> # --- Phase 3B: Object Introspection Operators --- -@register_operator("typing.Members") +@register_operator("Members") @lift_over_unions def _eval_members(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: """Evaluate Members[T] -> tuple of Member types for all members of T. @@ -513,7 +512,7 @@ def _eval_members(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: return _eval_members_impl(evaluator, typ, attrs_only=False) -@register_operator("typing.Attrs") +@register_operator("Attrs") @lift_over_unions def _eval_attrs(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: """Evaluate Attrs[T] -> tuple of Member types for annotated attributes only. @@ -637,7 +636,7 @@ def create_member_type( # --- Phase 3B: Utility Operators --- -@register_operator("typing.Length") +@register_operator("Length") @lift_over_unions def _eval_length(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: """Evaluate Length[T] -> Literal[int] for tuple length.""" diff --git a/mypy/types.py b/mypy/types.py index 7060d8896a162..d3aef67cddd0e 100644 --- a/mypy/types.py +++ b/mypy/types.py @@ -159,7 +159,7 @@ DISJOINT_BASE_DECORATOR_NAMES: Final = ("typing.disjoint_base", "typing_extensions.disjoint_base") # Supported @_type_operator decorator names (for type-level computation) -TYPE_OPERATOR_NAMES: Final = ("typing._type_operator",) +TYPE_OPERATOR_NAMES: Final = ("typing._type_operator", "_typeshed.typemap._type_operator") # We use this constant in various places when checking `tuple` subtyping: TUPLE_LIKE_INSTANCE_NAMES: Final = ( @@ -536,6 +536,10 @@ def fullname(self) -> str: assert self.type_ref is not None return self.type_ref + @property + def name(self) -> str: + return self.fullname.split(".")[-1] + def expand(self) -> Type: """Evaluate this type operator to produce a concrete type.""" from mypy.typelevel import evaluate_type_operator diff --git a/mypy/typeshed/stdlib/_typeshed/typemap.pyi b/mypy/typeshed/stdlib/_typeshed/typemap.pyi new file mode 100644 index 0000000000000..ee367eb0196ca --- /dev/null +++ b/mypy/typeshed/stdlib/_typeshed/typemap.pyi @@ -0,0 +1,267 @@ +"""Declarations from the typemap PEP proposal. + +These are here so that we can also easily export them from typing_extensions +and typemap.typing. +""" + +import typing_extensions +from typing import Generic, Literal, TypeVar +from typing_extensions import TypeVarTuple, Unpack + +_S = TypeVar("_S") +_T = TypeVar("_T") + +# Marker decorator for type operators. Classes decorated with this are treated +# specially by the type checker as type-level computation operators. +def _type_operator(cls: type[_T]) -> type[_T]: ... + +# MemberQuals: qualifiers that can apply to a Member +MemberQuals: typing_extensions.TypeAlias = Literal["ClassVar", "Final"] + +# ParamQuals: qualifiers that can apply to a Param +ParamQuals: typing_extensions.TypeAlias = Literal["positional", "keyword", "default", "*", "**"] + +# --- Data Types (used in type computations) --- + +_Name = TypeVar("_Name") +_Type = TypeVar("_Type") +_Quals = TypeVar("_Quals") +_Init = TypeVar("_Init") +_Definer = TypeVar("_Definer") + +class Member(Generic[_Name, _Type, _Quals, _Init, _Definer]): + """ + Represents a class member with name, type, qualifiers, initializer, and definer. + - _Name: Literal[str] - the member name + - _Type: the member's type + - _Quals: Literal['ClassVar'] | Literal['Final'] | Never - qualifiers + - _Init: the literal type of the initializer expression + - _Definer: the class that defined this member + """ + + name: _Name + typ: _Type + quals: _Quals + init: _Init + definer: _Definer + +class Param(Generic[_Name, _Type, _Quals]): + """ + Represents a function parameter for extended callable syntax. + - _Name: Literal[str] | None - the parameter name + - _Type: the parameter's type + - _Quals: Literal['positional', 'keyword', 'default', '*', '**'] - qualifiers + """ + + name: _Name + typ: _Type + quals: _Quals + + +_N = TypeVar("_N", bound=str) + +# Convenience aliases for Param + +# XXX: For mysterious reasons, if I mark this as `: +# typing_extensions.TypeAlias`, mypy thinks _N and _T are unbound... +PosParam = Param[_N, _T, Literal["positional"]] +PosDefaultParam = Param[_N, _T, Literal["positional", "default"]] +DefaultParam = Param[_N, _T, Literal["default"]] +NamedParam = Param[_N, _T, Literal["keyword"]] +NamedDefaultParam = Param[_N, _T, Literal["keyword", "default"]] +ArgsParam = Param[None, _T, Literal["*"]] +KwargsParam = Param[None, _T, Literal["**"]] + +# --- Type Introspection Operators --- + +_Base = TypeVar("_Base") +_Idx = TypeVar("_Idx") +_S1 = TypeVar("_S1") +_S2 = TypeVar("_S2") +_Start = TypeVar("_Start") +_End = TypeVar("_End") + +@_type_operator +class GetArg(Generic[_T, _Base, _Idx]): + """ + Get type argument at index _Idx from _T when viewed as _Base. + Returns Never if _T does not inherit from _Base or index is out of bounds. + """ + + ... + +@_type_operator +class GetArgs(Generic[_T, _Base]): + """ + Get all type arguments from _T when viewed as _Base, as a tuple. + Returns Never if _T does not inherit from _Base. + """ + + ... + +@_type_operator +class GetAttr(Generic[_T, _Name]): + """ + Get the type of attribute _Name from type _T. + _Name must be a Literal[str]. + """ + + ... + +@_type_operator +class Members(Generic[_T]): + """ + Get all members of type _T as a tuple of Member types. + Includes methods, class variables, and instance attributes. + """ + + ... + +@_type_operator +class Attrs(Generic[_T]): + """ + Get annotated instance attributes of _T as a tuple of Member types. + Excludes methods and ClassVar members. + """ + + ... + +@_type_operator +class FromUnion(Generic[_T]): + """ + Convert a union type to a tuple of its constituent types. + If _T is not a union, returns a 1-tuple containing _T. + """ + + ... + +# --- Member/Param Accessors (defined as type aliases using GetAttr) --- + +_MP = TypeVar("_MP", bound=Member[Any, Any, Any, Any, Any] | Param[Any, Any, Any]) +_M = TypeVar("_M", bound=Member[Any, Any, Any, Any, Any]) + + +GetName = GetAttr[_MP, Literal["name"]] +GetType = GetAttr[_MP, Literal["typ"]] +GetQuals = GetAttr[_MP, Literal["quals"]] +GetInit = GetAttr[_M, Literal["init"]] +GetDefiner = GetAttr[_M, Literal["definer"]] + +# --- Type Construction Operators --- + +_Ts = TypeVarTuple("_Ts") + +@_type_operator +class NewProtocol(Generic[Unpack[_Ts]]): + """ + Construct a new structural (protocol) type from Member types. + NewProtocol[Member[...], Member[...], ...] creates an anonymous protocol. + """ + + ... + +@_type_operator +class NewTypedDict(Generic[Unpack[_Ts]]): + """ + Construct a new TypedDict from Member types. + NewTypedDict[Member[...], Member[...], ...] creates an anonymous TypedDict. + """ + + ... + +# --- Boolean/Conditional Operators --- + +@_type_operator +class IsSub(Generic[_T, _Base]): + """ + Type-level subtype check. Evaluates to a type-level boolean. + Used in conditional type expressions: `_Cond[IsSub[T, Base], X, Y]` + """ + + ... + +@_type_operator +class Iter(Generic[_T]): + """ + Marks a type for iteration in type comprehensions. + `for x in Iter[T]` iterates over elements of tuple type T. + """ + + ... + +# --- String Operations --- + +@_type_operator +class Slice(Generic[_S, _Start, _End]): + """ + Slice a literal string type. + Slice[Literal["hello"], Literal[1], Literal[3]] = Literal["el"] + """ + + ... + +@_type_operator +class Concat(Generic[_S1, _S2]): + """ + Concatenate two literal string types. + Concat[Literal["hello"], Literal["world"]] = Literal["helloworld"] + """ + + ... + +@_type_operator +class Uppercase(Generic[_S]): + """Convert literal string to uppercase.""" + + ... + +@_type_operator +class Lowercase(Generic[_S]): + """Convert literal string to lowercase.""" + + ... + +@_type_operator +class Capitalize(Generic[_S]): + """Capitalize first character of literal string.""" + + ... + +@_type_operator +class Uncapitalize(Generic[_S]): + """Lowercase first character of literal string.""" + + ... + +# --- Annotated Operations --- + +@_type_operator +class GetAnnotations(Generic[_T]): + """ + Extract Annotated metadata from a type. + GetAnnotations[Annotated[int, 'foo', 'bar']] = Literal['foo', 'bar'] + GetAnnotations[int] = Never + """ + + ... + +@_type_operator +class DropAnnotations(Generic[_T]): + """ + Strip Annotated wrapper from a type. + DropAnnotations[Annotated[int, 'foo']] = int + DropAnnotations[int] = int + """ + + ... + +# --- Utility Operators --- + +@_type_operator +class Length(Generic[_T]): + """ + Get the length of a tuple type as a Literal[int]. + Returns Literal[None] for unbounded tuples. + """ + + ... diff --git a/mypy/typeshed/stdlib/typing.pyi b/mypy/typeshed/stdlib/typing.pyi index e08532bf520cc..e54a9db037e36 100644 --- a/mypy/typeshed/stdlib/typing.pyi +++ b/mypy/typeshed/stdlib/typing.pyi @@ -1189,258 +1189,5 @@ if sys.version_info >= (3, 13): # --- Type-level computation support --- -# Marker decorator for type operators. Classes decorated with this are treated -# specially by the type checker as type-level computation operators. -def _type_operator(cls: type[_T]) -> type[_T]: ... - if sys.version_info >= (3, 15): - # MemberQuals: qualifiers that can apply to a Member - MemberQuals: typing_extensions.TypeAlias = Literal["ClassVar", "Final"] - - # ParamQuals: qualifiers that can apply to a Param - ParamQuals: typing_extensions.TypeAlias = Literal["positional", "keyword", "default", "*", "**"] - - # --- Data Types (used in type computations) --- - - _Name = TypeVar("_Name") - _Type = TypeVar("_Type") - _Quals = TypeVar("_Quals") - _Init = TypeVar("_Init") - _Definer = TypeVar("_Definer") - - class Member(Generic[_Name, _Type, _Quals, _Init, _Definer]): - """ - Represents a class member with name, type, qualifiers, initializer, and definer. - - _Name: Literal[str] - the member name - - _Type: the member's type - - _Quals: Literal['ClassVar'] | Literal['Final'] | Never - qualifiers - - _Init: the literal type of the initializer expression - - _Definer: the class that defined this member - """ - - name: _Name - typ: _Type - quals: _Quals - init: _Init - definer: _Definer - - class Param(Generic[_Name, _Type, _Quals]): - """ - Represents a function parameter for extended callable syntax. - - _Name: Literal[str] | None - the parameter name - - _Type: the parameter's type - - _Quals: Literal['positional', 'keyword', 'default', '*', '**'] - qualifiers - """ - - name: _Name - typ: _Type - quals: _Quals - - - _N = TypeVar("_N", bound=str) - - # Convenience aliases for Param - - # XXX: For mysterious reasons, if I mark this as `: - # typing_extensions.TypeAlias`, mypy thinks _N and _T are unbound... - PosParam = Param[_N, _T, Literal["positional"]] - PosDefaultParam = Param[_N, _T, Literal["positional", "default"]] - DefaultParam = Param[_N, _T, Literal["default"]] - NamedParam = Param[_N, _T, Literal["keyword"]] - NamedDefaultParam = Param[_N, _T, Literal["keyword", "default"]] - ArgsParam = Param[None, _T, Literal["*"]] - KwargsParam = Param[None, _T, Literal["**"]] - - # --- Type Introspection Operators --- - - _Base = TypeVar("_Base") - _Idx = TypeVar("_Idx") - _S1 = TypeVar("_S1") - _S2 = TypeVar("_S2") - _Start = TypeVar("_Start") - _End = TypeVar("_End") - - @_type_operator - class GetArg(Generic[_T, _Base, _Idx]): - """ - Get type argument at index _Idx from _T when viewed as _Base. - Returns Never if _T does not inherit from _Base or index is out of bounds. - """ - - ... - - @_type_operator - class GetArgs(Generic[_T, _Base]): - """ - Get all type arguments from _T when viewed as _Base, as a tuple. - Returns Never if _T does not inherit from _Base. - """ - - ... - - @_type_operator - class GetAttr(Generic[_T, _Name]): - """ - Get the type of attribute _Name from type _T. - _Name must be a Literal[str]. - """ - - ... - - @_type_operator - class Members(Generic[_T]): - """ - Get all members of type _T as a tuple of Member types. - Includes methods, class variables, and instance attributes. - """ - - ... - - @_type_operator - class Attrs(Generic[_T]): - """ - Get annotated instance attributes of _T as a tuple of Member types. - Excludes methods and ClassVar members. - """ - - ... - - @_type_operator - class FromUnion(Generic[_T]): - """ - Convert a union type to a tuple of its constituent types. - If _T is not a union, returns a 1-tuple containing _T. - """ - - ... - - # --- Member/Param Accessors (defined as type aliases using GetAttr) --- - - _MP = TypeVar("_MP", bound=Member[Any, Any, Any, Any, Any] | Param[Any, Any, Any]) - _M = TypeVar("_M", bound=Member[Any, Any, Any, Any, Any]) - - - GetName = GetAttr[_MP, Literal["name"]] - GetType = GetAttr[_MP, Literal["typ"]] - GetQuals = GetAttr[_MP, Literal["quals"]] - GetInit = GetAttr[_M, Literal["init"]] - GetDefiner = GetAttr[_M, Literal["definer"]] - - # --- Type Construction Operators --- - - _Ts = TypeVarTuple("_Ts") - - @_type_operator - class NewProtocol(Generic[Unpack[_Ts]]): - """ - Construct a new structural (protocol) type from Member types. - NewProtocol[Member[...], Member[...], ...] creates an anonymous protocol. - """ - - ... - - @_type_operator - class NewTypedDict(Generic[Unpack[_Ts]]): - """ - Construct a new TypedDict from Member types. - NewTypedDict[Member[...], Member[...], ...] creates an anonymous TypedDict. - """ - - ... - - # --- Boolean/Conditional Operators --- - - @_type_operator - class IsSub(Generic[_T, _Base]): - """ - Type-level subtype check. Evaluates to a type-level boolean. - Used in conditional type expressions: `_Cond[IsSub[T, Base], X, Y]` - """ - - ... - - @_type_operator - class Iter(Generic[_T]): - """ - Marks a type for iteration in type comprehensions. - `for x in Iter[T]` iterates over elements of tuple type T. - """ - - ... - - # --- String Operations --- - - @_type_operator - class Slice(Generic[_S, _Start, _End]): - """ - Slice a literal string type. - Slice[Literal["hello"], Literal[1], Literal[3]] = Literal["el"] - """ - - ... - - @_type_operator - class Concat(Generic[_S1, _S2]): - """ - Concatenate two literal string types. - Concat[Literal["hello"], Literal["world"]] = Literal["helloworld"] - """ - - ... - - @_type_operator - class Uppercase(Generic[_S]): - """Convert literal string to uppercase.""" - - ... - - @_type_operator - class Lowercase(Generic[_S]): - """Convert literal string to lowercase.""" - - ... - - @_type_operator - class Capitalize(Generic[_S]): - """Capitalize first character of literal string.""" - - ... - - @_type_operator - class Uncapitalize(Generic[_S]): - """Lowercase first character of literal string.""" - - ... - - # --- Annotated Operations --- - - @_type_operator - class GetAnnotations(Generic[_T]): - """ - Extract Annotated metadata from a type. - GetAnnotations[Annotated[int, 'foo', 'bar']] = Literal['foo', 'bar'] - GetAnnotations[int] = Never - """ - - ... - - @_type_operator - class DropAnnotations(Generic[_T]): - """ - Strip Annotated wrapper from a type. - DropAnnotations[Annotated[int, 'foo']] = int - DropAnnotations[int] = int - """ - - ... - - # --- Utility Operators --- - - @_type_operator - class Length(Generic[_T]): - """ - Get the length of a tuple type as a Literal[int]. - Returns Literal[None] for unbounded tuples. - """ - - ... + from _typeshed.typemap import * From 5e41d12cbd450f0c05acd70e77d32a36063edf26 Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Tue, 27 Jan 2026 15:29:42 -0800 Subject: [PATCH 058/161] Replace star import with explicit imports in typing.pyi Replace `from _typeshed.typemap import *` with explicit imports and re-exports. Update __all__ to include all typemap exports. Co-Authored-By: Claude Opus 4.5 --- mypy/typeshed/stdlib/builtins.pyi | 2 +- mypy/typeshed/stdlib/typing.pyi | 79 ++++++++++++++++++++++++++++++- 2 files changed, 79 insertions(+), 2 deletions(-) diff --git a/mypy/typeshed/stdlib/builtins.pyi b/mypy/typeshed/stdlib/builtins.pyi index 67d1402cc0953..05497594d2dbb 100644 --- a/mypy/typeshed/stdlib/builtins.pyi +++ b/mypy/typeshed/stdlib/builtins.pyi @@ -30,6 +30,7 @@ from _typeshed import ( SupportsRichComparisonT, SupportsWrite, ) +from _typeshed.typemap import _type_operator from collections.abc import Awaitable, Callable, Iterable, Iterator, MutableSet, Reversible, Set as AbstractSet, Sized from io import BufferedRandom, BufferedReader, BufferedWriter, FileIO, TextIOWrapper from os import PathLike @@ -58,7 +59,6 @@ from typing import ( # noqa: Y022,UP035,RUF100 final, overload, type_check_only, - _type_operator, ) # we can't import `Literal` from typing or mypy crashes: see #11247 diff --git a/mypy/typeshed/stdlib/typing.pyi b/mypy/typeshed/stdlib/typing.pyi index e54a9db037e36..0ef1a257c9477 100644 --- a/mypy/typeshed/stdlib/typing.pyi +++ b/mypy/typeshed/stdlib/typing.pyi @@ -1190,4 +1190,81 @@ if sys.version_info >= (3, 13): # --- Type-level computation support --- if sys.version_info >= (3, 15): - from _typeshed.typemap import * + __all__ += [ + # Type operators + "GetArg", + "GetArgs", + "GetAttr", + "Members", + "Attrs", + "FromUnion", + "NewProtocol", + "NewTypedDict", + "IsSub", + "Iter", + "Slice", + "Concat", + "Uppercase", + "Lowercase", + "Capitalize", + "Uncapitalize", + "GetAnnotations", + "DropAnnotations", + "Length", + # Data types + "Member", + "Param", + "PosParam", + "PosDefaultParam", + "DefaultParam", + "NamedParam", + "NamedDefaultParam", + "ArgsParam", + "KwargsParam", + # Accessors + "GetName", + "GetType", + "GetQuals", + "GetInit", + "GetDefiner", + # Type aliases + "MemberQuals", + "ParamQuals", + ] + from _typeshed.typemap import ( + Attrs as Attrs, + ArgsParam as ArgsParam, + Capitalize as Capitalize, + Concat as Concat, + DefaultParam as DefaultParam, + DropAnnotations as DropAnnotations, + FromUnion as FromUnion, + GetAnnotations as GetAnnotations, + GetArg as GetArg, + GetArgs as GetArgs, + GetAttr as GetAttr, + GetDefiner as GetDefiner, + GetInit as GetInit, + GetName as GetName, + GetQuals as GetQuals, + GetType as GetType, + IsSub as IsSub, + Iter as Iter, + KwargsParam as KwargsParam, + Length as Length, + Lowercase as Lowercase, + Member as Member, + MemberQuals as MemberQuals, + Members as Members, + NamedDefaultParam as NamedDefaultParam, + NamedParam as NamedParam, + NewProtocol as NewProtocol, + NewTypedDict as NewTypedDict, + Param as Param, + ParamQuals as ParamQuals, + PosDefaultParam as PosDefaultParam, + PosParam as PosParam, + Slice as Slice, + Uncapitalize as Uncapitalize, + Uppercase as Uppercase, + ) From aaeff9b5328317e13e3291024a78a054d8748966 Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Tue, 27 Jan 2026 15:51:28 -0800 Subject: [PATCH 059/161] Support TypedDict in Members/Attrs operators Members and Attrs now work on TypedDict types, returning Member types for each key with appropriate qualifiers: - Required/NotRequired based on whether the key is in required_keys - ReadOnly for keys in readonly_keys Also updated MemberQuals type alias to include the new TypedDict qualifiers. Co-Authored-By: Claude Opus 4.5 --- mypy/typelevel.py | 59 +++++++++++++++++++-- mypy/typeshed/stdlib/_typeshed/typemap.pyi | 2 +- test-data/unit/check-typelevel-members.test | 13 +++++ 3 files changed, 70 insertions(+), 4 deletions(-) diff --git a/mypy/typelevel.py b/mypy/typelevel.py index 156150d1be36f..74dc49ddcd6e8 100644 --- a/mypy/typelevel.py +++ b/mypy/typelevel.py @@ -25,6 +25,7 @@ ProperType, TupleType, Type, + TypedDictType, TypeForComprehension, TypeOfAny, TypeOperatorType, @@ -536,14 +537,18 @@ def _eval_members_impl( target = evaluator.eval_proper(typ.args[0]) - if not isinstance(target, Instance): - return UninhabitedType() - # Get the Member TypeInfo member_info = evaluator.api.named_type_or_none("typing.Member") if member_info is None: return UninhabitedType() + # Handle TypedDict + if isinstance(target, TypedDictType): + return _eval_typeddict_members(evaluator, target, member_info.type) + + if not isinstance(target, Instance): + return UninhabitedType() + members: dict[str, Type] = {} # Iterate through MRO in reverse (base classes first) to include inherited members @@ -593,6 +598,54 @@ def _eval_members_impl( return evaluator.tuple_type(list(members.values())) +def _eval_typeddict_members( + evaluator: TypeLevelEvaluator, + target: TypedDictType, + member_type_info: TypeInfo, +) -> Type: + """Evaluate Members/Attrs for a TypedDict type.""" + members: list[Type] = [] + + for name, item_type in target.items.items(): + # Skip private/dunder names + if name.startswith("_"): + continue + + # Build qualifiers for TypedDict keys + quals: list[str] = [] + if name in target.required_keys: + quals.append("Required") + else: + quals.append("NotRequired") + if name in target.readonly_keys: + quals.append("ReadOnly") + + # Create qualifier type + if len(quals) == 1: + quals_type: Type = evaluator.literal_str(quals[0]) + else: + quals_type = UnionType.make_union( + [evaluator.literal_str(q) for q in quals] + ) + + # For TypedDict, definer is the TypedDict's fallback instance + definer = target.fallback + + member_type = Instance( + member_type_info, + [ + evaluator.literal_str(name), # name + item_type, # typ + quals_type, # quals + UninhabitedType(), # init (not tracked for TypedDict) + definer, # definer + ], + ) + members.append(member_type) + + return evaluator.tuple_type(members) + + def create_member_type( evaluator: TypeLevelEvaluator, member_type_info: TypeInfo, diff --git a/mypy/typeshed/stdlib/_typeshed/typemap.pyi b/mypy/typeshed/stdlib/_typeshed/typemap.pyi index ee367eb0196ca..fca9eea4d3cd0 100644 --- a/mypy/typeshed/stdlib/_typeshed/typemap.pyi +++ b/mypy/typeshed/stdlib/_typeshed/typemap.pyi @@ -16,7 +16,7 @@ _T = TypeVar("_T") def _type_operator(cls: type[_T]) -> type[_T]: ... # MemberQuals: qualifiers that can apply to a Member -MemberQuals: typing_extensions.TypeAlias = Literal["ClassVar", "Final"] +MemberQuals: typing_extensions.TypeAlias = Literal["ClassVar", "Final", "Required", "NotRequired", "ReadOnly"] # ParamQuals: qualifiers that can apply to a Param ParamQuals: typing_extensions.TypeAlias = Literal["positional", "keyword", "default", "*", "**"] diff --git a/test-data/unit/check-typelevel-members.test b/test-data/unit/check-typelevel-members.test index 5ed0e0b7ad715..d803d61458e85 100644 --- a/test-data/unit/check-typelevel-members.test +++ b/test-data/unit/check-typelevel-members.test @@ -179,5 +179,18 @@ c: Attrs[Child2] reveal_type(c) # N: Revealed type is "tuple[typing.Member[Literal['x'], builtins.bool, Never, Never, __main__.Child2], typing.Member[Literal['y'], builtins.str, Never, Never, __main__.Child2]]" +[builtins fixtures/typelevel.pyi] +[typing fixtures/typing-full.pyi] + +[case testTypeOperatorMembersTypedDict] +from typing import Members, TypedDict + +class Person(TypedDict): + name: str + age: int + +m: Members[Person] +reveal_type(m) # N: Revealed type is "tuple[typing.Member[Literal['name'], builtins.str, Literal['Required'], Never, __main__.Person], typing.Member[Literal['age'], builtins.int, Literal['Required'], Never, __main__.Person]]" + [builtins fixtures/typelevel.pyi] [typing fixtures/typing-full.pyi] From 22b8393c6fb383f407b04a0ed3f881c66aa4cfb0 Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Tue, 27 Jan 2026 15:57:57 -0800 Subject: [PATCH 060/161] Add comprehensive TypedDict tests for Members operator - Test with NotRequired qualifier - Test with ReadOnly qualifier - Test TypedDict inheritance (note: definer is always the child class since TypedDict merges all keys) - Test functional TypedDict syntax (TypedDict('Name', {...})) Co-Authored-By: Claude Opus 4.5 --- test-data/unit/check-typelevel-members.test | 44 ++++++++++++++++++++- 1 file changed, 43 insertions(+), 1 deletion(-) diff --git a/test-data/unit/check-typelevel-members.test b/test-data/unit/check-typelevel-members.test index d803d61458e85..26b46eb344017 100644 --- a/test-data/unit/check-typelevel-members.test +++ b/test-data/unit/check-typelevel-members.test @@ -183,11 +183,53 @@ reveal_type(c) # N: Revealed type is "tuple[typing.Member[Literal['x'], builtin [typing fixtures/typing-full.pyi] [case testTypeOperatorMembersTypedDict] -from typing import Members, TypedDict +from typing import Members, TypedDict, NotRequired class Person(TypedDict): name: str age: int + nickname: NotRequired[str] + +m: Members[Person] +reveal_type(m) # N: Revealed type is "tuple[typing.Member[Literal['name'], builtins.str, Literal['Required'], Never, __main__.Person], typing.Member[Literal['age'], builtins.int, Literal['Required'], Never, __main__.Person], typing.Member[Literal['nickname'], builtins.str, Literal['NotRequired'], Never, __main__.Person]]" + +[builtins fixtures/typelevel.pyi] +[typing fixtures/typing-full.pyi] + +[case testTypeOperatorMembersTypedDictReadOnly] +from typing import Members, TypedDict, ReadOnly + +class Config(TypedDict): + name: str + version: ReadOnly[int] + +m: Members[Config] +reveal_type(m) # N: Revealed type is "tuple[typing.Member[Literal['name'], builtins.str, Literal['Required'], Never, __main__.Config], typing.Member[Literal['version'], builtins.int, Literal['Required'] | Literal['ReadOnly'], Never, __main__.Config]]" + +[builtins fixtures/typelevel.pyi] +[typing fixtures/typing-full.pyi] + +[case testTypeOperatorMembersTypedDictInheritance] +# Note: TypedDict inheritance merges all keys into the child, so definer is always the child +from typing import Members, TypedDict, NotRequired + +class Base(TypedDict): + id: int + name: str + +class Extended(Base): + email: NotRequired[str] + +m: Members[Extended] +reveal_type(m) # N: Revealed type is "tuple[typing.Member[Literal['id'], builtins.int, Literal['Required'], Never, __main__.Extended], typing.Member[Literal['name'], builtins.str, Literal['Required'], Never, __main__.Extended], typing.Member[Literal['email'], builtins.str, Literal['NotRequired'], Never, __main__.Extended]]" + +[builtins fixtures/typelevel.pyi] +[typing fixtures/typing-full.pyi] + +[case testTypeOperatorMembersTypedDictFunctional] +from typing import Members, TypedDict + +Person = TypedDict('Person', {'name': str, 'age': int}) m: Members[Person] reveal_type(m) # N: Revealed type is "tuple[typing.Member[Literal['name'], builtins.str, Literal['Required'], Never, __main__.Person], typing.Member[Literal['age'], builtins.int, Literal['Required'], Never, __main__.Person]]" From d31eccbdff9dcf010ab12a77ea6a4ca4b9d105b6 Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Tue, 27 Jan 2026 15:59:18 -0800 Subject: [PATCH 061/161] Use Never for TypedDict member definer field TypedDict keys don't have a meaningful definer class, so use Never instead of the fallback instance. Co-Authored-By: Claude Opus 4.5 --- mypy/typelevel.py | 5 +---- test-data/unit/check-typelevel-members.test | 9 ++++----- 2 files changed, 5 insertions(+), 9 deletions(-) diff --git a/mypy/typelevel.py b/mypy/typelevel.py index 74dc49ddcd6e8..7284cc77dbc84 100644 --- a/mypy/typelevel.py +++ b/mypy/typelevel.py @@ -628,9 +628,6 @@ def _eval_typeddict_members( [evaluator.literal_str(q) for q in quals] ) - # For TypedDict, definer is the TypedDict's fallback instance - definer = target.fallback - member_type = Instance( member_type_info, [ @@ -638,7 +635,7 @@ def _eval_typeddict_members( item_type, # typ quals_type, # quals UninhabitedType(), # init (not tracked for TypedDict) - definer, # definer + UninhabitedType(), # definer (not tracked for TypedDict) ], ) members.append(member_type) diff --git a/test-data/unit/check-typelevel-members.test b/test-data/unit/check-typelevel-members.test index 26b46eb344017..f1ec543391366 100644 --- a/test-data/unit/check-typelevel-members.test +++ b/test-data/unit/check-typelevel-members.test @@ -191,7 +191,7 @@ class Person(TypedDict): nickname: NotRequired[str] m: Members[Person] -reveal_type(m) # N: Revealed type is "tuple[typing.Member[Literal['name'], builtins.str, Literal['Required'], Never, __main__.Person], typing.Member[Literal['age'], builtins.int, Literal['Required'], Never, __main__.Person], typing.Member[Literal['nickname'], builtins.str, Literal['NotRequired'], Never, __main__.Person]]" +reveal_type(m) # N: Revealed type is "tuple[typing.Member[Literal['name'], builtins.str, Literal['Required'], Never, Never], typing.Member[Literal['age'], builtins.int, Literal['Required'], Never, Never], typing.Member[Literal['nickname'], builtins.str, Literal['NotRequired'], Never, Never]]" [builtins fixtures/typelevel.pyi] [typing fixtures/typing-full.pyi] @@ -204,13 +204,12 @@ class Config(TypedDict): version: ReadOnly[int] m: Members[Config] -reveal_type(m) # N: Revealed type is "tuple[typing.Member[Literal['name'], builtins.str, Literal['Required'], Never, __main__.Config], typing.Member[Literal['version'], builtins.int, Literal['Required'] | Literal['ReadOnly'], Never, __main__.Config]]" +reveal_type(m) # N: Revealed type is "tuple[typing.Member[Literal['name'], builtins.str, Literal['Required'], Never, Never], typing.Member[Literal['version'], builtins.int, Literal['Required'] | Literal['ReadOnly'], Never, Never]]" [builtins fixtures/typelevel.pyi] [typing fixtures/typing-full.pyi] [case testTypeOperatorMembersTypedDictInheritance] -# Note: TypedDict inheritance merges all keys into the child, so definer is always the child from typing import Members, TypedDict, NotRequired class Base(TypedDict): @@ -221,7 +220,7 @@ class Extended(Base): email: NotRequired[str] m: Members[Extended] -reveal_type(m) # N: Revealed type is "tuple[typing.Member[Literal['id'], builtins.int, Literal['Required'], Never, __main__.Extended], typing.Member[Literal['name'], builtins.str, Literal['Required'], Never, __main__.Extended], typing.Member[Literal['email'], builtins.str, Literal['NotRequired'], Never, __main__.Extended]]" +reveal_type(m) # N: Revealed type is "tuple[typing.Member[Literal['id'], builtins.int, Literal['Required'], Never, Never], typing.Member[Literal['name'], builtins.str, Literal['Required'], Never, Never], typing.Member[Literal['email'], builtins.str, Literal['NotRequired'], Never, Never]]" [builtins fixtures/typelevel.pyi] [typing fixtures/typing-full.pyi] @@ -232,7 +231,7 @@ from typing import Members, TypedDict Person = TypedDict('Person', {'name': str, 'age': int}) m: Members[Person] -reveal_type(m) # N: Revealed type is "tuple[typing.Member[Literal['name'], builtins.str, Literal['Required'], Never, __main__.Person], typing.Member[Literal['age'], builtins.int, Literal['Required'], Never, __main__.Person]]" +reveal_type(m) # N: Revealed type is "tuple[typing.Member[Literal['name'], builtins.str, Literal['Required'], Never, Never], typing.Member[Literal['age'], builtins.int, Literal['Required'], Never, Never]]" [builtins fixtures/typelevel.pyi] [typing fixtures/typing-full.pyi] From 14ed507e29d453e68e5fbf270dcf93c3865dbe3e Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Tue, 27 Jan 2026 16:23:19 -0800 Subject: [PATCH 062/161] Implement NewTypedDict operator for type-level computation NewTypedDict creates a TypedDict from Member types, acting as the inverse of how Members operates on TypedDicts. It extracts name, type, and qualifiers from each Member argument to build the TypedDict with proper Required/NotRequired/ReadOnly handling. Co-Authored-By: Claude Opus 4.5 --- mypy/typelevel.py | 77 +++++++++++++++++++++ test-data/unit/check-typelevel-members.test | 32 +++++++++ test-data/unit/fixtures/typing-full.pyi | 5 ++ 3 files changed, 114 insertions(+) diff --git a/mypy/typelevel.py b/mypy/typelevel.py index 7284cc77dbc84..e7cfed1461399 100644 --- a/mypy/typelevel.py +++ b/mypy/typelevel.py @@ -683,6 +683,83 @@ def create_member_type( ) +# --- Phase 3B: Type Construction Operators --- + + +@register_operator("NewTypedDict") +def _eval_new_typeddict(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: + """Evaluate NewTypedDict[*Members] -> create a new TypedDict from Member types. + + This is the inverse of Members[TypedDict]. + """ + # Get the Member TypeInfo to verify arguments + member_info = evaluator.api.named_type_or_none("typing.Member") + if member_info is None: + return UninhabitedType() + + items: dict[str, Type] = {} + required_keys: set[str] = set() + readonly_keys: set[str] = set() + + for arg in typ.args: + arg = get_proper_type(arg) + + # Each argument should be a Member[name, typ, quals, init, definer] + if not isinstance(arg, Instance) or arg.type != member_info.type: + # Not a Member type - can't construct TypedDict + return UninhabitedType() + + if len(arg.args) < 3: + return UninhabitedType() + + # Extract name, type, and qualifiers from Member args + name_type, item_type, quals, *_ = arg.args + name = extract_literal_string(name_type) + if name is None: + return UninhabitedType() + is_required = True # Default is Required + is_readonly = False + + # Check qualifiers - can be a single Literal or a Union of Literals + quals_proper = get_proper_type(quals) + qual_strings: list[str] = [] + + if isinstance(quals_proper, LiteralType) and isinstance(quals_proper.value, str): + qual_strings.append(quals_proper.value) + elif isinstance(quals_proper, UnionType): + for item in quals_proper.items: + item_proper = get_proper_type(item) + if isinstance(item_proper, LiteralType) and isinstance(item_proper.value, str): + qual_strings.append(item_proper.value) + + for qual in qual_strings: + if qual == "NotRequired": + is_required = False + elif qual == "Required": + is_required = True + elif qual == "ReadOnly": + is_readonly = True + + items[name] = item_type + if is_required: + required_keys.add(name) + if is_readonly: + readonly_keys.add(name) + + # Get the TypedDict fallback + fallback = evaluator.api.named_type_or_none("typing._TypedDict") + if fallback is None: + # Fallback to Mapping[str, object] if _TypedDict not available + fallback = evaluator.api.named_type("builtins.dict") + + return TypedDictType( + items=items, + required_keys=required_keys, + readonly_keys=readonly_keys, + fallback=fallback, + ) + + # --- Phase 3B: Utility Operators --- diff --git a/test-data/unit/check-typelevel-members.test b/test-data/unit/check-typelevel-members.test index f1ec543391366..27d448cb2881a 100644 --- a/test-data/unit/check-typelevel-members.test +++ b/test-data/unit/check-typelevel-members.test @@ -235,3 +235,35 @@ reveal_type(m) # N: Revealed type is "tuple[typing.Member[Literal['name'], buil [builtins fixtures/typelevel.pyi] [typing fixtures/typing-full.pyi] + +[case testTypeOperatorNewTypedDict] +from typing import NewTypedDict, Member, Literal + +# Create a TypedDict from Member types +TD = NewTypedDict[ + Member[Literal['name'], str, Literal['Required'], int, int], + Member[Literal['age'], int, Literal['Required'], int, int], +] + +x: TD +reveal_type(x) # N: Revealed type is "TypedDict({'name': builtins.str, 'age': builtins.int})" +x = {'name': 'Alice', 'age': 30} + +[builtins fixtures/typelevel.pyi] +[typing fixtures/typing-full.pyi] + +[case testTypeOperatorNewTypedDictNotRequired] +from typing import NewTypedDict, Member, Literal + +# Create a TypedDict with NotRequired field +TD = NewTypedDict[ + Member[Literal['name'], str, Literal['Required'], int, int], + Member[Literal['nickname'], str, Literal['NotRequired'], int, int], +] + +x: TD +reveal_type(x) # N: Revealed type is "TypedDict({'name': builtins.str, 'nickname'?: builtins.str})" +x = {'name': 'Alice'} # nickname is optional + +[builtins fixtures/typelevel.pyi] +[typing fixtures/typing-full.pyi] diff --git a/test-data/unit/fixtures/typing-full.pyi b/test-data/unit/fixtures/typing-full.pyi index 3503297bedc6a..5f27b69161204 100644 --- a/test-data/unit/fixtures/typing-full.pyi +++ b/test-data/unit/fixtures/typing-full.pyi @@ -282,6 +282,11 @@ class Members(Generic[T]): ... @_type_operator class Attrs(Generic[T]): ... +_Ts = TypeVarTuple("_Ts") + +@_type_operator +class NewTypedDict(Generic[Unpack[_Ts]]): ... + # Member data type for type-level computation _Name = TypeVar('_Name') _Type = TypeVar('_Type') From 3d6741a064253b7b4ea757324231f8e9ecf97c3e Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Tue, 27 Jan 2026 16:58:37 -0800 Subject: [PATCH 063/161] Add TypeForComprehension tests Tests for type-level comprehension syntax: *[expr for var in Iter[T] if cond] - Basic comprehension over tuple types - Comprehension with IsSub condition filtering - Transformation of types in comprehension - Comprehension in type alias (Python 3.12+) Co-Authored-By: Claude Opus 4.5 --- .../unit/check-typelevel-comprehension.test | 76 +++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 test-data/unit/check-typelevel-comprehension.test diff --git a/test-data/unit/check-typelevel-comprehension.test b/test-data/unit/check-typelevel-comprehension.test new file mode 100644 index 0000000000000..8551d15a5c23a --- /dev/null +++ b/test-data/unit/check-typelevel-comprehension.test @@ -0,0 +1,76 @@ +[case testTypeComprehensionBasic] +# Test basic type comprehension over a tuple +from typing import Iter + +x: tuple[*[list[T] for T in Iter[tuple[int, str, bool]]]] +reveal_type(x) # N: Revealed type is "tuple[builtins.list[builtins.int], builtins.list[builtins.str], builtins.list[builtins.bool]]" + +[builtins fixtures/typelevel.pyi] +[typing fixtures/typing-full.pyi] + +[case testTypeComprehensionWithCondition] +# Test type comprehension with IsSub condition +from typing import Iter, IsSub + +# Filter to only types that are subtypes of int (just int in this case) +x: tuple[*[list[T] for T in Iter[tuple[int, str, float]] if IsSub[T, int]]] +reveal_type(x) # N: Revealed type is "tuple[builtins.list[builtins.int]]" + +[builtins fixtures/typelevel.pyi] +[typing fixtures/typing-full.pyi] + +[case testTypeComprehensionFilterSubtypeObject] +# Test type comprehension filtering subtypes of object (everything passes) +from typing import Iter, IsSub + +x: tuple[*[T for T in Iter[tuple[int, str, bool]] if IsSub[T, object]]] +reveal_type(x) # N: Revealed type is "tuple[builtins.int, builtins.str, builtins.bool]" + +[builtins fixtures/typelevel.pyi] +[typing fixtures/typing-full.pyi] + +[case testTypeComprehensionFilterNone] +# Test type comprehension where no items pass the condition +from typing import Iter, IsSub + +# No type in the tuple is a subtype of str (str is not in the tuple) +x: tuple[*[T for T in Iter[tuple[int, float, bool]] if IsSub[T, str]]] +reveal_type(x) # N: Revealed type is "tuple[()]" + +[builtins fixtures/typelevel.pyi] +[typing fixtures/typing-full.pyi] + +[case testTypeComprehensionSingleElement] +# Test type comprehension over single-element tuple +from typing import Iter + +x: tuple[*[list[T] for T in Iter[tuple[int]]]] +reveal_type(x) # N: Revealed type is "tuple[builtins.list[builtins.int]]" + +[builtins fixtures/typelevel.pyi] +[typing fixtures/typing-full.pyi] + +[case testTypeComprehensionTransform] +# Test type comprehension that transforms types +from typing import Iter + +# Wrap each type in a tuple +x: tuple[*[tuple[T, T] for T in Iter[tuple[int, str]]]] +reveal_type(x) # N: Revealed type is "tuple[tuple[builtins.int, builtins.int], tuple[builtins.str, builtins.str]]" + +[builtins fixtures/typelevel.pyi] +[typing fixtures/typing-full.pyi] + +[case testTypeComprehensionInTypeAlias] +# flags: --python-version 3.12 +# Test type comprehension in a type alias +from typing import Iter + +type Listify[*Ts] = tuple[*[list[T] for T in Iter[tuple[*Ts]]]] + +x: Listify[int, str, bool] +reveal_type(x) # N: Revealed type is "tuple[builtins.list[builtins.int], builtins.list[builtins.str], builtins.list[builtins.bool]]" + +[builtins fixtures/typelevel.pyi] +[typing fixtures/typing-full.pyi] + From 77484dbd079bc67394619bf456733a70503c120a Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Tue, 27 Jan 2026 17:05:32 -0800 Subject: [PATCH 064/161] Add NewTypedDict tests with TypeForComprehension Added tests that combine NewTypedDict with type comprehensions: - Round-trip: NewTypedDict[*[m for m in Iter[Members[Person]]]] - Filter: NewTypedDict with IsSub condition to filter fields - From class: Create TypedDict from class attributes via Attrs Fixes to make these work: - Handle TypeForComprehension in evaluate() method - Flatten UnpackType args in NewTypedDict (from comprehension results) - Use fullname comparison for Member type check - Fix GetAttr to expand attribute types with instance type arguments Co-Authored-By: Claude Opus 4.5 --- mypy/typelevel.py | 19 +++++++- test-data/unit/check-typelevel-members.test | 49 +++++++++++++++++++++ 2 files changed, 67 insertions(+), 1 deletion(-) diff --git a/mypy/typelevel.py b/mypy/typelevel.py index e7cfed1461399..e5f0e2dc80f2e 100644 --- a/mypy/typelevel.py +++ b/mypy/typelevel.py @@ -172,6 +172,8 @@ def evaluate(self, typ: Type) -> Type: """Main entry point: evaluate a type to its simplified form.""" if isinstance(typ, TypeOperatorType): return self.eval_operator(typ) + if isinstance(typ, TypeForComprehension): + return typ.expand() return typ # Already a concrete type or can't be evaluated def eval_proper(self, typ: Type) -> ProperType: @@ -384,7 +386,8 @@ def _eval_get_attr(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type if isinstance(target, Instance): node = target.type.names.get(name) if node is not None and node.type is not None: - return node.type + # Expand the attribute type with the instance's type arguments + return expand_type_by_instance(node.type, target) return UninhabitedType() return UninhabitedType() @@ -701,7 +704,21 @@ def _eval_new_typeddict(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> required_keys: set[str] = set() readonly_keys: set[str] = set() + # Flatten args - handle UnpackType from comprehensions + flat_args: list[Type] = [] for arg in typ.args: + # First evaluate the arg in case it's a TypeForComprehension + evaluated = evaluator.evaluate(arg) + if isinstance(evaluated, UnpackType): + inner = get_proper_type(evaluated.type) + if isinstance(inner, TupleType): + flat_args.extend(inner.items) + else: + flat_args.append(evaluated) + else: + flat_args.append(evaluated) + + for arg in flat_args: arg = get_proper_type(arg) # Each argument should be a Member[name, typ, quals, init, definer] diff --git a/test-data/unit/check-typelevel-members.test b/test-data/unit/check-typelevel-members.test index 27d448cb2881a..f519673fd9361 100644 --- a/test-data/unit/check-typelevel-members.test +++ b/test-data/unit/check-typelevel-members.test @@ -267,3 +267,52 @@ x = {'name': 'Alice'} # nickname is optional [builtins fixtures/typelevel.pyi] [typing fixtures/typing-full.pyi] + +[case testNewTypedDictWithComprehensionRoundTrip] +from typing import NewTypedDict, Members, TypedDict, Iter + +class Person(TypedDict): + name: str + age: int + +# Round-trip: Members extracts, NewTypedDict reconstructs +TD = NewTypedDict[*[m for m in Iter[Members[Person]]]] +x: TD +reveal_type(x) # N: Revealed type is "TypedDict({'name': builtins.str, 'age': builtins.int})" +x = {'name': 'Alice', 'age': 30} + +[builtins fixtures/typelevel.pyi] +[typing fixtures/typing-full.pyi] + +[case testNewTypedDictWithComprehensionFilter] +from typing import NewTypedDict, Members, TypedDict, Iter, IsSub, GetAttr, Literal + +class Person(TypedDict): + name: str + age: int + active: bool + +# Filter to only string fields +TD = NewTypedDict[*[m for m in Iter[Members[Person]] if IsSub[GetAttr[m, Literal['typ']], str]]] +x: TD +reveal_type(x) # N: Revealed type is "TypedDict({'name': builtins.str})" +x = {'name': 'Alice'} + +[builtins fixtures/typelevel.pyi] +[typing fixtures/typing-full.pyi] + +[case testNewTypedDictWithComprehensionFromClass] +from typing import NewTypedDict, Attrs, Iter + +class Point: + x: int + y: int + +# Create TypedDict from class attributes +TD = NewTypedDict[*[m for m in Iter[Attrs[Point]]]] +x: TD +reveal_type(x) # N: Revealed type is "TypedDict({'x': builtins.int, 'y': builtins.int})" +x = {'x': 1, 'y': 2} + +[builtins fixtures/typelevel.pyi] +[typing fixtures/typing-full.pyi] From a3488529110bcb0976482bae5a0962de0133cffb Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Tue, 27 Jan 2026 17:11:24 -0800 Subject: [PATCH 065/161] Extract flatten_args helper method in TypeLevelEvaluator Move argument flattening logic (evaluating and unpacking UnpackType from comprehensions) to a reusable helper method. Co-Authored-By: Claude Opus 4.5 --- mypy/typelevel.py | 34 +++++++++++++++++++--------------- 1 file changed, 19 insertions(+), 15 deletions(-) diff --git a/mypy/typelevel.py b/mypy/typelevel.py index e5f0e2dc80f2e..57b9e0bdee8bb 100644 --- a/mypy/typelevel.py +++ b/mypy/typelevel.py @@ -210,6 +210,24 @@ def literal_int(self, value: int) -> LiteralType: """Create a Literal[int] type.""" return LiteralType(value, self.api.named_type("builtins.int")) + def flatten_args(self, args: list[Type]) -> list[Type]: + """Flatten type arguments, evaluating and unpacking as needed. + + Handles UnpackType from comprehensions by expanding the inner TupleType. + """ + flat_args: list[Type] = [] + for arg in args: + evaluated = self.evaluate(arg) + if isinstance(evaluated, UnpackType): + inner = get_proper_type(evaluated.type) + if isinstance(inner, TupleType): + flat_args.extend(inner.items) + else: + flat_args.append(evaluated) + else: + flat_args.append(evaluated) + return flat_args + def literal_str(self, value: str) -> LiteralType: """Create a Literal[str] type.""" return LiteralType(value, self.api.named_type("builtins.str")) @@ -704,21 +722,7 @@ def _eval_new_typeddict(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> required_keys: set[str] = set() readonly_keys: set[str] = set() - # Flatten args - handle UnpackType from comprehensions - flat_args: list[Type] = [] - for arg in typ.args: - # First evaluate the arg in case it's a TypeForComprehension - evaluated = evaluator.evaluate(arg) - if isinstance(evaluated, UnpackType): - inner = get_proper_type(evaluated.type) - if isinstance(inner, TupleType): - flat_args.extend(inner.items) - else: - flat_args.append(evaluated) - else: - flat_args.append(evaluated) - - for arg in flat_args: + for arg in evaluator.flatten_args(typ.args): arg = get_proper_type(arg) # Each argument should be a Member[name, typ, quals, init, definer] From 854cea5adb977a18445c6406e8a88ba4b1efe4ec Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Tue, 27 Jan 2026 17:15:10 -0800 Subject: [PATCH 066/161] Lint --- mypy/typelevel.py | 16 ++++------------ .../unit/check-typelevel-comprehension.test | 1 - 2 files changed, 4 insertions(+), 13 deletions(-) diff --git a/mypy/typelevel.py b/mypy/typelevel.py index 57b9e0bdee8bb..c316f4b6253c7 100644 --- a/mypy/typelevel.py +++ b/mypy/typelevel.py @@ -16,6 +16,7 @@ from mypy.expandtype import expand_type, expand_type_by_instance from mypy.maptype import map_instance_to_supertype +from mypy.nodes import FuncDef, Var from mypy.subtypes import is_subtype from mypy.types import ( AnyType, @@ -38,8 +39,6 @@ is_stuck_expansion, ) -from mypy.nodes import FuncDef, Var - if TYPE_CHECKING: from mypy.nodes import TypeInfo from mypy.semanal_shared import SemanticAnalyzerInterface @@ -620,9 +619,7 @@ def _eval_members_impl( def _eval_typeddict_members( - evaluator: TypeLevelEvaluator, - target: TypedDictType, - member_type_info: TypeInfo, + evaluator: TypeLevelEvaluator, target: TypedDictType, member_type_info: TypeInfo ) -> Type: """Evaluate Members/Attrs for a TypedDict type.""" members: list[Type] = [] @@ -645,9 +642,7 @@ def _eval_typeddict_members( if len(quals) == 1: quals_type: Type = evaluator.literal_str(quals[0]) else: - quals_type = UnionType.make_union( - [evaluator.literal_str(q) for q in quals] - ) + quals_type = UnionType.make_union([evaluator.literal_str(q) for q in quals]) member_type = Instance( member_type_info, @@ -774,10 +769,7 @@ def _eval_new_typeddict(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> fallback = evaluator.api.named_type("builtins.dict") return TypedDictType( - items=items, - required_keys=required_keys, - readonly_keys=readonly_keys, - fallback=fallback, + items=items, required_keys=required_keys, readonly_keys=readonly_keys, fallback=fallback ) diff --git a/test-data/unit/check-typelevel-comprehension.test b/test-data/unit/check-typelevel-comprehension.test index 8551d15a5c23a..882249b06bb67 100644 --- a/test-data/unit/check-typelevel-comprehension.test +++ b/test-data/unit/check-typelevel-comprehension.test @@ -73,4 +73,3 @@ reveal_type(x) # N: Revealed type is "tuple[builtins.list[builtins.int], builti [builtins fixtures/typelevel.pyi] [typing fixtures/typing-full.pyi] - From 1ee0b4aa5c826aff5728052017a1486a7107ec1c Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Tue, 27 Jan 2026 17:31:18 -0800 Subject: [PATCH 067/161] Remove phase comments from typelevel.py Co-Authored-By: Claude Opus 4.5 --- mypy/typelevel.py | 21 --------------------- 1 file changed, 21 deletions(-) diff --git a/mypy/typelevel.py b/mypy/typelevel.py index c316f4b6253c7..619ae65dc857e 100644 --- a/mypy/typelevel.py +++ b/mypy/typelevel.py @@ -160,8 +160,6 @@ class EvaluationStuck(Exception): class TypeLevelEvaluator: """Evaluates type-level computations to concrete types. - - Phase 3A: Core conditional type evaluation (_Cond and IsSub). """ def __init__(self, api: SemanticAnalyzerInterface) -> None: @@ -194,7 +192,6 @@ def eval_operator(self, typ: TypeOperatorType) -> Type: # print("NO EVALUATOR", fullname) # Unknown operator - return Any for now - # In Phase 3B, unregistered operators will be handled appropriately return EXPANSION_ANY return evaluator(self, typ) @@ -236,9 +233,6 @@ def tuple_type(self, items: list[Type]) -> TupleType: return TupleType(items, self.api.named_type("builtins.tuple")) -# --- Operator Implementations for Phase 3A --- - - @register_operator("_Cond") def _eval_cond(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: """Evaluate _Cond[condition, TrueType, FalseType].""" @@ -324,9 +318,6 @@ def extract_literal_string(typ: Type) -> str | None: return None -# --- Phase 3B: Type Introspection Operators --- - - @register_operator("GetArg") @lift_over_unions def _eval_get_arg(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: @@ -410,9 +401,6 @@ def _eval_get_attr(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type return UninhabitedType() -# --- Phase 3B: String Operations --- - - @register_operator("Slice") @lift_over_unions def _eval_slice(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: @@ -520,9 +508,6 @@ def _eval_uncapitalize(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> return UninhabitedType() -# --- Phase 3B: Object Introspection Operators --- - - @register_operator("Members") @lift_over_unions def _eval_members(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: @@ -699,9 +684,6 @@ def create_member_type( ) -# --- Phase 3B: Type Construction Operators --- - - @register_operator("NewTypedDict") def _eval_new_typeddict(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: """Evaluate NewTypedDict[*Members] -> create a new TypedDict from Member types. @@ -773,9 +755,6 @@ def _eval_new_typeddict(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> ) -# --- Phase 3B: Utility Operators --- - - @register_operator("Length") @lift_over_unions def _eval_length(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: From 382db111559a20aa06af5fc1c88280329af48555 Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Tue, 27 Jan 2026 17:41:13 -0800 Subject: [PATCH 068/161] Make evaluate_comprehension way less dumb A bunch of this was bizarre claude code stuff that I noticed was wrong last week and then lost track of because I was busy fixing the fact that it never worked... --- mypy/typelevel.py | 102 ++++++++++++++++++++-------------------------- mypy/types.py | 19 +++------ 2 files changed, 51 insertions(+), 70 deletions(-) diff --git a/mypy/typelevel.py b/mypy/typelevel.py index 619ae65dc857e..ca3159747c74e 100644 --- a/mypy/typelevel.py +++ b/mypy/typelevel.py @@ -20,6 +20,7 @@ from mypy.subtypes import is_subtype from mypy.types import ( AnyType, + ComputedType, Instance, LiteralType, NoneType, @@ -170,7 +171,7 @@ def evaluate(self, typ: Type) -> Type: if isinstance(typ, TypeOperatorType): return self.eval_operator(typ) if isinstance(typ, TypeForComprehension): - return typ.expand() + return evaluate_comprehension(self, typ) return typ # Already a concrete type or can't be evaluated def eval_proper(self, typ: Type) -> ProperType: @@ -773,6 +774,46 @@ def _eval_length(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: return UninhabitedType() +def evaluate_comprehension(evaluator: TypeLevelEvaluator, typ: TypeForComprehension) -> Type: + """Evaluate a TypeForComprehension. + + Evaluates *[Expr for var in Iter if Cond] to UnpackType(TupleType([...])). + """ + + # Get the iterable type and expand it to a TupleType + iter_proper = evaluator.eval_proper(typ.iter_type) + + if not isinstance(iter_proper, TupleType): + # Can only iterate over tuple types + return UninhabitedType() + + # Process each item in the tuple + result_items: list[Type] = [] + assert typ.iter_var + for item in iter_proper.items: + # Substitute iter_var with item in element_expr and conditions + env = {typ.iter_var.id: item} + substituted_expr = expand_type(typ.element_expr, env) + substituted_conditions = [expand_type(cond, env) for cond in typ.conditions] + + # Evaluate all conditions + all_pass = True + for cond in substituted_conditions: + cond_result = extract_literal_bool(evaluator.evaluate(cond)) + if cond_result is False: + all_pass = False + break + elif cond_result is None: + # Undecidable condition - raise Stuck + raise EvaluationStuck + + if all_pass: + # Include this element in the result + result_items.append(substituted_expr) + + return UnpackType(evaluator.tuple_type(result_items)) + + # --- Helper Functions --- @@ -793,8 +834,8 @@ def get_type_args_for_base(instance: Instance, base_type: TypeInfo) -> tuple[Typ # --- Public API --- -def evaluate_type_operator(typ: TypeOperatorType) -> Type: - """Evaluate a TypeOperatorType. Called from TypeOperatorType.expand(). +def evaluate_computed_type(typ: ComputedType) -> Type: + """Evaluate a ComputedType. Called from ComputedType.expand(). Uses typelevel_ctx.api to access the semantic analyzer. """ @@ -803,61 +844,8 @@ def evaluate_type_operator(typ: TypeOperatorType) -> Type: evaluator = TypeLevelEvaluator(typelevel_ctx.api) try: - res = evaluator.eval_operator(typ) + res = evaluator.evaluate(typ) except EvaluationStuck: res = EXPANSION_ANY # print("EVALED!!", res) return res - - -def evaluate_comprehension(typ: TypeForComprehension) -> Type: - """Evaluate a TypeForComprehension. Called from TypeForComprehension.expand(). - - Evaluates *[Expr for var in Iter if Cond] to UnpackType(TupleType([...])). - """ - if typelevel_ctx.api is None: - # API not available yet - return stuck expansion marker - return EXPANSION_ANY - - evaluator = TypeLevelEvaluator(typelevel_ctx.api) - - try: - # Get the iterable type and expand it to a TupleType - iter_proper = evaluator.eval_proper(typ.iter_type) - except EvaluationStuck: - return EXPANSION_ANY - - if not isinstance(iter_proper, TupleType): - # Can only iterate over tuple types - return UninhabitedType() - - # Process each item in the tuple - result_items: list[Type] = [] - assert typ.iter_var - for item in iter_proper.items: - # Substitute iter_var with item in element_expr and conditions - env = {typ.iter_var.id: item} - substituted_expr = expand_type(typ.element_expr, env) - substituted_conditions = [expand_type(cond, env) for cond in typ.conditions] - - # Evaluate all conditions - try: - all_pass = True - for cond in substituted_conditions: - cond_result = extract_literal_bool(evaluator.evaluate(cond)) - if cond_result is False: - all_pass = False - break - elif cond_result is None: - # Undecidable condition - skip this item - all_pass = False - break - - if all_pass: - # Include this element in the result - result_items.append(substituted_expr) - except EvaluationStuck: - # Skip items that cause stuck evaluation - continue - - return UnpackType(evaluator.tuple_type(result_items)) diff --git a/mypy/types.py b/mypy/types.py index d3aef67cddd0e..8bd3944b0a322 100644 --- a/mypy/types.py +++ b/mypy/types.py @@ -497,10 +497,15 @@ def expand(self) -> Type: """Evaluate this computed type to produce a concrete type. Returns self if evaluation is not yet possible (e.g., contains unresolved type vars). + XXX: That is not true (we return EXPANSION_ANY) but probably + should be. Subclasses must implement this method. + """ - raise NotImplementedError + from mypy.typelevel import evaluate_computed_type + + return evaluate_computed_type(self) class TypeOperatorType(ComputedType): @@ -540,12 +545,6 @@ def fullname(self) -> str: def name(self) -> str: return self.fullname.split(".")[-1] - def expand(self) -> Type: - """Evaluate this type operator to produce a concrete type.""" - from mypy.typelevel import evaluate_type_operator - - return evaluate_type_operator(self) - def __hash__(self) -> int: return hash((self.fullname, tuple(self.args))) @@ -638,12 +637,6 @@ def type_param(self) -> mypy.nodes.TypeParam: def accept(self, visitor: TypeVisitor[T]) -> T: return visitor.visit_type_for_comprehension(self) - def expand(self) -> Type: - """Evaluate the comprehension to produce a tuple type.""" - from mypy.typelevel import evaluate_comprehension - - return evaluate_comprehension(self) - def __hash__(self) -> int: return hash((self.element_expr, self.iter_name, self.iter_type, tuple(self.conditions))) From 86c383c47edb96b2b7b106f2975a4808800c1437 Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Wed, 28 Jan 2026 11:30:41 -0800 Subject: [PATCH 069/161] Test GetName --- mypy/typeshed/stdlib/_typeshed/typemap.pyi | 7 ++++-- test-data/unit/check-typelevel-basic.test | 27 ++++++++++++++++++++++ test-data/unit/fixtures/typing-full.pyi | 12 ++++++++++ 3 files changed, 44 insertions(+), 2 deletions(-) diff --git a/mypy/typeshed/stdlib/_typeshed/typemap.pyi b/mypy/typeshed/stdlib/_typeshed/typemap.pyi index fca9eea4d3cd0..21e7cc8b2d9ab 100644 --- a/mypy/typeshed/stdlib/_typeshed/typemap.pyi +++ b/mypy/typeshed/stdlib/_typeshed/typemap.pyi @@ -137,8 +137,11 @@ class FromUnion(Generic[_T]): # --- Member/Param Accessors (defined as type aliases using GetAttr) --- -_MP = TypeVar("_MP", bound=Member[Any, Any, Any, Any, Any] | Param[Any, Any, Any]) -_M = TypeVar("_M", bound=Member[Any, Any, Any, Any, Any]) +# _MP = TypeVar("_MP", bound=Member[Any, Any, Any, Any, Any] | Param[Any, Any, Any]) +# _M = TypeVar("_M", bound=Member[Any, Any, Any, Any, Any]) + +_MP = TypeVar("_MP") +_M = TypeVar("_M") GetName = GetAttr[_MP, Literal["name"]] diff --git a/test-data/unit/check-typelevel-basic.test b/test-data/unit/check-typelevel-basic.test index 31efbd5e7004f..e3e6f367885a1 100644 --- a/test-data/unit/check-typelevel-basic.test +++ b/test-data/unit/check-typelevel-basic.test @@ -598,3 +598,30 @@ def g[T]( [builtins fixtures/typelevel.pyi] [typing fixtures/typing-full.pyi] + +[case testTypeOperatorTypedDictParam] +# flags: --python-version 3.14 + +from typing import Callable, Attrs, GetName, TypedDict, Iter + +class BaseTypedDict(TypedDict): + pass + + +def foo[T: BaseTypedDict](x: T) -> tuple[ + *[GetName[m] for m in Iter[Attrs[T]]] +]: + return tuple(x.keys()) # type: ignore + + +class Sigh(TypedDict): + x: int + y: str + + +td: Sigh = {'x': 10, 'y': 'hmmm'} +x = foo(td) +reveal_type(x) # N: Revealed type is "tuple[Literal['x'], Literal['y']]" + +[builtins fixtures/typelevel.pyi] +[typing fixtures/typing-full.pyi] diff --git a/test-data/unit/fixtures/typing-full.pyi b/test-data/unit/fixtures/typing-full.pyi index 5f27b69161204..c43c75c9408ba 100644 --- a/test-data/unit/fixtures/typing-full.pyi +++ b/test-data/unit/fixtures/typing-full.pyi @@ -303,3 +303,15 @@ class Member(Generic[_Name, _Type, _Quals, _Init, _Definer]): quals: _Quals init: _Init definer: _Definer + + +# _MP = TypeVar("_MP", bound=Member[Any, Any, Any, Any, Any] | Param[Any, Any, Any]) +# _M = TypeVar("_M", bound=Member[Any, Any, Any, Any, Any]) +_MP = TypeVar("_MP") +_M = TypeVar("_M") + +GetName = GetAttr[_MP, Literal["name"]] +GetType = GetAttr[_MP, Literal["typ"]] +GetQuals = GetAttr[_MP, Literal["quals"]] +GetInit = GetAttr[_M, Literal["init"]] +GetDefiner = GetAttr[_M, Literal["definer"]] From 070092af069ba40b4e5f8dcb08118574a7fb963b Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Wed, 28 Jan 2026 11:32:26 -0800 Subject: [PATCH 070/161] Don't skip private/dunder names Weird decision from claude tbh --- mypy/typelevel.py | 8 -------- test-data/unit/check-typelevel-members.test | 4 ++-- 2 files changed, 2 insertions(+), 10 deletions(-) diff --git a/mypy/typelevel.py b/mypy/typelevel.py index ca3159747c74e..de374dd53f86e 100644 --- a/mypy/typelevel.py +++ b/mypy/typelevel.py @@ -565,10 +565,6 @@ def _eval_members_impl( continue for name, sym in type_info.names.items(): - # Skip private/dunder names - if name.startswith("_"): - continue - if sym.type is None: continue @@ -611,10 +607,6 @@ def _eval_typeddict_members( members: list[Type] = [] for name, item_type in target.items.items(): - # Skip private/dunder names - if name.startswith("_"): - continue - # Build qualifiers for TypedDict keys quals: list[str] = [] if name in target.required_keys: diff --git a/test-data/unit/check-typelevel-members.test b/test-data/unit/check-typelevel-members.test index f519673fd9361..a47607550d680 100644 --- a/test-data/unit/check-typelevel-members.test +++ b/test-data/unit/check-typelevel-members.test @@ -77,7 +77,7 @@ class Empty: _private: int m: Members[Empty] -reveal_type(m) # N: Revealed type is "tuple[()]" +reveal_type(m) # N: Revealed type is "tuple[typing.Member[Literal['_private'], builtins.int, Never, Never, __main__.Empty]]" [builtins fixtures/typelevel.pyi] [typing fixtures/typing-full.pyi] @@ -143,7 +143,7 @@ class MyClass: self.y = 'test' a: Members[MyClass] -reveal_type(a) # N: Revealed type is "tuple[typing.Member[Literal['x'], builtins.int, Never, Never, __main__.MyClass]]" +reveal_type(a) # N: Revealed type is "tuple[typing.Member[Literal['x'], builtins.int, Never, Never, __main__.MyClass], typing.Member[Literal['__init__'], def (self: __main__.MyClass), Literal['ClassVar'], Never, __main__.MyClass]]" b: Attrs[MyClass] reveal_type(b) # N: Revealed type is "tuple[typing.Member[Literal['x'], builtins.int, Never, Never, __main__.MyClass]]" From 4fffee29dbfac0db0b1f43e76fb9827a2788eaa5 Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Wed, 28 Jan 2026 12:07:50 -0800 Subject: [PATCH 071/161] EXTRACT: Fix semanal crashes if there is a TypedDict declaration inside typing.pyi A PEP I'm working on is probably going to propose a TypedDict that lives in typing.pyi, and currently it at least sometimes crashes the semantic analyzer, since _TypedDict might have gotten deferred. Fix this in the usual way, by adding a bunch of deferral checks. --- mypy/semanal_typeddict.py | 15 ++++++++++++--- test-data/unit/fixtures/typing-typeddict.pyi | 5 +++++ 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/mypy/semanal_typeddict.py b/mypy/semanal_typeddict.py index 3655e4c89dd4b..0d1993017990d 100644 --- a/mypy/semanal_typeddict.py +++ b/mypy/semanal_typeddict.py @@ -118,6 +118,8 @@ def analyze_typeddict_classdef(self, defn: ClassDef) -> tuple[bool, TypeInfo | N info = self.build_typeddict_typeinfo( defn.name, field_types, required_keys, readonly_keys, defn.line, existing_info ) + if not info: + return True, None defn.analyzed = TypedDictExpr(info) defn.analyzed.line = defn.line defn.analyzed.column = defn.column @@ -173,6 +175,8 @@ def analyze_typeddict_classdef(self, defn: ClassDef) -> tuple[bool, TypeInfo | N info = self.build_typeddict_typeinfo( defn.name, field_types, required_keys, readonly_keys, defn.line, existing_info ) + if not info: + return True, None defn.analyzed = TypedDictExpr(info) defn.analyzed.line = defn.line defn.analyzed.column = defn.column @@ -489,7 +493,10 @@ def check_typeddict( call.line, existing_info, ) - info.line = node.line + + if not info: + return True, None, [] + info.line = node.line # Store generated TypeInfo under both names, see semanal_namedtuple for more details. if name != var_name or is_func_scope: self.api.add_symbol_skip_local(name, info) @@ -597,14 +604,16 @@ def build_typeddict_typeinfo( readonly_keys: set[str], line: int, existing_info: TypeInfo | None, - ) -> TypeInfo: + ) -> TypeInfo | None: # Prefer typing then typing_extensions if available. fallback = ( self.api.named_type_or_none("typing._TypedDict", []) or self.api.named_type_or_none("typing_extensions._TypedDict", []) or self.api.named_type_or_none("mypy_extensions._TypedDict", []) ) - assert fallback is not None + # Adding a TypedDict subtype into typing sometimes causes this deferral + if fallback is None: + return None info = existing_info or self.api.basic_new_typeinfo(name, fallback, line) typeddict_type = TypedDictType(item_types, required_keys, readonly_keys, fallback) if has_placeholder(typeddict_type): diff --git a/test-data/unit/fixtures/typing-typeddict.pyi b/test-data/unit/fixtures/typing-typeddict.pyi index 0bc5637b32708..0421934f7c528 100644 --- a/test-data/unit/fixtures/typing-typeddict.pyi +++ b/test-data/unit/fixtures/typing-typeddict.pyi @@ -81,3 +81,8 @@ class _TypedDict(Mapping[str, object]): def __delitem__(self, k: NoReturn) -> None: ... class _SpecialForm: pass + +# A dummy TypedDict declaration inside typing.pyi, to test that +# semanal can handle it. +class _TestBaseTypedDict(TypedDict): + pass From e63b92f5e470123c3a55933fcd3d8f4cfa87fea0 Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Wed, 28 Jan 2026 12:13:03 -0800 Subject: [PATCH 072/161] Add BaseTypedDict and defaults for Member --- mypy/typelevel.py | 3 +-- mypy/typeshed/stdlib/_typeshed/typemap.pyi | 14 +++++++++----- mypy/typeshed/stdlib/typing.pyi | 3 +++ test-data/unit/check-typelevel-basic.test | 6 +----- test-data/unit/fixtures/typing-full.pyi | 10 +++++++--- 5 files changed, 21 insertions(+), 15 deletions(-) diff --git a/mypy/typelevel.py b/mypy/typelevel.py index de374dd53f86e..affbfe8e7d930 100644 --- a/mypy/typelevel.py +++ b/mypy/typelevel.py @@ -160,8 +160,7 @@ class EvaluationStuck(Exception): class TypeLevelEvaluator: - """Evaluates type-level computations to concrete types. - """ + """Evaluates type-level computations to concrete types.""" def __init__(self, api: SemanticAnalyzerInterface) -> None: self.api = api diff --git a/mypy/typeshed/stdlib/_typeshed/typemap.pyi b/mypy/typeshed/stdlib/_typeshed/typemap.pyi index 21e7cc8b2d9ab..8a988d01039fb 100644 --- a/mypy/typeshed/stdlib/_typeshed/typemap.pyi +++ b/mypy/typeshed/stdlib/_typeshed/typemap.pyi @@ -5,8 +5,12 @@ and typemap.typing. """ import typing_extensions -from typing import Generic, Literal, TypeVar -from typing_extensions import TypeVarTuple, Unpack +from typing import Generic, Literal, TypeVar, TypedDict +from typing_extensions import TypeVarTuple, Unpack, Never + +class BaseTypedDict(TypedDict): + pass + _S = TypeVar("_S") _T = TypeVar("_T") @@ -25,9 +29,9 @@ ParamQuals: typing_extensions.TypeAlias = Literal["positional", "keyword", "defa _Name = TypeVar("_Name") _Type = TypeVar("_Type") -_Quals = TypeVar("_Quals") -_Init = TypeVar("_Init") -_Definer = TypeVar("_Definer") +_Quals = TypeVar("_Quals", default=Never) +_Init = TypeVar("_Init", default=Never) +_Definer = TypeVar("_Definer", default=Never) class Member(Generic[_Name, _Type, _Quals, _Init, _Definer]): """ diff --git a/mypy/typeshed/stdlib/typing.pyi b/mypy/typeshed/stdlib/typing.pyi index 0ef1a257c9477..f662de3d70565 100644 --- a/mypy/typeshed/stdlib/typing.pyi +++ b/mypy/typeshed/stdlib/typing.pyi @@ -1230,10 +1230,13 @@ if sys.version_info >= (3, 15): # Type aliases "MemberQuals", "ParamQuals", + # Misc + "BaseTypedDict", ] from _typeshed.typemap import ( Attrs as Attrs, ArgsParam as ArgsParam, + BaseTypedDict as BaseTypedDict, Capitalize as Capitalize, Concat as Concat, DefaultParam as DefaultParam, diff --git a/test-data/unit/check-typelevel-basic.test b/test-data/unit/check-typelevel-basic.test index e3e6f367885a1..8a4ea19fc4ffc 100644 --- a/test-data/unit/check-typelevel-basic.test +++ b/test-data/unit/check-typelevel-basic.test @@ -602,11 +602,7 @@ def g[T]( [case testTypeOperatorTypedDictParam] # flags: --python-version 3.14 -from typing import Callable, Attrs, GetName, TypedDict, Iter - -class BaseTypedDict(TypedDict): - pass - +from typing import BaseTypedDict, Callable, Attrs, GetName, TypedDict, Iter def foo[T: BaseTypedDict](x: T) -> tuple[ *[GetName[m] for m in Iter[Attrs[T]]] diff --git a/test-data/unit/fixtures/typing-full.pyi b/test-data/unit/fixtures/typing-full.pyi index c43c75c9408ba..e62e90092f004 100644 --- a/test-data/unit/fixtures/typing-full.pyi +++ b/test-data/unit/fixtures/typing-full.pyi @@ -43,6 +43,7 @@ NotRequired = 0 ReadOnly = 0 Self = 0 Unpack = 0 +Never = 0 Callable: _SpecialForm Union: _SpecialForm Literal: _SpecialForm @@ -235,6 +236,9 @@ class TypeAliasType: # Type computation! +class BaseTypedDict(TypedDict): + pass + def _type_operator(cls: type[T]) -> type[T]: ... @_type_operator @@ -290,9 +294,9 @@ class NewTypedDict(Generic[Unpack[_Ts]]): ... # Member data type for type-level computation _Name = TypeVar('_Name') _Type = TypeVar('_Type') -_Quals = TypeVar('_Quals') -_Init = TypeVar('_Init') -_Definer = TypeVar('_Definer') +_Quals = TypeVar("_Quals", default=Never) +_Init = TypeVar("_Init", default=Never) +_Definer = TypeVar("_Definer", default=Never) class Member(Generic[_Name, _Type, _Quals, _Init, _Definer]): """ From 87c7866fbac979f0e993abe0daa13d7b366e13d9 Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Wed, 28 Jan 2026 12:33:06 -0800 Subject: [PATCH 073/161] Add a qblike test based on qblike_2 Still only using NewTypedDict instead of NewProtocol, and doesn't support **kwargs: Unpack[K] --- test-data/unit/check-typelevel-examples.test | 202 +++++++++++++++++++ 1 file changed, 202 insertions(+) create mode 100644 test-data/unit/check-typelevel-examples.test diff --git a/test-data/unit/check-typelevel-examples.test b/test-data/unit/check-typelevel-examples.test new file mode 100644 index 0000000000000..04f8b3e8234d5 --- /dev/null +++ b/test-data/unit/check-typelevel-examples.test @@ -0,0 +1,202 @@ +[case testTypeLevel_qblike] +# flags: --python-version 3.14 + +from typing import Literal, Unpack, TypedDict + +from typing import ( + # NewProtocol, + BaseTypedDict, + NewTypedDict, + NewTypedDict as NewProtocol, + Iter, + Attrs, + IsSub, + GetType, + Member, + GetName, + GetAttr, + GetArg, +) + + +# Begin PEP section: Prisma-style ORMs + +"""First, to support the annotations we saw above, we have a collection +of dummy classes with generic types. +""" + + +class Pointer[T]: + pass + + +class Property[T](Pointer[T]): + pass + + +class Link[T](Pointer[T]): + pass + + +class SingleLink[T](Link[T]): + pass + + +class MultiLink[T](Link[T]): + pass + + +""" +The ``select`` method is where we start seeing new things. + +The ``**kwargs: Unpack[K]`` is part of this proposal, and allows +*inferring* a TypedDict from keyword args. + +``Attrs[K]`` extracts ``Member`` types corresponding to every +type-annotated attribute of ``K``, while calling ``NewProtocol`` with +``Member`` arguments constructs a new structural type. + +``GetName`` is a getter operator that fetches the name of a ``Member`` +as a literal type--all of these mechanisms lean very heavily on literal types. +``GetAttr`` gets the type of an attribute from a class. + +""" + + +# def select[ModelT, K: BaseTypedDict]( +# typ: type[ModelT], +# /, +# **kwargs: Unpack[K], +# ) -> list[ +# NewProtocol[ +# *[ +# Member[ +# GetName[c], +# ConvertField[GetAttr[ModelT, GetName[c]]], +# ] +# for c in Iter[Attrs[K]] +# ] +# ] +# ]: +# return [] + +def select_ex[ModelT, K: BaseTypedDict]( + typ: type[ModelT], + kwargs: K, +) -> list[ + NewTypedDict[ + *[ + Member[ + GetName[c], + ConvertField[GetAttr[ModelT, GetName[c]]], + ] + for c in Iter[Attrs[K]] + ] + ] +]: + return [] + + +"""ConvertField is our first type helper, and it is a conditional type +alias, which decides between two types based on a (limited) +subtype-ish check. + +In ``ConvertField``, we wish to drop the ``Property`` or ``Link`` +annotation and produce the underlying type, as well as, for links, +producing a new target type containing only properties and wrapping +``MultiLink`` in a list. +""" + +type ConvertField[T] = ( + AdjustLink[PropsOnly[PointerArg[T]], T] if IsSub[T, Link] else PointerArg[T] +) + +"""``PointerArg`` gets the type argument to ``Pointer`` or a subclass. + +``GetArg[T, Base, I]`` is one of the core primitives; it fetches the +index ``I`` type argument to ``Base`` from a type ``T``, if ``T`` +inherits from ``Base``. + +(The subtleties of this will be discussed later; in this case, it just +grabs the argument to a ``Pointer``). + +""" +# XXX: We kind of want to be able to do T: Pointer, but... +type PointerArg[T] = GetArg[T, Pointer, Literal[0]] + +""" +``AdjustLink`` sticks a ``list`` around ``MultiLink``, using features +we've discussed already. + +""" +type AdjustLink[Tgt, LinkTy] = list[Tgt] if IsSub[LinkTy, MultiLink] else Tgt + +"""And the final helper, ``PropsOnly[T]``, generates a new type that +contains all the ``Property`` attributes of ``T``. + +""" +type PropsOnly[T] = list[ + NewProtocol[ + *[ + Member[GetName[p], PointerArg[GetType[p]]] + for p in Iter[Attrs[T]] + if IsSub[GetType[p], Property] + ] + ] +] + +""" +The full test is `in our test suite <#qb-test_>`_. +""" + + +# End PEP section + + +# Basic filtering +class Comment: + id: Property[int] + name: Property[str] + poster: Link[User] + + +class Post: + id: Property[int] + + title: Property[str] + content: Property[str] + + comments: MultiLink[Comment] + author: Link[Comment] + + +class User: + id: Property[int] + + name: Property[str] + email: Property[str] + posts: Link[Post] + + +#### + +# We used TypedDicts explicitly here + +class Args0(TypedDict): + id: Literal[True] + name: Literal[True] + +args0: Args0 +reveal_type(select_ex(User, args0)) # N: Revealed type is "builtins.list[TypedDict({'id': builtins.int, 'name': builtins.str})]" + + +class Args1(TypedDict): + name: Literal[True] + email: Literal[True] + posts: Literal[True] + +args1: Args1 +reveal_type(select_ex(User, args1)) # N: Revealed type is "builtins.list[TypedDict({'name': builtins.str, 'email': builtins.str, 'posts': builtins.list[TypedDict({'id': builtins.int, 'title': builtins.str, 'content': builtins.str})]})]" + +[builtins fixtures/typelevel.pyi] +[typing fixtures/typing-full.pyi] From 45d8d5b6d0b847fc6269cfcfb975dcdf22a86723 Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Wed, 28 Jan 2026 12:49:57 -0800 Subject: [PATCH 074/161] Some notes for the plan --- TYPEMAP_IMPLEMENTATION_PLAN.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/TYPEMAP_IMPLEMENTATION_PLAN.md b/TYPEMAP_IMPLEMENTATION_PLAN.md index 2c9175f3c92b5..6bd3f071bc4e9 100644 --- a/TYPEMAP_IMPLEMENTATION_PLAN.md +++ b/TYPEMAP_IMPLEMENTATION_PLAN.md @@ -1386,6 +1386,8 @@ def analyze_class_attribute_with_initfield( ### 8.1 Unit Tests +MAYBE? + Create comprehensive tests in `mypy/test/`: 1. **`test_typelevel_basic.py`** - Basic type operators @@ -1499,8 +1501,8 @@ Port examples from the PEP: ### 2b. Member/Param Accessors as Type Aliases **Decision**: `GetName`, `GetType`, `GetQuals`, `GetInit`, `GetDefiner` are type aliases using `GetAttr`, not separate type operators. Since `Member` and `Param` are regular classes with attributes, `GetAttr[Member[...], Literal["name"]]` works directly. -### 3. ComputedType Hierarchy (NOT ProperType) -**Decision**: All computed types (`TypeOperatorType`, `TypeForComprehension`) inherit from a common `ComputedType` base class. Like `TypeAliasType`, `ComputedType` is NOT a `ProperType` and must be expanded before use in most type operations. This is handled by a single `isinstance(typ, ComputedType)` check in `get_proper_type()`. Note: Conditional types are represented as `_Cond[...]` TypeOperatorType, not a separate class. +### 3. ComputedType Hierarchy +**Decision**: All computed types (`TypeOperatorType`, `TypeForComprehension`) inherit from a common `ComputedType` base class. Like `TypeAliasType`, `ComputedType` is (unfortunately) a `ProperType` *but* must still be expanded before use in most type operations. When expansion gets stuck, it returns the same type. This is handled by a single `isinstance(typ, ComputedType)` check in `get_proper_type()`. Note: Conditional types are represented as `_Cond[...]` TypeOperatorType, not a separate class. ### 4. Lazy Evaluation with Caching **Decision**: Type-level computations are evaluated when needed (e.g., during subtype checking) rather than immediately during parsing. Results should be cached. @@ -1547,6 +1549,7 @@ Port examples from the PEP: ## Open Questions for Discussion 1. **Protocol vs TypedDict creation**: Should `NewProtocol` create true protocols (with `is_protocol=True`) or just structural types? + **RESOLVED**: Obviously true protocols. 2. **Type alias recursion**: How to handle recursive type aliases that use type-level computation? From 912dd0df3d31a2573889e7ece11102d88bace7f4 Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Wed, 28 Jan 2026 12:54:17 -0800 Subject: [PATCH 075/161] Remove redundant Required qualifier for TypedDict members Required is the default for TypedDict fields, so there's no need to explicitly include it as a qualifier. Now only NotRequired and ReadOnly are included as qualifiers; required fields have Never as their quals. Co-Authored-By: Claude Opus 4.5 --- mypy/typelevel.py | 11 ++++++----- test-data/unit/check-typelevel-members.test | 18 +++++++++--------- 2 files changed, 15 insertions(+), 14 deletions(-) diff --git a/mypy/typelevel.py b/mypy/typelevel.py index affbfe8e7d930..be7c6a7e2861f 100644 --- a/mypy/typelevel.py +++ b/mypy/typelevel.py @@ -607,17 +607,18 @@ def _eval_typeddict_members( for name, item_type in target.items.items(): # Build qualifiers for TypedDict keys + # Required is the default, so only add NotRequired when not required quals: list[str] = [] - if name in target.required_keys: - quals.append("Required") - else: + if name not in target.required_keys: quals.append("NotRequired") if name in target.readonly_keys: quals.append("ReadOnly") # Create qualifier type - if len(quals) == 1: - quals_type: Type = evaluator.literal_str(quals[0]) + if len(quals) == 0: + quals_type: Type = UninhabitedType() + elif len(quals) == 1: + quals_type = evaluator.literal_str(quals[0]) else: quals_type = UnionType.make_union([evaluator.literal_str(q) for q in quals]) diff --git a/test-data/unit/check-typelevel-members.test b/test-data/unit/check-typelevel-members.test index a47607550d680..8564fce2e27bd 100644 --- a/test-data/unit/check-typelevel-members.test +++ b/test-data/unit/check-typelevel-members.test @@ -191,7 +191,7 @@ class Person(TypedDict): nickname: NotRequired[str] m: Members[Person] -reveal_type(m) # N: Revealed type is "tuple[typing.Member[Literal['name'], builtins.str, Literal['Required'], Never, Never], typing.Member[Literal['age'], builtins.int, Literal['Required'], Never, Never], typing.Member[Literal['nickname'], builtins.str, Literal['NotRequired'], Never, Never]]" +reveal_type(m) # N: Revealed type is "tuple[typing.Member[Literal['name'], builtins.str, Never, Never, Never], typing.Member[Literal['age'], builtins.int, Never, Never, Never], typing.Member[Literal['nickname'], builtins.str, Literal['NotRequired'], Never, Never]]" [builtins fixtures/typelevel.pyi] [typing fixtures/typing-full.pyi] @@ -204,7 +204,7 @@ class Config(TypedDict): version: ReadOnly[int] m: Members[Config] -reveal_type(m) # N: Revealed type is "tuple[typing.Member[Literal['name'], builtins.str, Literal['Required'], Never, Never], typing.Member[Literal['version'], builtins.int, Literal['Required'] | Literal['ReadOnly'], Never, Never]]" +reveal_type(m) # N: Revealed type is "tuple[typing.Member[Literal['name'], builtins.str, Never, Never, Never], typing.Member[Literal['version'], builtins.int, Literal['ReadOnly'], Never, Never]]" [builtins fixtures/typelevel.pyi] [typing fixtures/typing-full.pyi] @@ -220,7 +220,7 @@ class Extended(Base): email: NotRequired[str] m: Members[Extended] -reveal_type(m) # N: Revealed type is "tuple[typing.Member[Literal['id'], builtins.int, Literal['Required'], Never, Never], typing.Member[Literal['name'], builtins.str, Literal['Required'], Never, Never], typing.Member[Literal['email'], builtins.str, Literal['NotRequired'], Never, Never]]" +reveal_type(m) # N: Revealed type is "tuple[typing.Member[Literal['id'], builtins.int, Never, Never, Never], typing.Member[Literal['name'], builtins.str, Never, Never, Never], typing.Member[Literal['email'], builtins.str, Literal['NotRequired'], Never, Never]]" [builtins fixtures/typelevel.pyi] [typing fixtures/typing-full.pyi] @@ -231,18 +231,18 @@ from typing import Members, TypedDict Person = TypedDict('Person', {'name': str, 'age': int}) m: Members[Person] -reveal_type(m) # N: Revealed type is "tuple[typing.Member[Literal['name'], builtins.str, Literal['Required'], Never, Never], typing.Member[Literal['age'], builtins.int, Literal['Required'], Never, Never]]" +reveal_type(m) # N: Revealed type is "tuple[typing.Member[Literal['name'], builtins.str, Never, Never, Never], typing.Member[Literal['age'], builtins.int, Never, Never, Never]]" [builtins fixtures/typelevel.pyi] [typing fixtures/typing-full.pyi] [case testTypeOperatorNewTypedDict] -from typing import NewTypedDict, Member, Literal +from typing import NewTypedDict, Member, Literal, Never # Create a TypedDict from Member types TD = NewTypedDict[ - Member[Literal['name'], str, Literal['Required'], int, int], - Member[Literal['age'], int, Literal['Required'], int, int], + Member[Literal['name'], str, Never, int, int], + Member[Literal['age'], int, Never, int, int], ] x: TD @@ -253,11 +253,11 @@ x = {'name': 'Alice', 'age': 30} [typing fixtures/typing-full.pyi] [case testTypeOperatorNewTypedDictNotRequired] -from typing import NewTypedDict, Member, Literal +from typing import NewTypedDict, Member, Literal, Never # Create a TypedDict with NotRequired field TD = NewTypedDict[ - Member[Literal['name'], str, Literal['Required'], int, int], + Member[Literal['name'], str, Never, int, int], Member[Literal['nickname'], str, Literal['NotRequired'], int, int], ] From 499d8d6785a64531a8216805fa84820baf8ad1e3 Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Wed, 28 Jan 2026 12:56:32 -0800 Subject: [PATCH 076/161] Simplify qualifier type creation to always use make_union UnionType.make_union handles empty (returns Never), single (returns the item), and multiple (returns union) cases correctly. Co-Authored-By: Claude Opus 4.5 --- mypy/typelevel.py | 15 ++------------- 1 file changed, 2 insertions(+), 13 deletions(-) diff --git a/mypy/typelevel.py b/mypy/typelevel.py index be7c6a7e2861f..d81cdb7eb52be 100644 --- a/mypy/typelevel.py +++ b/mypy/typelevel.py @@ -145,12 +145,7 @@ def wrapper(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: if not (isinstance(result, ProperType) and isinstance(result, UninhabitedType)): results.append(result) - if not results: - return UninhabitedType() - elif len(results) == 1: - return results[0] - else: - return UnionType.make_union(results) + return UnionType.make_union(results) return wrapper @@ -614,13 +609,7 @@ def _eval_typeddict_members( if name in target.readonly_keys: quals.append("ReadOnly") - # Create qualifier type - if len(quals) == 0: - quals_type: Type = UninhabitedType() - elif len(quals) == 1: - quals_type = evaluator.literal_str(quals[0]) - else: - quals_type = UnionType.make_union([evaluator.literal_str(q) for q in quals]) + quals_type = UnionType.make_union([evaluator.literal_str(q) for q in quals]) member_type = Instance( member_type_info, From 5e294434079c382838e509dfb65ace0d3fc0d0fe Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Wed, 28 Jan 2026 13:09:50 -0800 Subject: [PATCH 077/161] EXTRACT: Add a commented quote to check-incremental.test to fix hilighting --- test-data/unit/check-incremental.test | 1 + 1 file changed, 1 insertion(+) diff --git a/test-data/unit/check-incremental.test b/test-data/unit/check-incremental.test index 5b256b1731e01..fb05514311b67 100644 --- a/test-data/unit/check-incremental.test +++ b/test-data/unit/check-incremental.test @@ -2339,6 +2339,7 @@ tmp/c.py:1: error: Module "d" has no attribute "x" [out2] mypy: error: cannot read file 'tmp/nonexistent.py': No such file or directory +-- balance the quotes to fix syntax hilighting ' [case testSerializeAbstractPropertyIncremental] from abc import abstractmethod import typing From 1c9e33579546af43a32c38b61a74149f02b2daff Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Wed, 28 Jan 2026 13:33:15 -0800 Subject: [PATCH 078/161] Fix serialization of TypeOperator --- mypy/fixup.py | 11 +++++-- mypy/subtypes.py | 2 +- mypy/typelevel.py | 2 +- mypy/types.py | 29 ++++++------------ test-data/unit/check-incremental.test | 44 +++++++++++++++++++++++++++ 5 files changed, 64 insertions(+), 24 deletions(-) diff --git a/mypy/fixup.py b/mypy/fixup.py index 437e4ae55b220..d59eb87e501bb 100644 --- a/mypy/fixup.py +++ b/mypy/fixup.py @@ -378,8 +378,15 @@ def visit_union_type(self, ut: UnionType) -> None: def visit_type_type(self, t: TypeType) -> None: t.item.accept(self) - def visit_type_operator_type(self, t: TypeOperatorType) -> None: - for a in t.args: + def visit_type_operator_type(self, op: TypeOperatorType) -> None: + type_ref = op.type_ref + if type_ref is None: + return # We've already been here. + op.type_ref = None + op.type = lookup_fully_qualified_typeinfo( + self.modules, type_ref, allow_missing=self.allow_missing + ) + for a in op.args: a.accept(self) def visit_type_for_comprehension(self, t: TypeForComprehension) -> None: diff --git a/mypy/subtypes.py b/mypy/subtypes.py index aebc8d4ef272a..a24bffa71295a 100644 --- a/mypy/subtypes.py +++ b/mypy/subtypes.py @@ -1165,7 +1165,7 @@ def visit_type_alias_type(self, left: TypeAliasType) -> bool: def visit_type_operator_type(self, left: TypeOperatorType) -> bool: # PERF: Using is_same_type can mean exponential time checking... if isinstance(self.right, TypeOperatorType): - if left.fullname == self.right.fullname and len(left.args) == len(self.right.args): + if left.type == self.right.type and len(left.args) == len(self.right.args): if all(is_same_type(la, ra) for la, ra in zip(left.args, self.right.args)): return True return self._is_subtype(left.fallback, self.right) diff --git a/mypy/typelevel.py b/mypy/typelevel.py index d81cdb7eb52be..4388cccffaeaa 100644 --- a/mypy/typelevel.py +++ b/mypy/typelevel.py @@ -181,7 +181,7 @@ def eval_proper(self, typ: Type) -> ProperType: def eval_operator(self, typ: TypeOperatorType) -> Type: """Evaluate a type operator by dispatching to registered handler.""" - evaluator = _OPERATOR_EVALUATORS.get(typ.name) + evaluator = _OPERATOR_EVALUATORS.get(typ.type.name) if evaluator is None: # print("NO EVALUATOR", fullname) diff --git a/mypy/types.py b/mypy/types.py index 8bd3944b0a322..e31cd9e32e337 100644 --- a/mypy/types.py +++ b/mypy/types.py @@ -519,7 +519,7 @@ class TypeOperatorType(ComputedType): def __init__( self, - type: mypy.nodes.TypeInfo | None, # The TypeInfo for the operator (e.g., typing.GetArg) + type: mypy.nodes.TypeInfo, # The TypeInfo for the operator (e.g., typing.GetArg) args: list[Type], # The type arguments fallback: Instance, line: int = -1, @@ -534,32 +534,21 @@ def __init__( def accept(self, visitor: TypeVisitor[T]) -> T: return visitor.visit_type_operator_type(self) - @property - def fullname(self) -> str: - if self.type is not None: - return self.type.fullname - assert self.type_ref is not None - return self.type_ref - - @property - def name(self) -> str: - return self.fullname.split(".")[-1] - def __hash__(self) -> int: - return hash((self.fullname, tuple(self.args))) + return hash((self.type, tuple(self.args))) def __eq__(self, other: object) -> bool: if not isinstance(other, TypeOperatorType): return NotImplemented - return self.fullname == other.fullname and self.args == other.args + return self.type == other.type and self.args == other.args def __repr__(self) -> str: - return f"TypeOperatorType({self.fullname}, {self.args})" + return f"TypeOperatorType({self.type.fullname}, {self.args})" def serialize(self) -> JsonDict: data: JsonDict = { ".class": "TypeOperatorType", - "type_ref": self.fullname, + "type_ref": self.type.fullname, "args": [arg.serialize() for arg in self.args], "fallback": self.fallback.serialize(), } @@ -574,8 +563,8 @@ def deserialize(cls, data: JsonDict) -> TypeOperatorType: assert isinstance(args_list, list) args = [deserialize_type(arg) for arg in args_list] fallback = Instance.deserialize(data["fallback"]) - typ = TypeOperatorType(None, args, fallback) - typ.type_ref = data["type_ref"] + typ = TypeOperatorType(NOT_READY, args, fallback) + typ.type_ref = data["type_ref"] # Will be fixed up by fixup.py later. return typ def copy_modified(self, *, args: list[Type] | None = None) -> TypeOperatorType: @@ -590,7 +579,7 @@ def copy_modified(self, *, args: list[Type] | None = None) -> TypeOperatorType: def write(self, data: WriteBuffer) -> None: write_tag(data, TYPE_OPERATOR_TYPE) write_type_list(data, self.args) - write_str(data, self.fullname) + write_str(data, self.type.fullname) self.fallback.write(data) write_tag(data, END_TAG) @@ -600,7 +589,7 @@ def read(cls, data: ReadBuffer) -> TypeOperatorType: type_ref = read_str(data) assert read_tag(data) == INSTANCE fallback = Instance.read(data) - typ = TypeOperatorType(None, args, fallback) + typ = TypeOperatorType(NOT_READY, args, fallback) typ.type_ref = type_ref assert read_tag(data) == END_TAG return typ diff --git a/test-data/unit/check-incremental.test b/test-data/unit/check-incremental.test index fb05514311b67..c9c6f8ce85c9e 100644 --- a/test-data/unit/check-incremental.test +++ b/test-data/unit/check-incremental.test @@ -239,6 +239,50 @@ def baz() -> int: [rechecked mod2] [stale] +[case testIncrementalTypeOperator] +# flags: --python-version 3.14 +import a +[file a.py] +from typing import ( + NewTypedDict, + Iter, + Attrs, + GetName, + GetType, + Member, +) + +type PropsOnly[T] = list[ + NewTypedDict[ + *[ + Member[GetName[p], GetType[p]] + for p in Iter[Attrs[T]] + ] + ] +] +[file a.py.2] +from typing import ( + NewTypedDict, + Iter, + Attrs, + GetName, + GetType, + Member, +) + +type PropsOnly[T] = list[ + NewTypedDict[ + *[ + Member[GetName[p], GetType[p]] + for p in Iter[Attrs[T]] + ] + ] +] +# dummy change + +[builtins fixtures/typelevel.pyi] +[typing fixtures/typing-full.pyi] + [case testIncrementalMethodInterfaceChange] import mod1 From 900ded8010d1858b50188f22b068e15da9ea0fd7 Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Wed, 28 Jan 2026 15:34:15 -0800 Subject: [PATCH 079/161] Implement RaiseError --- mypy/typelevel.py | 25 ++++++++++++++++++++ mypy/typeshed/stdlib/_typeshed/typemap.pyi | 9 ++++++++ mypy/typeshed/stdlib/typing.pyi | 2 ++ test-data/unit/check-typelevel-basic.test | 27 ++++++++++++++++++++++ test-data/unit/fixtures/typing-full.pyi | 7 ++++-- 5 files changed, 68 insertions(+), 2 deletions(-) diff --git a/mypy/typelevel.py b/mypy/typelevel.py index 4388cccffaeaa..c8b894891fbb0 100644 --- a/mypy/typelevel.py +++ b/mypy/typelevel.py @@ -755,6 +755,31 @@ def _eval_length(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: return UninhabitedType() +@register_operator("RaiseError") +def _eval_raise_error(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: + """Evaluate RaiseError[S] -> emit a type error with message S. + + RaiseError is used to emit custom type errors during type-level computation. + The argument must be a Literal[str] containing the error message. + Returns Never after emitting the error. + """ + + args = evaluator.flatten_args(typ.args) + if not args: + msg = "RaiseError called without arguments!" + else: + msg = extract_literal_string(args[0]) or str(args[0]) + + if args[1:]: + msg += ": " + ", ".join(str(t) for t in args[1:]) + + # Use serious=True to bypass in_checked_function() check which requires + # self.options to be set on the SemanticAnalyzer + evaluator.api.fail(msg, typ, serious=True) + + return UninhabitedType() + + def evaluate_comprehension(evaluator: TypeLevelEvaluator, typ: TypeForComprehension) -> Type: """Evaluate a TypeForComprehension. diff --git a/mypy/typeshed/stdlib/_typeshed/typemap.pyi b/mypy/typeshed/stdlib/_typeshed/typemap.pyi index 8a988d01039fb..244c757683421 100644 --- a/mypy/typeshed/stdlib/_typeshed/typemap.pyi +++ b/mypy/typeshed/stdlib/_typeshed/typemap.pyi @@ -272,3 +272,12 @@ class Length(Generic[_T]): """ ... + +@_type_operator +class RaiseError(Generic[_S, Unpack[_Ts]]): + """ + Emit a type error with the given message. + RaiseError[Literal["error message"]] emits the error and returns Never. + """ + + ... diff --git a/mypy/typeshed/stdlib/typing.pyi b/mypy/typeshed/stdlib/typing.pyi index f662de3d70565..6fee8cfe0ccd8 100644 --- a/mypy/typeshed/stdlib/typing.pyi +++ b/mypy/typeshed/stdlib/typing.pyi @@ -1211,6 +1211,7 @@ if sys.version_info >= (3, 15): "GetAnnotations", "DropAnnotations", "Length", + "RaiseError", # Data types "Member", "Param", @@ -1267,6 +1268,7 @@ if sys.version_info >= (3, 15): ParamQuals as ParamQuals, PosDefaultParam as PosDefaultParam, PosParam as PosParam, + RaiseError as RaiseError, Slice as Slice, Uncapitalize as Uncapitalize, Uppercase as Uppercase, diff --git a/test-data/unit/check-typelevel-basic.test b/test-data/unit/check-typelevel-basic.test index 8a4ea19fc4ffc..337f940605e97 100644 --- a/test-data/unit/check-typelevel-basic.test +++ b/test-data/unit/check-typelevel-basic.test @@ -619,5 +619,32 @@ td: Sigh = {'x': 10, 'y': 'hmmm'} x = foo(td) reveal_type(x) # N: Revealed type is "tuple[Literal['x'], Literal['y']]" +[builtins fixtures/typelevel.pyi] +[typing fixtures/typing-full.pyi] + +[case testTypeOperatorRaiseError] +from typing import RaiseError, Literal + +x: RaiseError[Literal['custom error message']] # E: custom error message +reveal_type(x) # N: Revealed type is "Never" + +y: RaiseError[Literal['custom error message'], str] # E: custom error message: str + + +[builtins fixtures/typelevel.pyi] +[typing fixtures/typing-full.pyi] + +[case testTypeOperatorRaiseErrorInConditional] +from typing import RaiseError, IsSub, Literal + +# RaiseError in a conditional - error only if condition triggers it +T = int +x: str if IsSub[T, str] else RaiseError[Literal['T must be a str']] # E: T must be a str +reveal_type(x) # N: Revealed type is "Never" + +y: str if IsSub[T, int] else RaiseError[Literal['T must be a int']] +reveal_type(y) # N: Revealed type is "builtins.str" + + [builtins fixtures/typelevel.pyi] [typing fixtures/typing-full.pyi] diff --git a/test-data/unit/fixtures/typing-full.pyi b/test-data/unit/fixtures/typing-full.pyi index e62e90092f004..ffb55d14d7fef 100644 --- a/test-data/unit/fixtures/typing-full.pyi +++ b/test-data/unit/fixtures/typing-full.pyi @@ -241,6 +241,8 @@ class BaseTypedDict(TypedDict): def _type_operator(cls: type[T]) -> type[T]: ... +_Ts = TypeVarTuple("_Ts") + @_type_operator class Iter(Generic[T]): ... @@ -280,14 +282,15 @@ class Uncapitalize(Generic[T]): ... @_type_operator class Length(Generic[T]): ... +@_type_operator +class RaiseError(Generic[T, Unpack[_Ts]]): ... + @_type_operator class Members(Generic[T]): ... @_type_operator class Attrs(Generic[T]): ... -_Ts = TypeVarTuple("_Ts") - @_type_operator class NewTypedDict(Generic[Unpack[_Ts]]): ... From a1c628016981dccc7999289ef3d7b178b7696113 Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Wed, 28 Jan 2026 15:40:48 -0800 Subject: [PATCH 080/161] Extend Slice operator to support tuple types In addition to slicing literal strings, Slice now supports slicing tuple types: Slice[tuple[int, str, float], Literal[1], None] -> tuple[str, float] Co-Authored-By: Claude Opus 4.5 --- mypy/typelevel.py | 11 +++++++++-- test-data/unit/check-typelevel-basic.test | 21 +++++++++++++++++++++ 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/mypy/typelevel.py b/mypy/typelevel.py index c8b894891fbb0..59354cc13a45c 100644 --- a/mypy/typelevel.py +++ b/mypy/typelevel.py @@ -399,11 +399,11 @@ def _eval_get_attr(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type @register_operator("Slice") @lift_over_unions def _eval_slice(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: - """Evaluate Slice[S, Start, End] - slice a literal string.""" + """Evaluate Slice[S, Start, End] - slice a literal string or tuple type.""" if len(typ.args) != 3: return UninhabitedType() - s = extract_literal_string(evaluator.eval_proper(typ.args[0])) + target = evaluator.eval_proper(typ.args[0]) # Handle start - can be int or None start_type = evaluator.eval_proper(typ.args[1]) @@ -423,10 +423,17 @@ def _eval_slice(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: if end is None: return UninhabitedType() + # Handle literal string slicing + s = extract_literal_string(target) if s is not None: result = s[start:end] return evaluator.literal_str(result) + # Handle tuple type slicing + if isinstance(target, TupleType): + sliced_items = target.items[start:end] + return evaluator.tuple_type(sliced_items) + return UninhabitedType() diff --git a/test-data/unit/check-typelevel-basic.test b/test-data/unit/check-typelevel-basic.test index 337f940605e97..81214dd334e86 100644 --- a/test-data/unit/check-typelevel-basic.test +++ b/test-data/unit/check-typelevel-basic.test @@ -257,6 +257,27 @@ reveal_type(x2) # N: Revealed type is "Literal['he']" [builtins fixtures/typelevel.pyi] [typing fixtures/typing-full.pyi] +[case testTypeOperatorTupleSlice] +# Test tuple slice operator +from typing import Slice, Literal + +x1: Slice[tuple[int, str, float, bool], Literal[1], Literal[3]] +reveal_type(x1) # N: Revealed type is "tuple[builtins.str, builtins.float]" + +x2: Slice[tuple[int, str, float], Literal[0], Literal[2]] +reveal_type(x2) # N: Revealed type is "tuple[builtins.int, builtins.str]" + +# Slice from start +x3: Slice[tuple[int, str, float], None, Literal[2]] +reveal_type(x3) # N: Revealed type is "tuple[builtins.int, builtins.str]" + +# Slice to end +x4: Slice[tuple[int, str, float], Literal[1], None] +reveal_type(x4) # N: Revealed type is "tuple[builtins.str, builtins.float]" + +[builtins fixtures/typelevel.pyi] +[typing fixtures/typing-full.pyi] + [case testTypeOperatorLength] # Test Length operator from typing import Length, Literal, Tuple From 30b0a5484d681ebd3609fe9a199653d601784b0b Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Wed, 28 Jan 2026 15:47:28 -0800 Subject: [PATCH 081/161] Implement Matches and Bool type operators - Matches[T, S]: Returns Literal[True] if T and S are equivalent types (i.e., T is subtype of S AND S is subtype of T) - Bool[T]: Returns Literal[True] if T is Literal[True] or a union containing it. Useful for checking type-level boolean results. Co-Authored-By: Claude Opus 4.5 --- mypy/typelevel.py | 50 ++++++++++++++++++++++ mypy/typeshed/stdlib/_typeshed/typemap.pyi | 20 +++++++++ mypy/typeshed/stdlib/typing.pyi | 4 ++ test-data/unit/check-typelevel-basic.test | 46 ++++++++++++++++++++ test-data/unit/fixtures/typing-full.pyi | 6 +++ 5 files changed, 126 insertions(+) diff --git a/mypy/typelevel.py b/mypy/typelevel.py index 59354cc13a45c..63859c061056c 100644 --- a/mypy/typelevel.py +++ b/mypy/typelevel.py @@ -285,6 +285,56 @@ def _eval_issub(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: return evaluator.literal_bool(result) +@register_operator("Matches") +def _eval_matches(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: + """Evaluate Matches[T, S] - check if T and S are equivalent types. + + Returns Literal[True] if T is a subtype of S AND S is a subtype of T. + Equivalent to: IsSub[T, S] and IsSub[S, T] + """ + if len(typ.args) != 2: + return UninhabitedType() + + lhs, rhs = typ.args + + left_proper = evaluator.eval_proper(lhs) + right_proper = evaluator.eval_proper(rhs) + + # Handle type variables - may be undecidable + if has_type_vars(left_proper) or has_type_vars(right_proper): + return EXPANSION_ANY + + # Both directions must hold for type equivalence + result = is_subtype(left_proper, right_proper) and is_subtype(right_proper, left_proper) + + return evaluator.literal_bool(result) + + +@register_operator("Bool") +def _eval_bool(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: + """Evaluate Bool[T] - check if T contains Literal[True]. + + Returns Literal[True] if T is Literal[True] or a union containing Literal[True]. + Equivalent to: IsSub[Literal[True], T] and not IsSub[T, Never] + """ + if len(typ.args) != 1: + return UninhabitedType() + + arg_proper = evaluator.eval_proper(typ.args[0]) + + # Handle type variables - may be undecidable + if has_type_vars(arg_proper): + return EXPANSION_ANY + + # Check if Literal[True] is a subtype of arg (i.e., arg contains True) + # and arg is not Never + literal_true = evaluator.literal_bool(True) + contains_true = is_subtype(literal_true, arg_proper) + is_never = isinstance(arg_proper, UninhabitedType) + + return evaluator.literal_bool(contains_true and not is_never) + + def extract_literal_bool(typ: Type) -> bool | None: """Extract bool value from LiteralType.""" typ = get_proper_type(typ) diff --git a/mypy/typeshed/stdlib/_typeshed/typemap.pyi b/mypy/typeshed/stdlib/_typeshed/typemap.pyi index 244c757683421..bf830084fc838 100644 --- a/mypy/typeshed/stdlib/_typeshed/typemap.pyi +++ b/mypy/typeshed/stdlib/_typeshed/typemap.pyi @@ -187,6 +187,26 @@ class IsSub(Generic[_T, _Base]): ... +@_type_operator +class Matches(Generic[_T, _S]): + """ + Type equivalence check. Returns Literal[True] if T is a subtype of S + AND S is a subtype of T. + Equivalent to: IsSub[T, S] and IsSub[S, T] + """ + + ... + +@_type_operator +class Bool(Generic[_T]): + """ + Check if T contains Literal[True]. + Returns Literal[True] if T is Literal[True] or a union containing it. + Equivalent to: IsSub[Literal[True], T] and not IsSub[T, Never] + """ + + ... + @_type_operator class Iter(Generic[_T]): """ diff --git a/mypy/typeshed/stdlib/typing.pyi b/mypy/typeshed/stdlib/typing.pyi index 6fee8cfe0ccd8..20eef734984ee 100644 --- a/mypy/typeshed/stdlib/typing.pyi +++ b/mypy/typeshed/stdlib/typing.pyi @@ -1201,6 +1201,8 @@ if sys.version_info >= (3, 15): "NewProtocol", "NewTypedDict", "IsSub", + "Matches", + "Bool", "Iter", "Slice", "Concat", @@ -1238,6 +1240,7 @@ if sys.version_info >= (3, 15): Attrs as Attrs, ArgsParam as ArgsParam, BaseTypedDict as BaseTypedDict, + Bool as Bool, Capitalize as Capitalize, Concat as Concat, DefaultParam as DefaultParam, @@ -1257,6 +1260,7 @@ if sys.version_info >= (3, 15): KwargsParam as KwargsParam, Length as Length, Lowercase as Lowercase, + Matches as Matches, Member as Member, MemberQuals as MemberQuals, Members as Members, diff --git a/test-data/unit/check-typelevel-basic.test b/test-data/unit/check-typelevel-basic.test index 81214dd334e86..9370c53b2a901 100644 --- a/test-data/unit/check-typelevel-basic.test +++ b/test-data/unit/check-typelevel-basic.test @@ -11,6 +11,52 @@ reveal_type(y) # N: Revealed type is "Literal[False]" [builtins fixtures/typelevel.pyi] [typing fixtures/typing-full.pyi] +[case testTypeOperatorMatches] +from typing import Matches, Literal + +# Matches checks type equivalence (both directions of subtype) +x1: Matches[int, int] +reveal_type(x1) # N: Revealed type is "Literal[True]" + +x2: Matches[int, object] +reveal_type(x2) # N: Revealed type is "Literal[False]" + +x3: Matches[Literal[1], Literal[1]] +reveal_type(x3) # N: Revealed type is "Literal[True]" + +x4: Matches[Literal[1], int] +reveal_type(x4) # N: Revealed type is "Literal[False]" + +[builtins fixtures/typelevel.pyi] +[typing fixtures/typing-full.pyi] + +[case testTypeOperatorBool] +# flags: --python-version 3.10 +from typing import Bool, Literal, IsSub, Union + +# Bool checks if type contains Literal[True] +x1: Bool[Literal[True]] +reveal_type(x1) # N: Revealed type is "Literal[True]" + +x2: Bool[Literal[False]] +reveal_type(x2) # N: Revealed type is "Literal[False]" + +x3: Bool[Union[Literal[True], Literal[False]]] +reveal_type(x3) # N: Revealed type is "Literal[True]" + +x4: Bool[bool] +reveal_type(x4) # N: Revealed type is "Literal[True]" + +# Using Bool to check result of IsSub +x5: Bool[IsSub[int, object]] +reveal_type(x5) # N: Revealed type is "Literal[True]" + +x6: Bool[IsSub[int, str]] +reveal_type(x6) # N: Revealed type is "Literal[False]" + +[builtins fixtures/typelevel.pyi] +[typing fixtures/typing-full.pyi] + [case testTypeOperatorTernarySyntax] from typing import IsSub diff --git a/test-data/unit/fixtures/typing-full.pyi b/test-data/unit/fixtures/typing-full.pyi index ffb55d14d7fef..94b7b160a0901 100644 --- a/test-data/unit/fixtures/typing-full.pyi +++ b/test-data/unit/fixtures/typing-full.pyi @@ -249,6 +249,12 @@ class Iter(Generic[T]): ... @_type_operator class IsSub(Generic[T, U]): ... +@_type_operator +class Matches(Generic[T, U]): ... + +@_type_operator +class Bool(Generic[T]): ... + @_type_operator class GetArg(Generic[T, U, V]): ... From d92e026a9ea840ef275954ce9f752113077ca6db Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Wed, 28 Jan 2026 16:15:59 -0800 Subject: [PATCH 082/161] Display RaiseError messages from the alias invocation site --- mypy/typelevel.py | 17 ++++++++++++----- mypy/types.py | 7 ++++--- test-data/unit/check-typelevel-basic.test | 5 +++++ 3 files changed, 21 insertions(+), 8 deletions(-) diff --git a/mypy/typelevel.py b/mypy/typelevel.py index 63859c061056c..2361ab196c7ac 100644 --- a/mypy/typelevel.py +++ b/mypy/typelevel.py @@ -16,7 +16,7 @@ from mypy.expandtype import expand_type, expand_type_by_instance from mypy.maptype import map_instance_to_supertype -from mypy.nodes import FuncDef, Var +from mypy.nodes import Context, FuncDef, Var from mypy.subtypes import is_subtype from mypy.types import ( AnyType, @@ -157,8 +157,9 @@ class EvaluationStuck(Exception): class TypeLevelEvaluator: """Evaluates type-level computations to concrete types.""" - def __init__(self, api: SemanticAnalyzerInterface) -> None: + def __init__(self, api: SemanticAnalyzerInterface, ctx: Context | None) -> None: self.api = api + self.ctx = ctx def evaluate(self, typ: Type) -> Type: """Main entry point: evaluate a type to its simplified form.""" @@ -830,9 +831,11 @@ def _eval_raise_error(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> T if args[1:]: msg += ": " + ", ".join(str(t) for t in args[1:]) + # TODO: We could also print a stack trace? + ctx = evaluator.ctx or typ # Use serious=True to bypass in_checked_function() check which requires # self.options to be set on the SemanticAnalyzer - evaluator.api.fail(msg, typ, serious=True) + evaluator.api.fail(msg, ctx, serious=True) return UninhabitedType() @@ -897,15 +900,19 @@ def get_type_args_for_base(instance: Instance, base_type: TypeInfo) -> tuple[Typ # --- Public API --- -def evaluate_computed_type(typ: ComputedType) -> Type: +def evaluate_computed_type(typ: ComputedType, ctx: Context | None = None) -> Type: """Evaluate a ComputedType. Called from ComputedType.expand(). Uses typelevel_ctx.api to access the semantic analyzer. + + The ctx argument indicates where an error message from RaiseError + ought to be placed. TODO: Make it a stack of contexts maybe? + """ if typelevel_ctx.api is None: raise AssertionError("No access to semantic analyzer!") - evaluator = TypeLevelEvaluator(typelevel_ctx.api) + evaluator = TypeLevelEvaluator(typelevel_ctx.api, ctx) try: res = evaluator.evaluate(typ) except EvaluationStuck: diff --git a/mypy/types.py b/mypy/types.py index e31cd9e32e337..05baed5aad27d 100644 --- a/mypy/types.py +++ b/mypy/types.py @@ -493,7 +493,7 @@ class ComputedType(ProperType): __slots__ = () - def expand(self) -> Type: + def expand(self, ctx: mypy.nodes.Context | None = None) -> Type: """Evaluate this computed type to produce a concrete type. Returns self if evaluation is not yet possible (e.g., contains unresolved type vars). @@ -505,7 +505,7 @@ def expand(self) -> Type: """ from mypy.typelevel import evaluate_computed_type - return evaluate_computed_type(self) + return evaluate_computed_type(self, ctx) class TypeOperatorType(ComputedType): @@ -3949,6 +3949,7 @@ def get_proper_type(typ: Type | None) -> ProperType | None: This also *attempts* to expand computed types, though it might fail. """ + ctx = typ if typ is None: return None # TODO: this is an ugly hack, remove. @@ -3960,7 +3961,7 @@ def get_proper_type(typ: Type | None) -> ProperType | None: typ = typ._expand_once() elif isinstance(typ, ComputedType): # Handles TypeOperatorType, TypeForComprehension - if not is_stuck_expansion(ntyp := typ.expand()): + if not is_stuck_expansion(ntyp := typ.expand(ctx)): typ = ntyp else: break diff --git a/test-data/unit/check-typelevel-basic.test b/test-data/unit/check-typelevel-basic.test index 9370c53b2a901..16d3fed0df871 100644 --- a/test-data/unit/check-typelevel-basic.test +++ b/test-data/unit/check-typelevel-basic.test @@ -690,6 +690,8 @@ reveal_type(x) # N: Revealed type is "tuple[Literal['x'], Literal['y']]" [typing fixtures/typing-full.pyi] [case testTypeOperatorRaiseError] +# flags: --python-version 3.14 + from typing import RaiseError, Literal x: RaiseError[Literal['custom error message']] # E: custom error message @@ -697,6 +699,9 @@ reveal_type(x) # N: Revealed type is "Never" y: RaiseError[Literal['custom error message'], str] # E: custom error message: str +type Alias[T] = RaiseError[Literal['wrong'], T] + +z: Alias[bool] # E: wrong: bool [builtins fixtures/typelevel.pyi] [typing fixtures/typing-full.pyi] From 1bac502a86f7a9218dd9f8815e5f61ecd3e0f77f Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Wed, 28 Jan 2026 16:29:39 -0800 Subject: [PATCH 083/161] DEBUG: Make TypeStrVisitor more able to print from expanding --- mypy/nodes.py | 4 ++-- mypy/strconv.py | 11 +++++++++-- mypy/test/testmerge.py | 2 +- mypy/types.py | 33 ++++++++++++++++++++------------- 4 files changed, 32 insertions(+), 18 deletions(-) diff --git a/mypy/nodes.py b/mypy/nodes.py index 51d79249a3c38..260bc9c0f5dd1 100644 --- a/mypy/nodes.py +++ b/mypy/nodes.py @@ -4075,7 +4075,7 @@ def __str__(self) -> str: options = Options() return self.dump( str_conv=mypy.strconv.StrConv(options=options), - type_str_conv=mypy.types.TypeStrVisitor(options=options), + type_str_conv=mypy.types.TypeStrVisitor(options=options, expand=True), ) def dump( @@ -4836,7 +4836,7 @@ def __str__(self) -> str: s += f" ({self.node.fullname})" # Include declared type of variables and functions. if self.type is not None: - s += f" : {self.type}" + s += f" : {self.type.str_with_options(expand=True)}" if self.cross_ref: s += f" cross_ref:{self.cross_ref}" return s diff --git a/mypy/strconv.py b/mypy/strconv.py index b26f1d8d71a8e..2b943d5122a74 100644 --- a/mypy/strconv.py +++ b/mypy/strconv.py @@ -41,7 +41,9 @@ def __init__(self, *, show_ids: bool = False, options: Options) -> None: def stringify_type(self, t: mypy.types.Type) -> str: import mypy.types - return t.accept(mypy.types.TypeStrVisitor(id_mapper=self.id_mapper, options=self.options)) + return t.accept( + mypy.types.TypeStrVisitor(id_mapper=self.id_mapper, options=self.options, expand=True) + ) def get_id(self, o: object) -> int | None: if self.id_mapper: @@ -691,7 +693,12 @@ def dump_tagged(nodes: Sequence[object], tag: str | None, str_conv: StrConv) -> a.append(indent(n.accept(str_conv), 2)) elif isinstance(n, Type): a.append( - indent(n.accept(TypeStrVisitor(str_conv.id_mapper, options=str_conv.options)), 2) + indent( + n.accept( + TypeStrVisitor(str_conv.id_mapper, options=str_conv.options, expand=True) + ), + 2, + ) ) elif n is not None: a.append(indent(str(n), 2)) diff --git a/mypy/test/testmerge.py b/mypy/test/testmerge.py index 2e88b519f7f85..c070b12455f3c 100644 --- a/mypy/test/testmerge.py +++ b/mypy/test/testmerge.py @@ -44,7 +44,7 @@ def setup(self) -> None: self.str_conv = StrConv(show_ids=True, options=Options()) assert self.str_conv.id_mapper is not None self.id_mapper: IdMapper = self.str_conv.id_mapper - self.type_str_conv = TypeStrVisitor(self.id_mapper, options=Options()) + self.type_str_conv = TypeStrVisitor(self.id_mapper, options=Options(), expand=True) def run_case(self, testcase: DataDrivenTestCase) -> None: name = testcase.name diff --git a/mypy/types.py b/mypy/types.py index 05baed5aad27d..83fbd86e9ef57 100644 --- a/mypy/types.py +++ b/mypy/types.py @@ -307,8 +307,8 @@ def accept(self, visitor: TypeVisitor[T]) -> T: def __repr__(self) -> str: return self.accept(TypeStrVisitor(options=Options())) - def str_with_options(self, options: Options) -> str: - return self.accept(TypeStrVisitor(options=options)) + def str_with_options(self, options: Options | None = None, expand: bool = False) -> str: + return self.accept(TypeStrVisitor(options=options or Options(), expand=expand)) def serialize(self) -> JsonDict | str: raise NotImplementedError(f"Cannot serialize {self.__class__.__name__} instance") @@ -4342,16 +4342,23 @@ def visit_placeholder_type(self, t: PlaceholderType, /) -> str: def visit_type_alias_type(self, t: TypeAliasType, /) -> str: if t.alias is None: return "" - if not t.is_recursive: - return get_proper_type(t).accept(self) - if self.dotted_aliases is None: - self.dotted_aliases = set() - elif t in self.dotted_aliases: - return "..." - self.dotted_aliases.add(t) - type_str = get_proper_type(t).accept(self) - self.dotted_aliases.discard(t) - return type_str + + if self.expand: + if not t.is_recursive: + return get_proper_type(t).accept(self) + if self.dotted_aliases is None: + self.dotted_aliases = set() + elif t in self.dotted_aliases: + return "..." + self.dotted_aliases.add(t) + type_str = get_proper_type(t).accept(self) + self.dotted_aliases.discard(t) + return type_str + else: + s = t.alias.fullname + if t.args: + s += f"[{self.list_str(t.args)}]" + return s def visit_unpack_type(self, t: UnpackType, /) -> str: if not self.options.reveal_verbose_types: @@ -4381,7 +4388,7 @@ def list_str(self, a: Iterable[Type], *, use_or_syntax: bool = False) -> str: res = [] for t in a: s = t.accept(self) - if use_or_syntax and isinstance(get_proper_type(t), CallableType): + if use_or_syntax and isinstance(get_proper_type_simple(t), CallableType): res.append(f"({s})") else: res.append(s) From b09bd23eefa2aeebe9aa08348f06682390da0d6e Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Thu, 29 Jan 2026 11:24:22 -0800 Subject: [PATCH 084/161] Fix Length for empty tuples and unbounded tuples --- mypy/typelevel.py | 10 +++++++--- test-data/unit/check-typelevel-basic.test | 14 +++++++++++++- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/mypy/typelevel.py b/mypy/typelevel.py index 2361ab196c7ac..01a39c62bce17 100644 --- a/mypy/typelevel.py +++ b/mypy/typelevel.py @@ -805,10 +805,14 @@ def _eval_length(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: target = evaluator.eval_proper(typ.args[0]) if isinstance(target, TupleType): - # Check for unbounded tuple (has ..., represented by partial_fallback) - if target.partial_fallback and not target.items: - return NoneType() # Unbounded tuple returns None + # If there is an Unpack, it must be of an unbounded tuple, or + # it would have been substituted out. + # TODO: Or would it be stuck? Think. + if any(isinstance(st, UnpackType) for st in target.items): + return NoneType() return evaluator.literal_int(len(target.items)) + if isinstance(target, Instance) and target.type.has_base("builtins.tuple"): + return NoneType() return UninhabitedType() diff --git a/test-data/unit/check-typelevel-basic.test b/test-data/unit/check-typelevel-basic.test index 16d3fed0df871..b88d835b0a0e4 100644 --- a/test-data/unit/check-typelevel-basic.test +++ b/test-data/unit/check-typelevel-basic.test @@ -326,7 +326,7 @@ reveal_type(x4) # N: Revealed type is "tuple[builtins.str, builtins.float]" [case testTypeOperatorLength] # Test Length operator -from typing import Length, Literal, Tuple +from typing import Length, Literal, Iter, Tuple x: Length[tuple[int, str, float]] reveal_type(x) # N: Revealed type is "Literal[3]" @@ -334,6 +334,18 @@ reveal_type(x) # N: Revealed type is "Literal[3]" y: Length[tuple[int]] reveal_type(y) # N: Revealed type is "Literal[1]" +z: Length[tuple[()]] +reveal_type(z) # N: Revealed type is "Literal[0]" + +w: Length[tuple[int, ...]] +reveal_type(w) # N: Revealed type is "None" + +a: Length[tuple[*[x for x in Iter[tuple[int, str, bool]]]]] +reveal_type(a) # N: Revealed type is "Literal[3]" + +b: Length[tuple[str, *tuple[int, ...]]] +reveal_type(b) # N: Revealed type is "None" + [builtins fixtures/typelevel.pyi] [typing fixtures/typing-full.pyi] From a7cdd85934c774f2078bafc19596bb61da42bbd0 Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Thu, 29 Jan 2026 11:45:11 -0800 Subject: [PATCH 085/161] Try switching from tracking TypeAliasType to TypeAlias in recursion checks --- mypy/semanal_typeargs.py | 12 ++++++------ mypy/type_visitor.py | 20 +++++++++++++------- 2 files changed, 19 insertions(+), 13 deletions(-) diff --git a/mypy/semanal_typeargs.py b/mypy/semanal_typeargs.py index 46a30e5d7442f..07aa87dda51fb 100644 --- a/mypy/semanal_typeargs.py +++ b/mypy/semanal_typeargs.py @@ -15,7 +15,7 @@ from mypy.message_registry import INVALID_PARAM_SPEC_LOCATION, INVALID_PARAM_SPEC_LOCATION_NOTE from mypy.messages import format_type from mypy.mixedtraverser import MixedTraverserVisitor -from mypy.nodes import Block, ClassDef, Context, FakeInfo, FuncItem, MypyFile +from mypy.nodes import Block, ClassDef, Context, FakeInfo, FuncItem, MypyFile, TypeAlias from mypy.options import Options from mypy.scope import Scope from mypy.subtypes import is_same_type, is_subtype @@ -61,7 +61,7 @@ def __init__( self.recurse_into_functions = True # Keep track of the type aliases already visited. This is needed to avoid # infinite recursion on types like A = Union[int, List[A]]. - self.seen_aliases: set[TypeAliasType] = set() + self.seen_aliases: set[TypeAlias] = set() def visit_mypy_file(self, o: MypyFile) -> None: self.errors.set_file(o.path, o.fullname, scope=self.scope, options=self.options) @@ -84,12 +84,12 @@ def visit_block(self, o: Block) -> None: def visit_type_alias_type(self, t: TypeAliasType) -> None: super().visit_type_alias_type(t) + assert t.alias is not None, f"Unfixed type alias {t.type_ref}" if t.is_recursive: - if t in self.seen_aliases: + if t.alias in self.seen_aliases: # Avoid infinite recursion on recursive type aliases. return - self.seen_aliases.add(t) - assert t.alias is not None, f"Unfixed type alias {t.type_ref}" + self.seen_aliases.add(t.alias) is_error, is_invalid = self.validate_args( t.alias.name, tuple(t.args), t.alias.alias_tvars, t ) @@ -106,7 +106,7 @@ def visit_type_alias_type(self, t: TypeAliasType) -> None: # to verify the arguments satisfy the upper bounds of the expansion as well. get_proper_type(t).accept(self) if t.is_recursive: - self.seen_aliases.discard(t) + self.seen_aliases.discard(t.alias) def visit_tuple_type(self, t: TupleType) -> None: t.items = flatten_nested_tuples(t.items) diff --git a/mypy/type_visitor.py b/mypy/type_visitor.py index 91ae19bcb8656..093418bcee8fb 100644 --- a/mypy/type_visitor.py +++ b/mypy/type_visitor.py @@ -15,7 +15,7 @@ from abc import abstractmethod from collections.abc import Iterable, Sequence -from typing import Any, Final, Generic, TypeVar, cast +from typing import TYPE_CHECKING, Any, Final, Generic, TypeVar, cast from mypy_extensions import mypyc_attr, trait @@ -53,6 +53,10 @@ get_proper_type, ) +if TYPE_CHECKING: + from mypy.nodes import TypeAlias + + T = TypeVar("T", covariant=True) @@ -378,7 +382,7 @@ class TypeQuery(SyntheticTypeVisitor[T]): def __init__(self) -> None: # Keep track of the type aliases already visited. This is needed to avoid # infinite recursion on types like A = Union[int, List[A]]. - self.seen_aliases: set[TypeAliasType] | None = None + self.seen_aliases: set[TypeAlias] | None = None # By default, we eagerly expand type aliases, and query also types in the # alias target. In most cases this is a desired behavior, but we may want # to skip targets in some cases (e.g. when collecting type variables). @@ -471,9 +475,10 @@ def visit_type_alias_type(self, t: TypeAliasType, /) -> T: # (also use this as a simple-minded cache). if self.seen_aliases is None: self.seen_aliases = set() - elif t in self.seen_aliases: + elif t.alias in self.seen_aliases: return self.strategy([]) - self.seen_aliases.add(t) + if t.alias: + self.seen_aliases.add(t.alias) return get_proper_type(t).accept(self) def visit_type_operator_type(self, t: TypeOperatorType, /) -> T: @@ -516,7 +521,7 @@ def __init__(self, strategy: int) -> None: # Keep track of the type aliases already visited. This is needed to avoid # infinite recursion on types like A = Union[int, List[A]]. An empty set is # represented as None as a micro-optimization. - self.seen_aliases: set[TypeAliasType] | None = None + self.seen_aliases: set[TypeAlias] | None = None # By default, we eagerly expand type aliases, and query also types in the # alias target. In most cases this is a desired behavior, but we may want # to skip targets in some cases (e.g. when collecting type variables). @@ -618,9 +623,10 @@ def visit_type_alias_type(self, t: TypeAliasType, /) -> bool: # (also use this as a simple-minded cache). if self.seen_aliases is None: self.seen_aliases = set() - elif t in self.seen_aliases: + elif t.alias in self.seen_aliases: return self.default - self.seen_aliases.add(t) + if t.alias: + self.seen_aliases.add(t.alias) return get_proper_type(t).accept(self) def visit_type_operator_type(self, t: TypeOperatorType, /) -> bool: From 14ae4ae1a76a427e7f63ff3a065f03eccc90ea1f Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Thu, 29 Jan 2026 13:23:29 -0800 Subject: [PATCH 086/161] Make GetArg work on tuples, make negative indexes work --- mypy/typelevel.py | 50 +++++++++----- test-data/unit/check-typelevel-basic.test | 82 +++++++++++++++++++++++ 2 files changed, 114 insertions(+), 18 deletions(-) diff --git a/mypy/typelevel.py b/mypy/typelevel.py index 01a39c62bce17..8ea158428eb52 100644 --- a/mypy/typelevel.py +++ b/mypy/typelevel.py @@ -10,7 +10,7 @@ from __future__ import annotations import itertools -from collections.abc import Callable, Iterator +from collections.abc import Callable, Iterator, Sequence from contextlib import contextmanager from typing import TYPE_CHECKING, Final @@ -364,6 +364,25 @@ def extract_literal_string(typ: Type) -> str | None: return None +def _get_args(evaluator: TypeLevelEvaluator, target: Type, base: Type) -> Sequence[Type] | None: + target = evaluator.eval_proper(target) + base = evaluator.eval_proper(base) + + # TODO: Other cases + if isinstance(target, Instance) and isinstance(base, Instance): + return get_type_args_for_base(target, base.type) + + if ( + isinstance(target, TupleType) + and isinstance(base, Instance) + # XXX: Do a real check + and target.partial_fallback == base + ): + return target.items + + return None + + @register_operator("GetArg") @lift_over_unions def _eval_get_arg(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: @@ -371,20 +390,20 @@ def _eval_get_arg(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: if len(typ.args) != 3: return UninhabitedType() - target = evaluator.eval_proper(typ.args[0]) - base = evaluator.eval_proper(typ.args[1]) - idx_type = evaluator.eval_proper(typ.args[2]) + args = _get_args(evaluator, typ.args[0], typ.args[1]) + + if args is None: + return UninhabitedType() # Extract index as int - index = extract_literal_int(idx_type) + index = extract_literal_int(evaluator.eval_proper(typ.args[2])) if index is None: return UninhabitedType() # Can't evaluate without literal index - if isinstance(target, Instance) and isinstance(base, Instance): - args = get_type_args_for_base(target, base.type) - if args is not None and 0 <= index < len(args): - return args[index] - return UninhabitedType() # Never - invalid index or not a subtype + if index < 0: + index += len(args) + if 0 <= index < len(args): + return args[index] return UninhabitedType() @@ -396,16 +415,11 @@ def _eval_get_args(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type if len(typ.args) != 2: return UninhabitedType() - target = evaluator.eval_proper(typ.args[0]) - base = evaluator.eval_proper(typ.args[1]) + args = _get_args(evaluator, typ.args[0], typ.args[1]) - if isinstance(target, Instance) and isinstance(base, Instance): - args = get_type_args_for_base(target, base.type) - if args is not None: - return evaluator.tuple_type(list(args)) + if args is None: return UninhabitedType() - - return UninhabitedType() + return evaluator.tuple_type(list(args)) @register_operator("FromUnion") diff --git a/test-data/unit/check-typelevel-basic.test b/test-data/unit/check-typelevel-basic.test index b88d835b0a0e4..4559c7a6d621c 100644 --- a/test-data/unit/check-typelevel-basic.test +++ b/test-data/unit/check-typelevel-basic.test @@ -428,6 +428,88 @@ reveal_type(args) # N: Revealed type is "tuple[builtins.int, builtins.str]" [builtins fixtures/typelevel.pyi] [typing fixtures/typing-full.pyi] +[case testTypeOperatorGetArgNegativeIndex] +# Test GetArg with negative indexes +from typing import Generic, TypeVar, GetArg, Literal + +T = TypeVar('T') +U = TypeVar('U') +V = TypeVar('V') + +class MyGeneric(Generic[T, U, V]): + pass + +# Negative indexes count from the end +x: GetArg[MyGeneric[int, str, float], MyGeneric, Literal[-1]] +reveal_type(x) # N: Revealed type is "builtins.float" + +y: GetArg[MyGeneric[int, str, float], MyGeneric, Literal[-2]] +reveal_type(y) # N: Revealed type is "builtins.str" + +z: GetArg[MyGeneric[int, str, float], MyGeneric, Literal[-3]] +reveal_type(z) # N: Revealed type is "builtins.int" + +[builtins fixtures/typelevel.pyi] +[typing fixtures/typing-full.pyi] + +[case testTypeOperatorGetArgTuple] +# Test GetArg on tuple types +from typing import GetArg, Literal + +# GetArg on tuple with tuple base +x0: GetArg[tuple[int, str, float], tuple, Literal[0]] +reveal_type(x0) # N: Revealed type is "builtins.int" + +x1: GetArg[tuple[int, str, float], tuple, Literal[1]] +reveal_type(x1) # N: Revealed type is "builtins.str" + +x2: GetArg[tuple[int, str, float], tuple, Literal[2]] +reveal_type(x2) # N: Revealed type is "builtins.float" + +# Negative indexes on tuples +y: GetArg[tuple[int, str, float], tuple, Literal[-1]] +reveal_type(y) # N: Revealed type is "builtins.float" + +z: GetArg[tuple[int, str, float], tuple, Literal[-2]] +reveal_type(z) # N: Revealed type is "builtins.str" + +[builtins fixtures/typelevel.pyi] +[typing fixtures/typing-full.pyi] + +[case testTypeOperatorGetArgOutOfBounds] +# Test GetArg with out-of-bounds indexes returns Never +from typing import Generic, TypeVar, GetArg, Literal + +T = TypeVar('T') +U = TypeVar('U') + +class MyGeneric(Generic[T, U]): + pass + +# Index too large +x: GetArg[MyGeneric[int, str], MyGeneric, Literal[2]] +reveal_type(x) # N: Revealed type is "Never" + +x2: GetArg[MyGeneric[int, str], MyGeneric, Literal[100]] +reveal_type(x2) # N: Revealed type is "Never" + +# Negative index too small +y: GetArg[MyGeneric[int, str], MyGeneric, Literal[-3]] +reveal_type(y) # N: Revealed type is "Never" + +y2: GetArg[MyGeneric[int, str], MyGeneric, Literal[-100]] +reveal_type(y2) # N: Revealed type is "Never" + +# Out of bounds on tuples +z: GetArg[tuple[int, str], tuple, Literal[2]] +reveal_type(z) # N: Revealed type is "Never" + +z2: GetArg[tuple[int, str], tuple, Literal[-3]] +reveal_type(z2) # N: Revealed type is "Never" + +[builtins fixtures/typelevel.pyi] +[typing fixtures/typing-full.pyi] + [case testTypeComprehensionBasicTy] # flags: --python-version 3.14 # Test basic type comprehension From ffb6ffc4eef6d8e81e14a1b839cd7a8841431ecc Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Thu, 29 Jan 2026 14:57:00 -0800 Subject: [PATCH 087/161] Preserve the TypeLevelEvaluator in the typelevel_ctx --- mypy/typelevel.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/mypy/typelevel.py b/mypy/typelevel.py index 8ea158428eb52..59488270bda24 100644 --- a/mypy/typelevel.py +++ b/mypy/typelevel.py @@ -55,6 +55,12 @@ class TypeLevelContext: def __init__(self) -> None: self._api: SemanticAnalyzerInterface | None = None + # Make an evaluator part of this state also, so that we can + # maintain a depth tracker and an outer error message context. + # + # XXX: but maybe we should always thread the evaluator back + # ourselves or something instead? + self._evaluator: TypeLevelEvaluator | None = None @property def api(self) -> SemanticAnalyzerInterface | None: @@ -930,10 +936,15 @@ def evaluate_computed_type(typ: ComputedType, ctx: Context | None = None) -> Typ if typelevel_ctx.api is None: raise AssertionError("No access to semantic analyzer!") - evaluator = TypeLevelEvaluator(typelevel_ctx.api, ctx) + old_evaluator = typelevel_ctx._evaluator + if not typelevel_ctx._evaluator: + typelevel_ctx._evaluator = TypeLevelEvaluator(typelevel_ctx.api, ctx) try: - res = evaluator.evaluate(typ) + res = typelevel_ctx._evaluator.evaluate(typ) except EvaluationStuck: res = EXPANSION_ANY + finally: + typelevel_ctx._evaluator = old_evaluator + # print("EVALED!!", res) return res From 2edd7cd319dcbc7be5dc2b3f51b2762c6b6bb015 Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Thu, 29 Jan 2026 15:44:23 -0800 Subject: [PATCH 088/161] Fix Iter[tuple[()]] --- mypy/typelevel.py | 3 --- test-data/unit/check-typelevel-basic.test | 3 +++ 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/mypy/typelevel.py b/mypy/typelevel.py index 59488270bda24..71b6f448f1965 100644 --- a/mypy/typelevel.py +++ b/mypy/typelevel.py @@ -263,9 +263,6 @@ def _eval_iter(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: target = evaluator.eval_proper(typ.args[0]) if isinstance(target, TupleType): - # Check for unbounded tuple (has ..., represented by partial_fallback) - if target.partial_fallback and not target.items: - return UninhabitedType() return target else: return UninhabitedType() diff --git a/test-data/unit/check-typelevel-basic.test b/test-data/unit/check-typelevel-basic.test index 4559c7a6d621c..6e161c10a02d5 100644 --- a/test-data/unit/check-typelevel-basic.test +++ b/test-data/unit/check-typelevel-basic.test @@ -537,6 +537,9 @@ def f(x: tuple[*[T for T in Iter[tuple[int, str, float]]]]) -> None: reveal_type(f) # N: Revealed type is "def (x: tuple[builtins.int, builtins.str, builtins.float])" +z: tuple[*[T for T in Iter[tuple[()]]]] +reveal_type(z) # N: Revealed type is "tuple[()]" + [builtins fixtures/typelevel.pyi] [typing fixtures/typing-full.pyi] From ca7adee09fc36eacbb5bb36ac5356c39f75ac740 Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Thu, 29 Jan 2026 16:00:20 -0800 Subject: [PATCH 089/161] A bunch of fiddling around to make recursion work better Biggest thing is the overflow detector. I did a lot of other fighting with things but in hindsight I think a bunch of it was that my test code would produce overflows... --- mypy/semanal_typeargs.py | 3 + mypy/typeanal.py | 7 +- mypy/typelevel.py | 79 +++++++++++-- mypy/typestate.py | 21 +++- test-data/unit/check-typelevel-examples.test | 115 +++++++++++++++++++ 5 files changed, 210 insertions(+), 15 deletions(-) diff --git a/mypy/semanal_typeargs.py b/mypy/semanal_typeargs.py index 07aa87dda51fb..d629687a8f48c 100644 --- a/mypy/semanal_typeargs.py +++ b/mypy/semanal_typeargs.py @@ -101,7 +101,10 @@ def visit_type_alias_type(self, t: TypeAliasType) -> None: if not is_error: # If there was already an error for the alias itself, there is no point in checking # the expansion, most likely it will result in the same kind of error. + if t.args: + # XXX: I was trying this at one point but it might not be needed. + # if t.args and not any(isinstance(st, ComputedType) for st in t.args): # Since we always allow unbounded type variables in alias definitions, we need # to verify the arguments satisfy the upper bounds of the expansion as well. get_proper_type(t).accept(self) diff --git a/mypy/typeanal.py b/mypy/typeanal.py index 91e6f95e0cc7f..c11930c16cfd4 100644 --- a/mypy/typeanal.py +++ b/mypy/typeanal.py @@ -115,6 +115,7 @@ find_unpack_in_list, flatten_nested_tuples, get_proper_type, + get_proper_type_simple, has_type_vars, ) from mypy.types_utils import get_bad_type_type_item @@ -2451,11 +2452,15 @@ def visit_type_alias_type(self, t: TypeAliasType) -> Type: return t new_nodes = self.seen_nodes | {t.alias} visitor = DivergingAliasDetector(new_nodes, self.lookup, self.scope) - _ = get_proper_type(t).accept(visitor) + _ = get_proper_type_simple(t).accept(visitor) if visitor.diverging: self.diverging = True return t + def visit_type_operator_type(self, t: TypeOperatorType) -> Type: + # XXX: I'm really not sure what to do here + return t + def detect_diverging_alias( node: TypeAlias, diff --git a/mypy/typelevel.py b/mypy/typelevel.py index 71b6f448f1965..8202738c0424d 100644 --- a/mypy/typelevel.py +++ b/mypy/typelevel.py @@ -27,11 +27,13 @@ ProperType, TupleType, Type, + TypeAliasType, TypedDictType, TypeForComprehension, TypeOfAny, TypeOperatorType, TypeVarType, + UnboundType, UninhabitedType, UnionType, UnpackType, @@ -45,6 +47,9 @@ from mypy.semanal_shared import SemanticAnalyzerInterface +MAX_DEPTH = 100 + + class TypeLevelContext: """Holds the context for type-level computation evaluation. @@ -94,6 +99,8 @@ def set_api(self, api: SemanticAnalyzerInterface) -> Iterator[None]: EXPANSION_ANY = AnyType(TypeOfAny.expansion_stuck) +EXPANSION_OVERFLOW = AnyType(TypeOfAny.from_error) + def register_operator( name: str, @@ -160,20 +167,40 @@ class EvaluationStuck(Exception): pass +class EvaluationOverflow(Exception): + pass + + class TypeLevelEvaluator: """Evaluates type-level computations to concrete types.""" def __init__(self, api: SemanticAnalyzerInterface, ctx: Context | None) -> None: self.api = api self.ctx = ctx + self.depth = 0 def evaluate(self, typ: Type) -> Type: """Main entry point: evaluate a type to its simplified form.""" - if isinstance(typ, TypeOperatorType): - return self.eval_operator(typ) - if isinstance(typ, TypeForComprehension): - return evaluate_comprehension(self, typ) - return typ # Already a concrete type or can't be evaluated + + if self.depth >= MAX_DEPTH: + ctx = self.ctx or typ + # Use serious=True to bypass in_checked_function() check which requires + # self.options to be set on the SemanticAnalyzer + self.api.fail("Type expansion is too deep; producing Any", ctx, serious=True) + raise EvaluationOverflow() + + try: + self.depth += 1 + if isinstance(typ, TypeOperatorType): + rtyp = self.eval_operator(typ) + elif isinstance(typ, TypeForComprehension): + rtyp = evaluate_comprehension(self, typ) + else: + rtyp = typ # Already a concrete type or can't be evaluated + + return rtyp + finally: + self.depth -= 1 def eval_proper(self, typ: Type) -> ProperType: """Main entry point: evaluate a type to its simplified form.""" @@ -181,7 +208,7 @@ def eval_proper(self, typ: Type) -> ProperType: # A call to another expansion via an alias got stuck, reraise here if is_stuck_expansion(typ): raise EvaluationStuck - if isinstance(typ, TypeVarType): + if isinstance(typ, (TypeVarType, UnboundType, ComputedType)): raise EvaluationStuck return typ @@ -215,7 +242,7 @@ def flatten_args(self, args: list[Type]) -> list[Type]: """ flat_args: list[Type] = [] for arg in args: - evaluated = self.evaluate(arg) + evaluated = self.eval_proper(arg) if isinstance(evaluated, UnpackType): inner = get_proper_type(evaluated.type) if isinstance(inner, TupleType): @@ -235,6 +262,32 @@ def tuple_type(self, items: list[Type]) -> TupleType: return TupleType(items, self.api.named_type("builtins.tuple")) +def _call_by_value(evaluator: TypeLevelEvaluator, typ: Type) -> Type: + """Make sure alias arguments are evaluated before expansion. + + Currently this is used in conditional bodies, which should protect + any recursive uses, to make sure that arguments to potentially + recursive aliases get evaluated before substituted in, to make + sure that they don't grow without bound. + + FIXME: I am not sure this is sufficient to do just where we are doing it. + It is probably not. We may need to universally do this when there are + potentially computed arguments? + + XXX: Actually maybe this isn't needed at all?? + I'm leaving the code in here for now in case I want to recover it easily. + """ + ACTUALLY_DO_IT = False + + if ACTUALLY_DO_IT and isinstance(typ, TypeAliasType): + typ = typ.copy_modified( + args=[get_proper_type(_call_by_value(evaluator, st)) for st in typ.args] + ) + + # Evaluate recursively instead of letting it get handled in the + # get_proper_type loop to help maintain better error contexts. + return evaluator.eval_proper(typ) + @register_operator("_Cond") def _eval_cond(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: """Evaluate _Cond[condition, TrueType, FalseType].""" @@ -243,12 +296,12 @@ def _eval_cond(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: return UninhabitedType() condition, true_type, false_type = typ.args - result = extract_literal_bool(evaluator.evaluate(condition)) + result = extract_literal_bool(evaluator.eval_proper(condition)) if result is True: - return true_type + return _call_by_value(evaluator, true_type) elif result is False: - return false_type + return _call_by_value(evaluator, false_type) else: # Undecidable - return Any for now # In the future, we might want to keep the conditional and defer evaluation @@ -938,7 +991,13 @@ def evaluate_computed_type(typ: ComputedType, ctx: Context | None = None) -> Typ typelevel_ctx._evaluator = TypeLevelEvaluator(typelevel_ctx.api, ctx) try: res = typelevel_ctx._evaluator.evaluate(typ) + except EvaluationOverflow: + # If this is not the top level of type evaluation, re-raise. + if old_evaluator is not None: + raise + res = EXPANSION_OVERFLOW except EvaluationStuck: + # TODO: Should we do the same top level thing as above? res = EXPANSION_ANY finally: typelevel_ctx._evaluator = old_evaluator diff --git a/mypy/typestate.py b/mypy/typestate.py index d45837dad645a..be7fa7a8ba650 100644 --- a/mypy/typestate.py +++ b/mypy/typestate.py @@ -9,7 +9,14 @@ from mypy.nodes import VARIANCE_NOT_READY, TypeInfo from mypy.server.trigger import make_trigger -from mypy.types import Instance, Type, TypeVarId, TypeVarType, get_proper_type +from mypy.types import ( + Instance, + Type, + TypeVarId, + TypeVarType, + get_proper_type, + get_proper_type_simple, +) MAX_NEGATIVE_CACHE_TYPES: Final = 1000 MAX_NEGATIVE_CACHE_ENTRIES: Final = 10000 @@ -116,7 +123,10 @@ def __init__(self) -> None: def is_assumed_subtype(self, left: Type, right: Type) -> bool: for l, r in reversed(self._assuming): - if get_proper_type(l) == get_proper_type(left) and get_proper_type( + # XXX: get_proper_type_simple on assumptions because doing + # get_proper_type triggered some infinite + # recursions. Think about whether this is right. + if get_proper_type_simple(l) == get_proper_type(left) and get_proper_type_simple( r ) == get_proper_type(right): return True @@ -124,9 +134,12 @@ def is_assumed_subtype(self, left: Type, right: Type) -> bool: def is_assumed_proper_subtype(self, left: Type, right: Type) -> bool: for l, r in reversed(self._assuming_proper): - if get_proper_type(l) == get_proper_type(left) and get_proper_type( + # XXX: get_proper_type_simple on assumptions because doing + # get_proper_type triggered some infinite + # recursions. Think about whether this is right. + if get_proper_type_simple(l) == get_proper_type(left) and get_proper_type( r - ) == get_proper_type(right): + ) == get_proper_type_simple(right): return True return False diff --git a/test-data/unit/check-typelevel-examples.test b/test-data/unit/check-typelevel-examples.test index 04f8b3e8234d5..0bbf14cae455c 100644 --- a/test-data/unit/check-typelevel-examples.test +++ b/test-data/unit/check-typelevel-examples.test @@ -200,3 +200,118 @@ reveal_type(select_ex(User, args1)) # N: Revealed type is "builtins.list[TypedD [builtins fixtures/typelevel.pyi] [typing fixtures/typing-full.pyi] + + +[case testTypeLevel_nplike] +# flags: --python-version 3.14 + +# Simulate array broadcasting + +from typing import Literal as L + +from typing import ( + Literal, + Never, + GetArg, + Bool, + Matches, + Iter, + Slice, + Length, + IsSub, + RaiseError, +) + + +class Array2[DType, Shape]: + + def __abs__(self) -> Array2[DType, Shape]: + raise BaseException + + def __add__[Shape2]( + self, + other: Array2[DType, Shape2] + ) -> Array2[DType, Merge[Shape, Shape2]]: + raise BaseException + + +def add2[DType, Shape1, Shape2]( + lhs: Array2[DType, Shape1], + rhs: Array2[DType, Shape2], +) -> Array2[DType, Merge[Shape1, Shape2]]: + raise BaseException + + +type AppendTuple[A, B] = tuple[ + *[x for x in Iter[A]], + B, +] + +# asdf, not implemented yet +type And[T, S] = S if Bool[T] else T +type Or[T, S] = T if Bool[T] else S + +type MergeOne[T, S] = ( + T + # if Matches[T, S] or Matches[S, Literal[1]] + if Bool[Or[Matches[T, S], Matches[S, Literal[1]]]] + else S if Matches[T, Literal[1]] + else RaiseError[Literal["Broadcast mismatch"], T, S] +) + +type Head[T] = GetArg[T, tuple, Literal[0]] + +type DropLast[T] = Slice[T, Literal[0], Literal[-1]] +type Last[T] = GetArg[T, tuple, Literal[-1]] + +# Matching on Never here is intentional; it prevents stupid +# infinite recursions. +type Empty[T] = IsSub[Length[T], Literal[0]] + +type Merge[T, S] = ( + S if Bool[Empty[T]] else T if Bool[Empty[S]] + else + AppendTuple[ + Merge[DropLast[T], DropLast[S]], + MergeOne[Last[T], Last[S]] + ] +) + + +a0: MergeOne[L[1], L[5]] +reveal_type(a0) # N: Revealed type is "Literal[5]" + +a1: MergeOne[int, L[5]] # E: Broadcast mismatch: builtins.int, Literal[5] +reveal_type(a1) # N: Revealed type is "Never" + +a2: MergeOne[L[5], L[5]] +reveal_type(a2) # N: Revealed type is "Literal[5]" + +a3: MergeOne[int, int] +reveal_type(a3) # N: Revealed type is "builtins.int" + +m0: Merge[tuple[L[1], L[4], L[5]], tuple[L[9], L[1], L[5]]] +reveal_type(m0) # N: Revealed type is "tuple[Literal[9], Literal[4], Literal[5]]" + +m1: Merge[tuple[L[1], L[4], L[5]], tuple[L[9], L[10], L[5]]] # E: Broadcast mismatch: Literal[4], Literal[10] +reveal_type(m1) # N: Revealed type is "tuple[Literal[9], Never, Literal[5]]" + +m2: Merge[tuple[L[2], L[5]], tuple[L[9], L[2], L[1]]] +reveal_type(m2) # N: Revealed type is "tuple[Literal[9], Literal[2], Literal[5]]" + +type T41 = tuple[L[4], L[1]] +type T3 = tuple[L[3]] + +m3: Merge[T41, T3] +reveal_type(m3) # N: Revealed type is "tuple[Literal[4], Literal[3]]" + +# + +b1: Array2[float, tuple[L[4], L[1]]] +b2: Array2[float, tuple[L[3]]] + +reveal_type(add2(b1, b2)) # N: Revealed type is "__main__.Array2[builtins.float, tuple[Literal[4], Literal[3]]]" +reveal_type(b1 + b2) # N: Revealed type is "__main__.Array2[builtins.float, tuple[Literal[4], Literal[3]]]" + +[builtins fixtures/typelevel.pyi] +[typing fixtures/typing-full.pyi] From 55a9ad5fad75e629ba87887c7d74821de7e765d1 Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Thu, 29 Jan 2026 16:36:03 -0800 Subject: [PATCH 090/161] Understand what _call_by_value accomplishes: it makes the *call* *by* *value*. --- mypy/typelevel.py | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/mypy/typelevel.py b/mypy/typelevel.py index 8202738c0424d..0eb9d0dfee399 100644 --- a/mypy/typelevel.py +++ b/mypy/typelevel.py @@ -270,16 +270,13 @@ def _call_by_value(evaluator: TypeLevelEvaluator, typ: Type) -> Type: recursive aliases get evaluated before substituted in, to make sure that they don't grow without bound. - FIXME: I am not sure this is sufficient to do just where we are doing it. - It is probably not. We may need to universally do this when there are - potentially computed arguments? + This shouldn't be necessary for correctness, but can be important + for performance. - XXX: Actually maybe this isn't needed at all?? - I'm leaving the code in here for now in case I want to recover it easily. + We should *maybe* do it in more places! Possibly everywhere? Or + maybe we should do it *never* and just do a better job of caching. """ - ACTUALLY_DO_IT = False - - if ACTUALLY_DO_IT and isinstance(typ, TypeAliasType): + if isinstance(typ, TypeAliasType): typ = typ.copy_modified( args=[get_proper_type(_call_by_value(evaluator, st)) for st in typ.args] ) @@ -288,6 +285,7 @@ def _call_by_value(evaluator: TypeLevelEvaluator, typ: Type) -> Type: # get_proper_type loop to help maintain better error contexts. return evaluator.eval_proper(typ) + @register_operator("_Cond") def _eval_cond(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: """Evaluate _Cond[condition, TrueType, FalseType].""" From 5e29b8db18dcfbe85069b1a8313ef5443217aa73 Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Thu, 29 Jan 2026 16:36:56 -0800 Subject: [PATCH 091/161] Add a type evaluator cache --- mypy/typelevel.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/mypy/typelevel.py b/mypy/typelevel.py index 0eb9d0dfee399..12cfdc4aee69a 100644 --- a/mypy/typelevel.py +++ b/mypy/typelevel.py @@ -179,9 +179,14 @@ def __init__(self, api: SemanticAnalyzerInterface, ctx: Context | None) -> None: self.ctx = ctx self.depth = 0 + self.cache: dict[Type, Type] = {} + def evaluate(self, typ: Type) -> Type: """Main entry point: evaluate a type to its simplified form.""" + if typ in self.cache: + return self.cache[typ] + if self.depth >= MAX_DEPTH: ctx = self.ctx or typ # Use serious=True to bypass in_checked_function() check which requires @@ -198,6 +203,8 @@ def evaluate(self, typ: Type) -> Type: else: rtyp = typ # Already a concrete type or can't be evaluated + self.cache[typ] = rtyp + return rtyp finally: self.depth -= 1 From e598f031017066808827e057773664b7ed3b4133 Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Thu, 29 Jan 2026 17:20:15 -0800 Subject: [PATCH 092/161] Fix some more Unpack expanding locations to make the full thing work --- mypy/semanal.py | 8 ++- mypy/semanal_typeargs.py | 14 +++-- mypy/types.py | 28 ++++++++-- mypy/types_utils.py | 8 ++- test-data/unit/check-typelevel-examples.test | 55 +++++++++++++++++--- 5 files changed, 95 insertions(+), 18 deletions(-) diff --git a/mypy/semanal.py b/mypy/semanal.py index a243c04842a77..02b525dd3547f 100644 --- a/mypy/semanal.py +++ b/mypy/semanal.py @@ -304,6 +304,7 @@ UnpackType, flatten_nested_tuples, get_proper_type, + get_proper_type_simple, get_proper_types, has_type_vars, is_named_instance, @@ -4409,8 +4410,13 @@ def disable_invalid_recursive_aliases( f'Cannot resolve name "{current_node.name}" (possible cyclic definition)' ) elif is_invalid_recursive_alias({current_node}, current_node.target): + # Need to do get_proper_type_simple since we don't want + # any computation-based expansion done, or expansions in + # the arguments. target = ( - "tuple" if isinstance(get_proper_type(current_node.target), TupleType) else "union" + "tuple" + if isinstance(get_proper_type_simple(current_node.target), TupleType) + else "union" ) messages.append(f"Invalid recursive alias: a {target} item of itself") if detect_diverging_alias( diff --git a/mypy/semanal_typeargs.py b/mypy/semanal_typeargs.py index d629687a8f48c..73e629770a40b 100644 --- a/mypy/semanal_typeargs.py +++ b/mypy/semanal_typeargs.py @@ -22,6 +22,7 @@ from mypy.types import ( AnyType, CallableType, + ComputedType, Instance, Parameters, ParamSpecType, @@ -240,10 +241,12 @@ def validate_args( ) elif isinstance(tvar, TypeVarTupleType): p_arg = get_proper_type(arg) - # XXX: get_proper_type could possibly mess things up - # now that it might evaluate - assert isinstance(p_arg, TupleType) - for it in p_arg.items: + # Because of the exciting new world of type evaluation + # done in get_proper_type, it might expand to a + # builtins.tuple subtype instance if it is variadic. + assert isinstance(p_arg, (TupleType, Instance)) + items = p_arg.items if isinstance(p_arg, TupleType) else p_arg.args + for it in items: if self.check_non_paramspec(it, "TypeVarTuple", context): is_invalid = True if is_invalid: @@ -253,6 +256,9 @@ def validate_args( def visit_unpack_type(self, typ: UnpackType) -> None: super().visit_unpack_type(typ) proper_type = get_proper_type(typ.type) + # If it is a ComputedType, it's probably stuck... and we'll just hope we are OK. + if isinstance(proper_type, ComputedType): + return if isinstance(proper_type, TupleType): return if isinstance(proper_type, TypeVarTupleType): diff --git a/mypy/types.py b/mypy/types.py index 83fbd86e9ef57..4b05a4c74f240 100644 --- a/mypy/types.py +++ b/mypy/types.py @@ -1493,6 +1493,11 @@ def deserialize(cls, data: JsonDict) -> UnpackType: typ = data["type"] return UnpackType(deserialize_type(typ)) + def copy_modified(self, *, type: Type | None = None) -> UnpackType: + return UnpackType( + type if type is not None else self.type, self.line, self.column, self.from_star_syntax + ) + def __hash__(self) -> int: return hash(self.type) @@ -3901,6 +3906,15 @@ def get_proper_type_simple(typ: Type | None) -> ProperType | None: return cast(ProperType, typ) +def _could_be_computed_unpack(t: Type) -> bool: + return isinstance(t, TypeForComprehension) or ( + # An unpack of a type alias or a computed type could expand to + # something we need to eval + isinstance(t, UnpackType) + and isinstance(t.type, (TypeAliasType, ComputedType)) + ) + + def _expand_type_fors_in_args(typ: ProperType) -> ProperType: """ Expand any TypeForComprehensions in type arguments. @@ -3914,18 +3928,22 @@ def _expand_type_fors_in_args(typ: ProperType) -> ProperType: typ2: ProperType - if isinstance(typ, TupleType) and any( - isinstance(st, TypeForComprehension) for st in typ.items - ): + if isinstance(typ, TupleType) and any(_could_be_computed_unpack(st) for st in typ.items): typ2 = typ.copy_modified(items=[get_proper_type(st) for st in typ.items]) # expanding the types might produce Unpacks, which we use # expand_type to substitute in. typ = expand_type(typ2, {}) - elif isinstance(typ, Instance) and any( - isinstance(st, TypeForComprehension) for st in typ.args + elif ( + isinstance(typ, Instance) + and typ.type # Make sure it's not a FakeInfo + and typ.type.has_type_var_tuple_type + and any(_could_be_computed_unpack(st) for st in typ.args) ): typ2 = typ.copy_modified(args=[get_proper_type(st) for st in typ.args]) typ = expand_type(typ2, {}) + elif isinstance(typ, UnpackType) and _could_be_computed_unpack(typ): + # No need to expand here + typ = typ.copy_modified(type=get_proper_type(typ.type)) return typ diff --git a/mypy/types_utils.py b/mypy/types_utils.py index 7d8a08ea9b156..773bf8b7f07d8 100644 --- a/mypy/types_utils.py +++ b/mypy/types_utils.py @@ -32,6 +32,7 @@ UnpackType, flatten_nested_unions, get_proper_type, + get_proper_type_simple, get_proper_types, ) @@ -66,7 +67,12 @@ def is_invalid_recursive_alias(seen_nodes: set[TypeAlias], target: Type) -> bool if target.alias in seen_nodes: return True assert target.alias, f"Unfixed type alias {target.type_ref}" - return is_invalid_recursive_alias(seen_nodes | {target.alias}, get_proper_type(target)) + # Need to do get_proper_type_simple since we don't want + # any computation-based expansion done, or expansions in + # the arguments. + return is_invalid_recursive_alias( + seen_nodes | {target.alias}, get_proper_type_simple(target) + ) if isinstance(target, ComputedType): # XXX: We need to do *something* useful here!! return False diff --git a/test-data/unit/check-typelevel-examples.test b/test-data/unit/check-typelevel-examples.test index 0bbf14cae455c..c6af6e5528256 100644 --- a/test-data/unit/check-typelevel-examples.test +++ b/test-data/unit/check-typelevel-examples.test @@ -223,11 +223,24 @@ from typing import ( ) -class Array2[DType, Shape]: - - def __abs__(self) -> Array2[DType, Shape]: +class Array[DType, *Shape]: + def __add__[*Shape2]( + self, + other: Array[DType, *Shape2] + ) -> Array[DType, *Merge[tuple[*Shape], tuple[*Shape2]]]: + # ) -> Array[DType, *Broadcast[Shape, Shape2]]: raise BaseException +def add[DType, *Shape1, *Shape2]( + lhs: Array[DType, *Shape1], + rhs: Array[DType, *Shape2], +) -> Array[DType, *Merge[tuple[*Shape1], tuple[*Shape2]]]: +# ) -> Array[DType, *Broadcast[Shape1, Shape2]]: + raise BaseException + + +class Array2[DType, Shape]: + def __add__[Shape2]( self, other: Array2[DType, Shape2] @@ -307,11 +320,39 @@ reveal_type(m3) # N: Revealed type is "tuple[Literal[4], Literal[3]]" # -b1: Array2[float, tuple[L[4], L[1]]] -b2: Array2[float, tuple[L[3]]] +z1: Array2[float, tuple[L[4], L[1]]] +z2: Array2[float, tuple[L[3]]] + +reveal_type(add2(z1, z2)) # N: Revealed type is "__main__.Array2[builtins.float, tuple[Literal[4], Literal[3]]]" +reveal_type(z1 + z2) # N: Revealed type is "__main__.Array2[builtins.float, tuple[Literal[4], Literal[3]]]" + +# + +b1: Array[float, int, int] +b2: Array[float, int] +reveal_type(b1 + b2) # N: Revealed type is "__main__.Array[builtins.float, builtins.int, builtins.int]" + + +# + +c1: Array[float, L[4], L[1]] +c2: Array[float, L[3]] +res1 = c1 + c2 +reveal_type(res1) # N: Revealed type is "__main__.Array[builtins.float, Literal[4], Literal[3]]" + +res2 = add(c1, c2) +reveal_type(res2) # N: Revealed type is "__main__.Array[builtins.float, Literal[4], Literal[3]]" + + +checkr: Array[float, L[4], L[3]] = res1 +checkr = res2 + +# + +err1: Array[float, L[4], L[2]] +err2: Array[float, L[3]] +# err1 + err2 # XXX: We want to do this one but we get the wrong error location! -reveal_type(add2(b1, b2)) # N: Revealed type is "__main__.Array2[builtins.float, tuple[Literal[4], Literal[3]]]" -reveal_type(b1 + b2) # N: Revealed type is "__main__.Array2[builtins.float, tuple[Literal[4], Literal[3]]]" [builtins fixtures/typelevel.pyi] [typing fixtures/typing-full.pyi] From a2998a5bdc116318f18c7e88cefb8f921208c3df Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Fri, 30 Jan 2026 10:36:51 -0800 Subject: [PATCH 093/161] Second, simpler version of nplike --- test-data/unit/check-typelevel-examples.test | 90 ++++++++++++++++++++ 1 file changed, 90 insertions(+) diff --git a/test-data/unit/check-typelevel-examples.test b/test-data/unit/check-typelevel-examples.test index c6af6e5528256..7e16e8454eaaf 100644 --- a/test-data/unit/check-typelevel-examples.test +++ b/test-data/unit/check-typelevel-examples.test @@ -354,5 +354,95 @@ err2: Array[float, L[3]] # err1 + err2 # XXX: We want to do this one but we get the wrong error location! +[builtins fixtures/typelevel.pyi] +[typing fixtures/typing-full.pyi] + + +[case testTypeLevel_nplike2] +# flags: --python-version 3.14 + +# Simpler version of above to snip out and demonstrate + +# Simulate array broadcasting + +from typing import Literal as L + +from typing import ( + Literal, + Never, + GetArg, + Bool, + Matches, + Iter, + Slice, + Length, + IsSub, + RaiseError, +) + + +class Array[DType, *Shape]: + def __add__[*Shape2]( + self, + other: Array[DType, *Shape2] + ) -> Array[DType, *Merge[tuple[*Shape], tuple[*Shape2]]]: + raise BaseException + + +type AppendTuple[A, B] = tuple[ + *[x for x in Iter[A]], + B, +] + +# asdf, not implemented yet +type Or[T, S] = T if Bool[T] else S + +type MergeOne[T, S] = ( + T + # if Matches[T, S] or Matches[S, Literal[1]] + if Bool[Or[Matches[T, S], Matches[S, Literal[1]]]] + else S if Matches[T, Literal[1]] + else RaiseError[Literal["Broadcast mismatch"], T, S] +) + +type DropLast[T] = Slice[T, Literal[0], Literal[-1]] +type Last[T] = GetArg[T, tuple, Literal[-1]] + +# Matching on Never here is intentional; it prevents stupid +# infinite recursions. +type Empty[T] = IsSub[Length[T], Literal[0]] + +type Merge[T, S] = ( + S if Bool[Empty[T]] else T if Bool[Empty[S]] + else + AppendTuple[ + Merge[DropLast[T], DropLast[S]], + MergeOne[Last[T], Last[S]] + ] +) + +a1: Array[float, L[4], L[1]] +a2: Array[float, L[3]] +ar = a1 + a2 +reveal_type(ar) # N: Revealed type is "__main__.Array[builtins.float, Literal[4], Literal[3]]" +checkr: Array[float, L[4], L[3]] = ar + + +b1: Array[float, int, int] +b2: Array[float, int] +reveal_type(b1 + b2) # N: Revealed type is "__main__.Array[builtins.float, builtins.int, builtins.int]" + + +c1: Array[float, L[4], L[1], L[5]] +c2: Array[float, L[4], L[3], L[1]] +reveal_type(c1 + c2) # N: Revealed type is "__main__.Array[builtins.float, Literal[4], Literal[3], Literal[5]]" + +# + +err1: Array[float, L[4], L[2]] +err2: Array[float, L[3]] +# err1 + err2 # XXX: We want to do this one but we get the wrong error location! + + [builtins fixtures/typelevel.pyi] [typing fixtures/typing-full.pyi] From 8bbdccabac1bc3766d80c92cad91265904323e77 Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Fri, 30 Jan 2026 12:53:43 -0800 Subject: [PATCH 094/161] DEV: Just try symlinking pep.rst in --- AGENTS.md | 6 ++++++ TYPEMAP_IMPLEMENTATION_PLAN.md | 2 +- pep.rst | 1 + 3 files changed, 8 insertions(+), 1 deletion(-) create mode 120000 pep.rst diff --git a/AGENTS.md b/AGENTS.md index 76079d151da23..5c0f88f6070be 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,12 @@ This file provides guidance to coding agents, I guess. Also to humans some probably. +## Current Work: Typemap PEP Implementation + +We are implementing a PEP draft for type-level computation. The specification +is in `pep.rst`. Refer to it when implementing new type operators +or features. + ## Default virtualenv The default virtualenv is ``venv``, so most of the commands below diff --git a/TYPEMAP_IMPLEMENTATION_PLAN.md b/TYPEMAP_IMPLEMENTATION_PLAN.md index 6bd3f071bc4e9..f11ae7daa7103 100644 --- a/TYPEMAP_IMPLEMENTATION_PLAN.md +++ b/TYPEMAP_IMPLEMENTATION_PLAN.md @@ -1,7 +1,7 @@ # Implementation Plan: Type-Level Computation for Mypy This document outlines a plan for implementing the type-level -computation proposal described in `../typemap/pre-pep.rst`. +computation proposal described in `pep.rst`. ## Overview diff --git a/pep.rst b/pep.rst new file mode 120000 index 0000000000000..c52d575bcefd3 --- /dev/null +++ b/pep.rst @@ -0,0 +1 @@ +../typemap/pep.rst \ No newline at end of file From ffa3b36a877ecefac53e52c9d9b736d7f630a1b0 Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Fri, 30 Jan 2026 13:39:42 -0800 Subject: [PATCH 095/161] Implement and, or, not type boolean operators Add support for `and`, `or`, `not` in type-level boolean expressions. These are converted to _And, _Or, _Not type operators internally: - `A and B` -> `_And[A, B]` - `A or B` -> `_Or[A, B]` - `not A` -> `_Not[A]` Works both in regular type annotations and in type aliases. Co-Authored-By: Claude Opus 4.5 --- mypy/exprtotype.py | 28 +++++++ mypy/fastparse.py | 40 ++++++++- mypy/typelevel.py | 53 ++++++++++++ mypy/typeshed/stdlib/builtins.pyi | 35 +++++++- test-data/unit/check-typelevel-basic.test | 85 ++++++++++++++++++++ test-data/unit/check-typelevel-examples.test | 7 +- test-data/unit/fixtures/typelevel.pyi | 14 ++++ 7 files changed, 251 insertions(+), 11 deletions(-) diff --git a/mypy/exprtotype.py b/mypy/exprtotype.py index b6e26ebf1bd2c..ff4c613010b64 100644 --- a/mypy/exprtotype.py +++ b/mypy/exprtotype.py @@ -164,6 +164,22 @@ def expr_to_unanalyzed_type( ], uses_pep604_syntax=True, ) + elif isinstance(expr, OpExpr) and expr.op in ("and", "or"): + # Convert `A and B` to `_And[A, B]` and `A or B` to `_Or[A, B]` + op_name = "_And" if expr.op == "and" else "_Or" + return UnboundType( + f"__builtins__.{op_name}", + [ + expr_to_unanalyzed_type( + expr.left, options, allow_new_syntax, lookup_qualified=lookup_qualified + ), + expr_to_unanalyzed_type( + expr.right, options, allow_new_syntax, lookup_qualified=lookup_qualified + ), + ], + line=expr.line, + column=expr.column, + ) elif isinstance(expr, CallExpr) and isinstance(_parent, ListExpr): c = expr.callee names = [] @@ -232,6 +248,18 @@ def expr_to_unanalyzed_type( elif isinstance(expr, BytesExpr): return parse_type_string(expr.value, "builtins.bytes", expr.line, expr.column) elif isinstance(expr, UnaryExpr): + # Handle `not` for type booleans + if expr.op == "not": + return UnboundType( + "__builtins__._Not", + [ + expr_to_unanalyzed_type( + expr.expr, options, allow_new_syntax, lookup_qualified=lookup_qualified + ) + ], + line=expr.line, + column=expr.column, + ) typ = expr_to_unanalyzed_type( expr.expr, options, allow_new_syntax, lookup_qualified=lookup_qualified ) diff --git a/mypy/fastparse.py b/mypy/fastparse.py index 30737eeaea475..282b46490bf90 100644 --- a/mypy/fastparse.py +++ b/mypy/fastparse.py @@ -132,7 +132,7 @@ PY_MINOR_VERSION: Final = sys.version_info[1] import ast as ast3 -from ast import AST, Attribute, Call, FunctionType, Name, Starred, UAdd, UnaryOp, USub +from ast import AST, And, Attribute, Call, FunctionType, Name, Not, Starred, UAdd, UnaryOp, USub def ast3_parse( @@ -2062,6 +2062,10 @@ def visit_Constant(self, n: ast3.Constant) -> Type: # UnaryOp(op, operand) def visit_UnaryOp(self, n: UnaryOp) -> Type: + # Handle `not` for type booleans + if isinstance(n.op, Not): + return self.visit_UnaryOp_not(n) + # We support specifically Literal[-4], Literal[+4], and nothing else. # For example, Literal[~6] or Literal[not False] is not supported. typ = self.visit(n.operand) @@ -2202,6 +2206,40 @@ def visit_List(self, n: ast3.List) -> Type: result = self.translate_argument_list(n.elts) return result + # BoolOp(boolop op, expr* values) + def visit_BoolOp(self, n: ast3.BoolOp) -> Type: + """Handle boolean operations in type contexts. + + Convert `A and B` to `_And[A, B]` and `A or B` to `_Or[A, B]`. + Chains like `A and B and C` become `_And[_And[A, B], C]`. + """ + # Process left-to-right, building up nested operators + result = self.visit(n.values[0]) + op_name = "_And" if isinstance(n.op, And) else "_Or" + + for value in n.values[1:]: + right = self.visit(value) + result = UnboundType( + f"__builtins__.{op_name}", + [result, right], + line=self.line, + column=self.convert_column(n.col_offset), + ) + return result + + def visit_UnaryOp_not(self, n: UnaryOp) -> Type: + """Handle `not` in type contexts. + + Convert `not X` to `_Not[X]`. + """ + operand = self.visit(n.operand) + return UnboundType( + "__builtins__._Not", + [operand], + line=self.line, + column=self.convert_column(n.col_offset), + ) + # IfExp(expr test, expr body, expr orelse) def visit_IfExp(self, n: ast3.IfExp) -> Type: """Handle ternary expressions in type contexts. diff --git a/mypy/typelevel.py b/mypy/typelevel.py index 12cfdc4aee69a..4532b3801e4f1 100644 --- a/mypy/typelevel.py +++ b/mypy/typelevel.py @@ -313,6 +313,59 @@ def _eval_cond(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: return EXPANSION_ANY +@register_operator("_And") +def _eval_and(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: + """Evaluate _And[cond1, cond2] - logical AND of type booleans.""" + if len(typ.args) != 2: + return UninhabitedType() + + left = extract_literal_bool(evaluator.eval_proper(typ.args[0])) + if left is False: + # Short-circuit: False and X = False + return evaluator.literal_bool(False) + if left is None: + return UninhabitedType() + + right = extract_literal_bool(evaluator.eval_proper(typ.args[1])) + if right is None: + return UninhabitedType() + + return evaluator.literal_bool(right) + + +@register_operator("_Or") +def _eval_or(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: + """Evaluate _Or[cond1, cond2] - logical OR of type booleans.""" + if len(typ.args) != 2: + return UninhabitedType() + + left = extract_literal_bool(evaluator.eval_proper(typ.args[0])) + if left is True: + # Short-circuit: True or X = True + return evaluator.literal_bool(True) + if left is None: + return UninhabitedType() + + right = extract_literal_bool(evaluator.eval_proper(typ.args[1])) + if right is None: + return UninhabitedType() + + return evaluator.literal_bool(right) + + +@register_operator("_Not") +def _eval_not(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: + """Evaluate _Not[cond] - logical NOT of a type boolean.""" + if len(typ.args) != 1: + return UninhabitedType() + + result = extract_literal_bool(evaluator.eval_proper(typ.args[0])) + if result is None: + return UninhabitedType() + + return evaluator.literal_bool(not result) + + @register_operator("Iter") def _eval_iter(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: """Evaluate a type-level iterator (Iter[T]).""" diff --git a/mypy/typeshed/stdlib/builtins.pyi b/mypy/typeshed/stdlib/builtins.pyi index 05497594d2dbb..e3e68bc0908a4 100644 --- a/mypy/typeshed/stdlib/builtins.pyi +++ b/mypy/typeshed/stdlib/builtins.pyi @@ -2233,11 +2233,8 @@ if sys.version_info >= (3, 13): class PythonFinalizationError(RuntimeError): ... -_TrueType = TypeVar("_TrueType") -_FalseType = TypeVar("_FalseType") - @_type_operator -class _Cond(Generic[_T, _TrueType, _FalseType]): +class _Cond(Generic[_T, _T1, _T2]): """ Type-level conditional expression. _Cond[IsSub[T, Base], TrueType, FalseType] evaluates to TrueType if T is a subtype of Base, @@ -2245,3 +2242,33 @@ class _Cond(Generic[_T, _TrueType, _FalseType]): """ ... + +@_type_operator +class _And(Generic[_T1, _T2]): + """ + Type-level logical AND. + _And[A, B] evaluates to Literal[True] if both A and B are Literal[True], + otherwise Literal[False]. + """ + + ... + +@_type_operator +class _Or(Generic[_T1, _T2]): + """ + Type-level logical OR. + _Or[A, B] evaluates to Literal[True] if either A or B is Literal[True], + otherwise Literal[False]. + """ + + ... + +@_type_operator +class _Not(Generic[_T]): + """ + Type-level logical NOT. + _Not[A] evaluates to Literal[True] if A is Literal[False], + and Literal[False] if A is Literal[True]. + """ + + ... diff --git a/test-data/unit/check-typelevel-basic.test b/test-data/unit/check-typelevel-basic.test index 6e161c10a02d5..6930f622de186 100644 --- a/test-data/unit/check-typelevel-basic.test +++ b/test-data/unit/check-typelevel-basic.test @@ -57,6 +57,91 @@ reveal_type(x6) # N: Revealed type is "Literal[False]" [builtins fixtures/typelevel.pyi] [typing fixtures/typing-full.pyi] +[case testTypeOperatorAnd] +from typing import IsSub, Literal + +# _And is the internal representation for `and` in type booleans +# We test it directly via IsSub compositions + +# True and True = True +x1: Literal[True] if IsSub[int, object] and IsSub[str, object] else Literal[False] +reveal_type(x1) # N: Revealed type is "Literal[True]" + +# True and False = False +x2: Literal[True] if IsSub[int, object] and IsSub[int, str] else Literal[False] +reveal_type(x2) # N: Revealed type is "Literal[False]" + +# False and True = False (short-circuit) +x3: Literal[True] if IsSub[int, str] and IsSub[str, object] else Literal[False] +reveal_type(x3) # N: Revealed type is "Literal[False]" + +# False and False = False +x4: Literal[True] if IsSub[int, str] and IsSub[str, int] else Literal[False] +reveal_type(x4) # N: Revealed type is "Literal[False]" + +[builtins fixtures/typelevel.pyi] +[typing fixtures/typing-full.pyi] + +[case testTypeOperatorOr] +from typing import IsSub, Literal + +# _Or is the internal representation for `or` in type booleans + +# True or True = True +x1: Literal[True] if IsSub[int, object] or IsSub[str, object] else Literal[False] +reveal_type(x1) # N: Revealed type is "Literal[True]" + +# True or False = True (short-circuit) +x2: Literal[True] if IsSub[int, object] or IsSub[int, str] else Literal[False] +reveal_type(x2) # N: Revealed type is "Literal[True]" + +# False or True = True +x3: Literal[True] if IsSub[int, str] or IsSub[str, object] else Literal[False] +reveal_type(x3) # N: Revealed type is "Literal[True]" + +# False or False = False +x4: Literal[True] if IsSub[int, str] or IsSub[str, int] else Literal[False] +reveal_type(x4) # N: Revealed type is "Literal[False]" + +[builtins fixtures/typelevel.pyi] +[typing fixtures/typing-full.pyi] + +[case testTypeOperatorNot] +from typing import IsSub, Literal + +# _Not is the internal representation for `not` in type booleans + +# not True = False +x1: Literal[True] if not IsSub[int, object] else Literal[False] +reveal_type(x1) # N: Revealed type is "Literal[False]" + +# not False = True +x2: Literal[True] if not IsSub[int, str] else Literal[False] +reveal_type(x2) # N: Revealed type is "Literal[True]" + +[builtins fixtures/typelevel.pyi] +[typing fixtures/typing-full.pyi] + +[case testTypeOperatorBoolCombinations] +from typing import IsSub, Literal + +# Test combinations of and, or, not + +# not (True and False) = True +x1: Literal[True] if not (IsSub[int, object] and IsSub[int, str]) else Literal[False] +reveal_type(x1) # N: Revealed type is "Literal[True]" + +# (True or False) and True = True +x2: Literal[True] if (IsSub[int, object] or IsSub[int, str]) and IsSub[str, object] else Literal[False] +reveal_type(x2) # N: Revealed type is "Literal[True]" + +# not False or False = True +x3: Literal[True] if not IsSub[int, str] or IsSub[str, int] else Literal[False] +reveal_type(x3) # N: Revealed type is "Literal[True]" + +[builtins fixtures/typelevel.pyi] +[typing fixtures/typing-full.pyi] + [case testTypeOperatorTernarySyntax] from typing import IsSub diff --git a/test-data/unit/check-typelevel-examples.test b/test-data/unit/check-typelevel-examples.test index 7e16e8454eaaf..c23f67da1545f 100644 --- a/test-data/unit/check-typelevel-examples.test +++ b/test-data/unit/check-typelevel-examples.test @@ -260,14 +260,9 @@ type AppendTuple[A, B] = tuple[ B, ] -# asdf, not implemented yet -type And[T, S] = S if Bool[T] else T -type Or[T, S] = T if Bool[T] else S - type MergeOne[T, S] = ( T - # if Matches[T, S] or Matches[S, Literal[1]] - if Bool[Or[Matches[T, S], Matches[S, Literal[1]]]] + if Matches[T, S] or Matches[S, Literal[1]] else S if Matches[T, Literal[1]] else RaiseError[Literal["Broadcast mismatch"], T, S] ) diff --git a/test-data/unit/fixtures/typelevel.pyi b/test-data/unit/fixtures/typelevel.pyi index 10edb76265ca2..93de287efd6d2 100644 --- a/test-data/unit/fixtures/typelevel.pyi +++ b/test-data/unit/fixtures/typelevel.pyi @@ -64,3 +64,17 @@ _FalseType = TypeVar('_FalseType') @type_check_only @_type_operator class _Cond(Generic[_T, _TrueType, _FalseType]): ... + +_T2 = TypeVar('_T2') + +@type_check_only +@_type_operator +class _And(Generic[_T, _T2]): ... + +@type_check_only +@_type_operator +class _Or(Generic[_T, _T2]): ... + +@type_check_only +@_type_operator +class _Not(Generic[_T]): ... From da21e348d96110aeba33d25f80ba1ff787b7e439 Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Fri, 30 Jan 2026 15:50:38 -0800 Subject: [PATCH 096/161] Tweak indirection to use _visit in another case --- mypy/indirection.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mypy/indirection.py b/mypy/indirection.py index f0b35caa16740..861b781c467fa 100644 --- a/mypy/indirection.py +++ b/mypy/indirection.py @@ -96,7 +96,7 @@ def visit_type_var_tuple(self, t: types.TypeVarTupleType) -> None: self._visit(t.default) def visit_unpack_type(self, t: types.UnpackType) -> None: - t.type.accept(self) + self._visit(t.type) def visit_parameters(self, t: types.Parameters) -> None: self._visit_type_list(t.arg_types) From d33d278da4615b2478825de8e23b7c5cca320241 Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Fri, 30 Jan 2026 15:56:48 -0800 Subject: [PATCH 097/161] Drop Or from second test version --- test-data/unit/check-typelevel-examples.test | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/test-data/unit/check-typelevel-examples.test b/test-data/unit/check-typelevel-examples.test index c23f67da1545f..f036bf9c7dd22 100644 --- a/test-data/unit/check-typelevel-examples.test +++ b/test-data/unit/check-typelevel-examples.test @@ -389,13 +389,9 @@ type AppendTuple[A, B] = tuple[ B, ] -# asdf, not implemented yet -type Or[T, S] = T if Bool[T] else S - type MergeOne[T, S] = ( T - # if Matches[T, S] or Matches[S, Literal[1]] - if Bool[Or[Matches[T, S], Matches[S, Literal[1]]]] + if Matches[T, S] or Matches[S, Literal[1]] else S if Matches[T, Literal[1]] else RaiseError[Literal["Broadcast mismatch"], T, S] ) From 66a0671533aa232baa7c1cb757219d19d7a557c1 Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Fri, 30 Jan 2026 16:33:22 -0800 Subject: [PATCH 098/161] Make sure to propagate stuckness properly from Length We were incorrectly returning None as the length of `Unpack[Ts]` which allowed type simplification to start happening early based on bogus conditionals. (Maybe something needs to be done to prevent that more directly too?) --- mypy/typelevel.py | 15 ++++++++------- test-data/unit/check-typelevel-examples.test | 13 ++++++------- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/mypy/typelevel.py b/mypy/typelevel.py index 4532b3801e4f1..a60c2a0773635 100644 --- a/mypy/typelevel.py +++ b/mypy/typelevel.py @@ -32,6 +32,7 @@ TypeForComprehension, TypeOfAny, TypeOperatorType, + TypeVarLikeType, TypeVarType, UnboundType, UninhabitedType, @@ -215,7 +216,9 @@ def eval_proper(self, typ: Type) -> ProperType: # A call to another expansion via an alias got stuck, reraise here if is_stuck_expansion(typ): raise EvaluationStuck - if isinstance(typ, (TypeVarType, UnboundType, ComputedType)): + if isinstance(typ, (TypeVarLikeType, UnboundType, ComputedType)): + raise EvaluationStuck + if isinstance(typ, UnpackType) and isinstance(typ.type, TypeVarLikeType): raise EvaluationStuck return typ @@ -437,10 +440,6 @@ def _eval_bool(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: arg_proper = evaluator.eval_proper(typ.args[0]) - # Handle type variables - may be undecidable - if has_type_vars(arg_proper): - return EXPANSION_ANY - # Check if Literal[True] is a subtype of arg (i.e., arg contains True) # and arg is not Never literal_true = evaluator.literal_bool(True) @@ -933,10 +932,12 @@ def _eval_length(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: target = evaluator.eval_proper(typ.args[0]) if isinstance(target, TupleType): + # Need to evaluate the elements before we inspect them + items = [evaluator.eval_proper(st) for st in target.items] + # If there is an Unpack, it must be of an unbounded tuple, or # it would have been substituted out. - # TODO: Or would it be stuck? Think. - if any(isinstance(st, UnpackType) for st in target.items): + if any(isinstance(st, UnpackType) for st in items): return NoneType() return evaluator.literal_int(len(target.items)) if isinstance(target, Instance) and target.type.has_base("builtins.tuple"): diff --git a/test-data/unit/check-typelevel-examples.test b/test-data/unit/check-typelevel-examples.test index f036bf9c7dd22..1d1a7816a12fc 100644 --- a/test-data/unit/check-typelevel-examples.test +++ b/test-data/unit/check-typelevel-examples.test @@ -384,11 +384,6 @@ class Array[DType, *Shape]: raise BaseException -type AppendTuple[A, B] = tuple[ - *[x for x in Iter[A]], - B, -] - type MergeOne[T, S] = ( T if Matches[T, S] or Matches[S, Literal[1]] @@ -406,12 +401,16 @@ type Empty[T] = IsSub[Length[T], Literal[0]] type Merge[T, S] = ( S if Bool[Empty[T]] else T if Bool[Empty[S]] else - AppendTuple[ - Merge[DropLast[T], DropLast[S]], + tuple[ + *Merge[DropLast[T], DropLast[S]], MergeOne[Last[T], Last[S]] ] ) +m0: Merge[tuple[L[4], L[5]], tuple[L[9], L[1], L[5]]] +reveal_type(m0) # N: Revealed type is "tuple[Literal[9], Literal[4], Literal[5]]" + + a1: Array[float, L[4], L[1]] a2: Array[float, L[3]] ar = a1 + a2 From fcf2614ead900351d242eb621c3cb55e38009e12 Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Fri, 30 Jan 2026 17:24:31 -0800 Subject: [PATCH 099/161] Fix some more infinite loop cases --- mypy/semanal_typeargs.py | 3 ++- mypy/typelevel.py | 3 +-- mypy/types.py | 7 ++++++- test-data/unit/check-typelevel-examples.test | 14 ++++++-------- 4 files changed, 15 insertions(+), 12 deletions(-) diff --git a/mypy/semanal_typeargs.py b/mypy/semanal_typeargs.py index 73e629770a40b..8aa1e631e8363 100644 --- a/mypy/semanal_typeargs.py +++ b/mypy/semanal_typeargs.py @@ -38,6 +38,7 @@ UnpackType, flatten_nested_tuples, get_proper_type, + get_proper_type_simple, get_proper_types, split_with_prefix_and_suffix, ) @@ -108,7 +109,7 @@ def visit_type_alias_type(self, t: TypeAliasType) -> None: # if t.args and not any(isinstance(st, ComputedType) for st in t.args): # Since we always allow unbounded type variables in alias definitions, we need # to verify the arguments satisfy the upper bounds of the expansion as well. - get_proper_type(t).accept(self) + get_proper_type_simple(t).accept(self) if t.is_recursive: self.seen_aliases.discard(t.alias) diff --git a/mypy/typelevel.py b/mypy/typelevel.py index a60c2a0773635..23f75ad7635db 100644 --- a/mypy/typelevel.py +++ b/mypy/typelevel.py @@ -33,7 +33,6 @@ TypeOfAny, TypeOperatorType, TypeVarLikeType, - TypeVarType, UnboundType, UninhabitedType, UnionType, @@ -1061,5 +1060,5 @@ def evaluate_computed_type(typ: ComputedType, ctx: Context | None = None) -> Typ finally: typelevel_ctx._evaluator = old_evaluator - # print("EVALED!!", res) + # print("EVALED!!", typ, "====>", res) return res diff --git a/mypy/types.py b/mypy/types.py index 4b05a4c74f240..778970ba83c08 100644 --- a/mypy/types.py +++ b/mypy/types.py @@ -3911,7 +3911,12 @@ def _could_be_computed_unpack(t: Type) -> bool: # An unpack of a type alias or a computed type could expand to # something we need to eval isinstance(t, UnpackType) - and isinstance(t.type, (TypeAliasType, ComputedType)) + and ( + isinstance(t.type, (TypeAliasType, ComputedType)) + # XXX: Some TupleTypes have snuck in in some cases and I + # need to debug this more + or (isinstance(t.type, ProperType) and isinstance(t.type, TupleType)) + ) ) diff --git a/test-data/unit/check-typelevel-examples.test b/test-data/unit/check-typelevel-examples.test index 1d1a7816a12fc..677f47c9124e0 100644 --- a/test-data/unit/check-typelevel-examples.test +++ b/test-data/unit/check-typelevel-examples.test @@ -255,11 +255,6 @@ def add2[DType, Shape1, Shape2]( raise BaseException -type AppendTuple[A, B] = tuple[ - *[x for x in Iter[A]], - B, -] - type MergeOne[T, S] = ( T if Matches[T, S] or Matches[S, Literal[1]] @@ -276,11 +271,14 @@ type Last[T] = GetArg[T, tuple, Literal[-1]] # infinite recursions. type Empty[T] = IsSub[Length[T], Literal[0]] +type Tup[*Ts] = tuple[*Ts] + type Merge[T, S] = ( S if Bool[Empty[T]] else T if Bool[Empty[S]] else - AppendTuple[ - Merge[DropLast[T], DropLast[S]], + Tup[ + # XXX: This is super wrong! + *Merge[DropLast[T], DropLast[S]], # E: Broadcast mismatch: Literal[4], Literal[10] MergeOne[Last[T], Last[S]] ] ) @@ -289,7 +287,7 @@ type Merge[T, S] = ( a0: MergeOne[L[1], L[5]] reveal_type(a0) # N: Revealed type is "Literal[5]" -a1: MergeOne[int, L[5]] # E: Broadcast mismatch: builtins.int, Literal[5] +a1: MergeOne[int, L[5]] # E: Broadcast mismatch: int, Literal[5] reveal_type(a1) # N: Revealed type is "Never" a2: MergeOne[L[5], L[5]] From 7f75d5c740e5756b8f627ffd42fe8da8b4ef261d Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Mon, 2 Feb 2026 12:34:32 -0800 Subject: [PATCH 100/161] Add some more conditional type argument tests --- test-data/unit/check-typelevel-basic.test | 37 +++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/test-data/unit/check-typelevel-basic.test b/test-data/unit/check-typelevel-basic.test index 6930f622de186..002af4da6e68e 100644 --- a/test-data/unit/check-typelevel-basic.test +++ b/test-data/unit/check-typelevel-basic.test @@ -900,5 +900,42 @@ y: str if IsSub[T, int] else RaiseError[Literal['T must be a int']] reveal_type(y) # N: Revealed type is "builtins.str" +[builtins fixtures/typelevel.pyi] +[typing fixtures/typing-full.pyi] + + +[case testTypeOperatorConditionalArg1] +# flags: --python-version 3.14 + +from typing import IsSub + +def foo[T](x: T, y: list[T] if IsSub[T, int] else T) -> T: + return x + + +foo(0, 0) # E: Argument 2 to "foo" has incompatible type "int"; expected "list[int]" +foo(0, [0]) +foo('a', 'a') +foo('a', ['a']) # E: Argument 2 to "foo" has incompatible type "list[str]"; expected "str" + + +[builtins fixtures/typelevel.pyi] +[typing fixtures/typing-full.pyi] + +[case testTypeOperatorConditionalArg2] +# flags: --python-version 3.14 + +from typing import IsSub + +def foo[T](y: list[T] if IsSub[T, int] else T, x: T) -> T: + return x + + +foo(0, 0) # E: Argument 1 to "foo" has incompatible type "int"; expected "list[int]" +foo([0], 0) +foo('a', 'a') +foo(['a'], 'a') # E: Argument 1 to "foo" has incompatible type "list[str]"; expected "str" + + [builtins fixtures/typelevel.pyi] [typing fixtures/typing-full.pyi] From a8ed91f6fc5da845bae0765dd018784a15090c33 Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Mon, 2 Feb 2026 13:48:43 -0800 Subject: [PATCH 101/161] Implement NewProtocol type operator NewProtocol[*Members] creates a synthetic structural protocol type from Member arguments. The protocol uses structural subtyping, so any class with matching members satisfies the protocol. Key implementation details: - Creates a synthetic TypeInfo with is_protocol=True - Inherits from object (not Protocol directly, which is just a marker) - Sets up proper MRO using calculate_mro with fallback - Adds members to symbol table as Var nodes with qualifiers - Uses a counter in TypeLevelContext for deterministic protocol names Co-Authored-By: Claude Opus 4.5 --- mypy/typelevel.py | 159 ++++++++++++++++++++-- test-data/unit/check-typelevel-basic.test | 77 +++++++++++ test-data/unit/fixtures/typing-full.pyi | 3 + 3 files changed, 224 insertions(+), 15 deletions(-) diff --git a/mypy/typelevel.py b/mypy/typelevel.py index 23f75ad7635db..b29a91cf70b07 100644 --- a/mypy/typelevel.py +++ b/mypy/typelevel.py @@ -16,7 +16,18 @@ from mypy.expandtype import expand_type, expand_type_by_instance from mypy.maptype import map_instance_to_supertype -from mypy.nodes import Context, FuncDef, Var +from mypy.mro import calculate_mro +from mypy.nodes import ( + MDEF, + Block, + ClassDef, + Context, + FuncDef, + SymbolTable, + SymbolTableNode, + TypeInfo, + Var, +) from mypy.subtypes import is_subtype from mypy.types import ( AnyType, @@ -43,7 +54,6 @@ ) if TYPE_CHECKING: - from mypy.nodes import TypeInfo from mypy.semanal_shared import SemanticAnalyzerInterface @@ -66,6 +76,9 @@ def __init__(self) -> None: # XXX: but maybe we should always thread the evaluator back # ourselves or something instead? self._evaluator: TypeLevelEvaluator | None = None + # Counter for generating unique synthetic protocol names + # Reset when a new API context is set to ensure deterministic names per-module + self.synthetic_protocol_counter: int = 0 @property def api(self) -> SemanticAnalyzerInterface | None: @@ -82,11 +95,17 @@ def set_api(self, api: SemanticAnalyzerInterface) -> Iterator[None]: result = get_proper_type(some_type) """ saved = self._api + saved_counter = self.synthetic_protocol_counter self._api = api + # HACK: Reset the counter when entering a new API context to ensure + # deterministic protocol names within each analysis context. + # This helps make tests more stable. + self.synthetic_protocol_counter = 0 try: yield finally: self._api = saved + self.synthetic_protocol_counter = saved_counter # Global context instance for type-level computation @@ -476,6 +495,28 @@ def extract_literal_string(typ: Type) -> str | None: return None +def extract_qualifier_strings(typ: Type) -> list[str]: + """Extract qualifier strings from a type that may be a Literal or Union of Literals. + + Used to extract qualifiers from Member[..., quals, ...] where quals can be: + - A single Literal[str] like Literal["ClassVar"] + - A Union of Literals like Literal["ClassVar"] | Literal["Final"] + - Never (no qualifiers) - returns empty list + """ + typ = get_proper_type(typ) + qual_strings: list[str] = [] + + if isinstance(typ, LiteralType) and isinstance(typ.value, str): + qual_strings.append(typ.value) + elif isinstance(typ, UnionType): + for item in typ.items: + item_proper = get_proper_type(item) + if isinstance(item_proper, LiteralType) and isinstance(item_proper.value, str): + qual_strings.append(item_proper.value) + + return qual_strings + + def _get_args(evaluator: TypeLevelEvaluator, target: Type, base: Type) -> Sequence[Type] | None: target = evaluator.eval_proper(target) base = evaluator.eval_proper(base) @@ -884,19 +925,7 @@ def _eval_new_typeddict(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> is_required = True # Default is Required is_readonly = False - # Check qualifiers - can be a single Literal or a Union of Literals - quals_proper = get_proper_type(quals) - qual_strings: list[str] = [] - - if isinstance(quals_proper, LiteralType) and isinstance(quals_proper.value, str): - qual_strings.append(quals_proper.value) - elif isinstance(quals_proper, UnionType): - for item in quals_proper.items: - item_proper = get_proper_type(item) - if isinstance(item_proper, LiteralType) and isinstance(item_proper.value, str): - qual_strings.append(item_proper.value) - - for qual in qual_strings: + for qual in extract_qualifier_strings(quals): if qual == "NotRequired": is_required = False elif qual == "Required": @@ -921,6 +950,106 @@ def _eval_new_typeddict(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> ) +@register_operator("NewProtocol") +def _eval_new_protocol(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: + """Evaluate NewProtocol[*Members] -> create a new structural protocol type. + + This creates a synthetic protocol class with members defined by the Member arguments. + The protocol type uses structural subtyping. + """ + + # Get the Member TypeInfo to verify arguments + member_info = evaluator.api.named_type_or_none("typing.Member") + if member_info is None: + return UninhabitedType() + + # Get object type for the base class + # HACK: We don't inherit from Protocol directly because Protocol is not always + # a TypeInfo in test fixtures. Instead we just set is_protocol=True and inherit + # from object, which is how mypy handles protocols internally (the Protocol base + # is removed from bases but is_protocol is set). + object_type = evaluator.api.named_type("builtins.object") + + # Build the members dictionary + members: dict[str, Type] = {} + member_vars: dict[str, tuple[Type, bool, bool]] = {} # name -> (type, is_classvar, is_final) + + for arg in evaluator.flatten_args(typ.args): + arg = get_proper_type(arg) + + # Each argument should be a Member[name, typ, quals, init, definer] + if not isinstance(arg, Instance) or arg.type != member_info.type: + # Not a Member type - can't construct protocol + return UninhabitedType() + + if len(arg.args) < 2: + return UninhabitedType() + + # Extract name and type from Member args + name_type = arg.args[0] + item_type = arg.args[1] + name = extract_literal_string(name_type) + if name is None: + return UninhabitedType() + + # Check qualifiers if present + is_classvar = False + is_final = False + + if len(arg.args) >= 3: + for qual in extract_qualifier_strings(arg.args[2]): + if qual == "ClassVar": + is_classvar = True + elif qual == "Final": + is_final = True + + members[name] = item_type + member_vars[name] = (item_type, is_classvar, is_final) + + # Generate a unique name for the synthetic protocol + typelevel_ctx.synthetic_protocol_counter += 1 + protocol_name = f"" + + # Create the synthetic protocol TypeInfo + # HACK: We create a ClassDef with an empty Block because TypeInfo requires one. + # This is purely synthetic and never actually executed. + class_def = ClassDef(protocol_name, Block([])) + class_def.fullname = f"__typelevel__.{protocol_name}" + + info = TypeInfo(SymbolTable(), class_def, "__typelevel__") + class_def.info = info + + # Mark as protocol + info.is_protocol = True + info.runtime_protocol = False # These aren't runtime checkable + + # Set up bases - inherit from object (not Protocol, since Protocol is just a marker) + info.bases = [object_type] + + # HACK: Set up MRO manually since we don't have a full semantic analysis pass. + # For a protocol, the MRO should be: [this_protocol, object] + try: + calculate_mro(info) + except Exception: + # If MRO calculation fails, set up a minimal one + # HACK: Minimal MRO setup when calculate_mro fails + info.mro = [info, object_type.type] + + # Add members to the symbol table + for name, (member_type, is_classvar, is_final) in member_vars.items(): + var = Var(name, member_type) + var.info = info + var._fullname = f"{info.fullname}.{name}" + var.is_classvar = is_classvar + var.is_final = is_final + var.is_initialized_in_class = True + # Don't mark as inferred since we have explicit types + var.is_inferred = False + info.names[name] = SymbolTableNode(MDEF, var) + + return Instance(info, []) + + @register_operator("Length") @lift_over_unions def _eval_length(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: diff --git a/test-data/unit/check-typelevel-basic.test b/test-data/unit/check-typelevel-basic.test index 002af4da6e68e..6fbb4611f9942 100644 --- a/test-data/unit/check-typelevel-basic.test +++ b/test-data/unit/check-typelevel-basic.test @@ -937,5 +937,82 @@ foo('a', 'a') foo(['a'], 'a') # E: Argument 1 to "foo" has incompatible type "list[str]"; expected "str" +[builtins fixtures/typelevel.pyi] +[typing fixtures/typing-full.pyi] + +[case testTypeOperatorNewProtocolBasic] +# flags: --python-version 3.14 + +from typing import NewProtocol, Member, Literal + +# Basic NewProtocol creation +type MyProto = NewProtocol[ + Member[Literal["x"], int], + Member[Literal["y"], str], +] + +# Check the protocol works with structural subtyping +class Good: + x: int + y: str + +class Bad: + x: int + # Missing y + +def takes_proto(p: MyProto) -> None: + pass + +g: Good +takes_proto(g) + +b: Bad +takes_proto(b) # E: Argument 1 to "takes_proto" has incompatible type "Bad"; expected "" \ + # N: "Bad" is missing following "" protocol member: \ + # N: y + + + +[builtins fixtures/typelevel.pyi] +[typing fixtures/typing-full.pyi] + +[case testTypeOperatorNewProtocolSubtyping] +# flags: --python-version 3.14 + +from typing import NewProtocol, Member, Literal + +# Create a protocol with two members +type Point = NewProtocol[ + Member[Literal["x"], int], + Member[Literal["y"], int], +] + +# Any class with x: int and y: int should satisfy Point +class Point2D: + x: int + y: int + +class Point3D: + x: int + y: int + z: int + +class NotAPoint: + a: int + b: int + +def draw(p: Point) -> None: + pass + +p2d: Point2D +p3d: Point3D +nap: NotAPoint + +draw(p2d) # OK - has x and y +draw(p3d) # OK - has x and y (plus z is fine) +draw(nap) # E: Argument 1 to "draw" has incompatible type "NotAPoint"; expected "" + + + [builtins fixtures/typelevel.pyi] [typing fixtures/typing-full.pyi] diff --git a/test-data/unit/fixtures/typing-full.pyi b/test-data/unit/fixtures/typing-full.pyi index 94b7b160a0901..a8cad6140fde4 100644 --- a/test-data/unit/fixtures/typing-full.pyi +++ b/test-data/unit/fixtures/typing-full.pyi @@ -300,6 +300,9 @@ class Attrs(Generic[T]): ... @_type_operator class NewTypedDict(Generic[Unpack[_Ts]]): ... +@_type_operator +class NewProtocol(Generic[Unpack[_Ts]]): ... + # Member data type for type-level computation _Name = TypeVar('_Name') _Type = TypeVar('_Type') From e6f228556a9ae56e08acd0ae9acb68b543e7f241 Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Mon, 2 Feb 2026 14:06:59 -0800 Subject: [PATCH 102/161] Give better names to the classes created by NewProtocol --- mypy/messages.py | 2 +- mypy/typelevel.py | 41 +++++---- mypy/types.py | 10 ++- test-data/unit/check-incremental.test | 39 +++++++++ test-data/unit/check-typelevel-basic.test | 92 +++++++++++++++++++- test-data/unit/check-typelevel-examples.test | 10 +-- 6 files changed, 167 insertions(+), 27 deletions(-) diff --git a/mypy/messages.py b/mypy/messages.py index e4dc4b5cab581..32c2eeecf396a 100644 --- a/mypy/messages.py +++ b/mypy/messages.py @@ -1749,7 +1749,7 @@ def reveal_type(self, typ: Type, context: Context) -> None: return # Nothing special here; just create the note: - visitor = TypeStrVisitor(expand=True, options=self.options) + visitor = TypeStrVisitor(expand=True, expand_recursive=True, options=self.options) self.note(f'Revealed type is "{typ.accept(visitor)}"', context) def reveal_locals(self, type_map: dict[str, Type | None], context: Context) -> None: diff --git a/mypy/typelevel.py b/mypy/typelevel.py index b29a91cf70b07..02283fbc2d834 100644 --- a/mypy/typelevel.py +++ b/mypy/typelevel.py @@ -76,9 +76,6 @@ def __init__(self) -> None: # XXX: but maybe we should always thread the evaluator back # ourselves or something instead? self._evaluator: TypeLevelEvaluator | None = None - # Counter for generating unique synthetic protocol names - # Reset when a new API context is set to ensure deterministic names per-module - self.synthetic_protocol_counter: int = 0 @property def api(self) -> SemanticAnalyzerInterface | None: @@ -95,17 +92,11 @@ def set_api(self, api: SemanticAnalyzerInterface) -> Iterator[None]: result = get_proper_type(some_type) """ saved = self._api - saved_counter = self.synthetic_protocol_counter self._api = api - # HACK: Reset the counter when entering a new API context to ensure - # deterministic protocol names within each analysis context. - # This helps make tests more stable. - self.synthetic_protocol_counter = 0 try: yield finally: self._api = saved - self.synthetic_protocol_counter = saved_counter # Global context instance for type-level computation @@ -950,6 +941,24 @@ def _eval_new_typeddict(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> ) +def _proto_entry_str(entry: tuple[Type, bool, bool]) -> str: + typ, is_classvar, is_final = entry + # XXX: We fully expand the type here for stringifying, which is + # potentially dangerous... + # TODO: We'll need to prevent recursion or something. + styp = typ.str_with_options(expand=True) + if is_classvar: + styp = f"ClassVar[{styp}]" + if is_final: + styp = f"Final[{styp}]" + return styp + + +def _proto_str(map: dict[str, tuple[Type, bool, bool]]) -> str: + body = [f"{name}: {_proto_entry_str(entry)}" for name, entry in map.items()] + return f"NewProtocol[{', '.join(body)}]" + + @register_operator("NewProtocol") def _eval_new_protocol(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: """Evaluate NewProtocol[*Members] -> create a new structural protocol type. @@ -958,20 +967,21 @@ def _eval_new_protocol(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> The protocol type uses structural subtyping. """ + # TODO: methods are probably in bad shape + # Get the Member TypeInfo to verify arguments member_info = evaluator.api.named_type_or_none("typing.Member") if member_info is None: return UninhabitedType() # Get object type for the base class - # HACK: We don't inherit from Protocol directly because Protocol is not always + # N.B: We don't inherit from Protocol directly because Protocol is not always # a TypeInfo in test fixtures. Instead we just set is_protocol=True and inherit # from object, which is how mypy handles protocols internally (the Protocol base # is removed from bases but is_protocol is set). object_type = evaluator.api.named_type("builtins.object") # Build the members dictionary - members: dict[str, Type] = {} member_vars: dict[str, tuple[Type, bool, bool]] = {} # name -> (type, is_classvar, is_final) for arg in evaluator.flatten_args(typ.args): @@ -987,11 +997,12 @@ def _eval_new_protocol(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> # Extract name and type from Member args name_type = arg.args[0] - item_type = arg.args[1] name = extract_literal_string(name_type) if name is None: return UninhabitedType() + item_type = arg.args[1] + # Check qualifiers if present is_classvar = False is_final = False @@ -1003,12 +1014,12 @@ def _eval_new_protocol(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> elif qual == "Final": is_final = True - members[name] = item_type member_vars[name] = (item_type, is_classvar, is_final) # Generate a unique name for the synthetic protocol - typelevel_ctx.synthetic_protocol_counter += 1 - protocol_name = f"" + # XXX: I hope that it is unique based on the inputs? + # Should we cache it also? + protocol_name = _proto_str(member_vars) # Create the synthetic protocol TypeInfo # HACK: We create a ClassDef with an empty Block because TypeInfo requires one. diff --git a/mypy/types.py b/mypy/types.py index 778970ba83c08..8879dd548df07 100644 --- a/mypy/types.py +++ b/mypy/types.py @@ -4071,12 +4071,18 @@ class TypeStrVisitor(SyntheticTypeVisitor[str]): """ def __init__( - self, id_mapper: IdMapper | None = None, expand: bool = False, *, options: Options + self, + id_mapper: IdMapper | None = None, + expand: bool = False, + expand_recursive: bool = False, + *, + options: Options, ) -> None: self.id_mapper = id_mapper self.options = options self.dotted_aliases: set[TypeAliasType] | None = None self.expand = expand + self.expand_recursive = expand_recursive def visit_unbound_type(self, t: UnboundType, /) -> str: s = t.name + "?" @@ -4366,7 +4372,7 @@ def visit_type_alias_type(self, t: TypeAliasType, /) -> str: if t.alias is None: return "" - if self.expand: + if (self.expand and not t.is_recursive) or self.expand_recursive: if not t.is_recursive: return get_proper_type(t).accept(self) if self.dotted_aliases is None: diff --git a/test-data/unit/check-incremental.test b/test-data/unit/check-incremental.test index c9c6f8ce85c9e..2c7a4779dc85a 100644 --- a/test-data/unit/check-incremental.test +++ b/test-data/unit/check-incremental.test @@ -283,6 +283,45 @@ type PropsOnly[T] = list[ [builtins fixtures/typelevel.pyi] [typing fixtures/typing-full.pyi] +[case testIncrementalNewProtocol] +# flags: --python-version 3.14 +import a +import b + +[file a.py] + +from typing import NewProtocol, Member, Literal, Iter + +# Basic NewProtocol creation +type MyProto = NewProtocol[ + Member[Literal["x"], int], + Member[Literal["y"], str], +] +x: MyProto + +type LinkedList[T] = NewProtocol[ + Member[Literal["data"], T], + Member[Literal["next"], LinkedList[T]], +] + +z: LinkedList[str] + +lol: tuple[*[t for t in Iter[tuple[MyProto]]]] + +asdf: NewProtocol[ + Member[Literal["x"], int], + Member[Literal["y"], str], +] + +[file b.py] + +[file b.py.2] + +# dummy change + +[builtins fixtures/typelevel.pyi] +[typing fixtures/typing-full.pyi] + [case testIncrementalMethodInterfaceChange] import mod1 diff --git a/test-data/unit/check-typelevel-basic.test b/test-data/unit/check-typelevel-basic.test index 6fbb4611f9942..8b4df63939da3 100644 --- a/test-data/unit/check-typelevel-basic.test +++ b/test-data/unit/check-typelevel-basic.test @@ -967,15 +967,101 @@ g: Good takes_proto(g) b: Bad -takes_proto(b) # E: Argument 1 to "takes_proto" has incompatible type "Bad"; expected "" \ - # N: "Bad" is missing following "" protocol member: \ +takes_proto(b) # E: Argument 1 to "takes_proto" has incompatible type "Bad"; expected "NewProtocol[x: builtins.int, y: builtins.str]" \ + # N: "Bad" is missing following "NewProtocol[x: builtins.int, y: builtins.str]" protocol member: \ # N: y +[builtins fixtures/typelevel.pyi] +[typing fixtures/typing-full.pyi] + + +[case testTypeOperatorNewProtocolRecursive1] +# flags: --python-version 3.14 + +from typing import NewProtocol, Member, Literal + +# Basic NewProtocol creation +type LinkedList = NewProtocol[ + Member[Literal["data"], int], + Member[Literal["next"], LinkedList], +] + +z: LinkedList +reveal_type(z) # N: Revealed type is "__typelevel__.NewProtocol[data: builtins.int, next: __main__.LinkedList]" + +reveal_type(z.data) # N: Revealed type is "builtins.int" +reveal_type(z.next.next.data) # N: Revealed type is "builtins.int" + +[builtins fixtures/typelevel.pyi] +[typing fixtures/typing-full.pyi] + + +[case testTypeOperatorNewProtocolRecursive2] +# flags: --python-version 3.14 + +from typing import NewProtocol, Member, Literal + +# Basic NewProtocol creation +type LinkedList[T] = NewProtocol[ + Member[Literal["data"], T], + Member[Literal["next"], LinkedList[T]], +] + +z: LinkedList[str] +reveal_type(z) # N: Revealed type is "__typelevel__.NewProtocol[data: builtins.str, next: __main__.LinkedList[builtins.str]]" + +reveal_type(z.data) # N: Revealed type is "builtins.str" +reveal_type(z.next.next.data) # N: Revealed type is "builtins.str" + +[builtins fixtures/typelevel.pyi] +[typing fixtures/typing-full.pyi] + + +[case testTypeOperatorNewTypedDictRecursive] +# flags: --python-version 3.14 + +from typing import NewTypedDict, Member, Literal + +# Basic NewProtocol creation +type LinkedList = NewTypedDict[ + Member[Literal["data"], int], + Member[Literal["next"], LinkedList], +] + +z: LinkedList +reveal_type(z) # N: Revealed type is "TypedDict({'data': builtins.int, 'next': ...})" + + +[builtins fixtures/typelevel.pyi] +[typing fixtures/typing-full.pyi] + + +[case testTypeOperatorNewProtocolReturn] +# flags: --python-version 3.14 + +from typing import NewProtocol, Member, Literal + + +def foo[T, U](x: T, y: U) -> NewProtocol[ + Member[Literal["x"], T], + Member[Literal["y"], U], +]: + raise BaseException + + +res1 = foo('lol', 10) +reveal_type(res1.x) # N: Revealed type is "builtins.str" +reveal_type(res1.y) # N: Revealed type is "builtins.int" + +res2 = foo(['foo', 'bar'], None) +reveal_type(res2.x) # N: Revealed type is "builtins.list[builtins.str]" +reveal_type(res2.y) # N: Revealed type is "None" [builtins fixtures/typelevel.pyi] [typing fixtures/typing-full.pyi] + [case testTypeOperatorNewProtocolSubtyping] # flags: --python-version 3.14 @@ -1010,7 +1096,7 @@ nap: NotAPoint draw(p2d) # OK - has x and y draw(p3d) # OK - has x and y (plus z is fine) -draw(nap) # E: Argument 1 to "draw" has incompatible type "NotAPoint"; expected "" +draw(nap) # E: Argument 1 to "draw" has incompatible type "NotAPoint"; expected "NewProtocol[x: builtins.int, y: builtins.int]" diff --git a/test-data/unit/check-typelevel-examples.test b/test-data/unit/check-typelevel-examples.test index 677f47c9124e0..8ad0fa6c57f55 100644 --- a/test-data/unit/check-typelevel-examples.test +++ b/test-data/unit/check-typelevel-examples.test @@ -4,10 +4,8 @@ from typing import Literal, Unpack, TypedDict from typing import ( - # NewProtocol, + NewProtocol, BaseTypedDict, - NewTypedDict, - NewTypedDict as NewProtocol, Iter, Attrs, IsSub, @@ -84,7 +82,7 @@ def select_ex[ModelT, K: BaseTypedDict]( typ: type[ModelT], kwargs: K, ) -> list[ - NewTypedDict[ + NewProtocol[ *[ Member[ GetName[c], @@ -187,7 +185,7 @@ class Args0(TypedDict): name: Literal[True] args0: Args0 -reveal_type(select_ex(User, args0)) # N: Revealed type is "builtins.list[TypedDict({'id': builtins.int, 'name': builtins.str})]" +reveal_type(select_ex(User, args0)) # N: Revealed type is "builtins.list[__typelevel__.NewProtocol[id: builtins.int, name: builtins.str]]" class Args1(TypedDict): @@ -196,7 +194,7 @@ class Args1(TypedDict): posts: Literal[True] args1: Args1 -reveal_type(select_ex(User, args1)) # N: Revealed type is "builtins.list[TypedDict({'name': builtins.str, 'email': builtins.str, 'posts': builtins.list[TypedDict({'id': builtins.int, 'title': builtins.str, 'content': builtins.str})]})]" +reveal_type(select_ex(User, args1)) # N: Revealed type is "builtins.list[__typelevel__.NewProtocol[name: builtins.str, email: builtins.str, posts: builtins.list[__typelevel__.NewProtocol[id: builtins.int, title: builtins.str, content: builtins.str]]]]" [builtins fixtures/typelevel.pyi] [typing fixtures/typing-full.pyi] From 06f16e3e97928ce600419515c4519b14203f9ad7 Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Tue, 3 Feb 2026 12:03:22 -0800 Subject: [PATCH 103/161] Rename typing.GetAttr to typing.GetMemberType Co-Authored-By: Claude Opus 4.5 --- mypy/typelevel.py | 6 +++--- mypy/typeshed/stdlib/_typeshed/typemap.pyi | 14 +++++++------- mypy/typeshed/stdlib/typing.pyi | 4 ++-- test-data/unit/check-typelevel-examples.test | 8 ++++---- test-data/unit/check-typelevel-members.test | 4 ++-- test-data/unit/fixtures/typing-full.pyi | 12 ++++++------ 6 files changed, 24 insertions(+), 24 deletions(-) diff --git a/mypy/typelevel.py b/mypy/typelevel.py index 02283fbc2d834..eab1ac7265a65 100644 --- a/mypy/typelevel.py +++ b/mypy/typelevel.py @@ -581,10 +581,10 @@ def _eval_from_union(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Ty return evaluator.tuple_type([target]) -@register_operator("GetAttr") +@register_operator("GetMemberType") @lift_over_unions -def _eval_get_attr(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: - """Evaluate GetAttr[T, Name] - get attribute type from T.""" +def _eval_get_member_type(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: + """Evaluate GetMemberType[T, Name] - get attribute type from T.""" if len(typ.args) != 2: return UninhabitedType() diff --git a/mypy/typeshed/stdlib/_typeshed/typemap.pyi b/mypy/typeshed/stdlib/_typeshed/typemap.pyi index bf830084fc838..a9f836f37323a 100644 --- a/mypy/typeshed/stdlib/_typeshed/typemap.pyi +++ b/mypy/typeshed/stdlib/_typeshed/typemap.pyi @@ -104,7 +104,7 @@ class GetArgs(Generic[_T, _Base]): ... @_type_operator -class GetAttr(Generic[_T, _Name]): +class GetMemberType(Generic[_T, _Name]): """ Get the type of attribute _Name from type _T. _Name must be a Literal[str]. @@ -139,7 +139,7 @@ class FromUnion(Generic[_T]): ... -# --- Member/Param Accessors (defined as type aliases using GetAttr) --- +# --- Member/Param Accessors (defined as type aliases using GetMemberType) --- # _MP = TypeVar("_MP", bound=Member[Any, Any, Any, Any, Any] | Param[Any, Any, Any]) # _M = TypeVar("_M", bound=Member[Any, Any, Any, Any, Any]) @@ -148,11 +148,11 @@ _MP = TypeVar("_MP") _M = TypeVar("_M") -GetName = GetAttr[_MP, Literal["name"]] -GetType = GetAttr[_MP, Literal["typ"]] -GetQuals = GetAttr[_MP, Literal["quals"]] -GetInit = GetAttr[_M, Literal["init"]] -GetDefiner = GetAttr[_M, Literal["definer"]] +GetName = GetMemberType[_MP, Literal["name"]] +GetType = GetMemberType[_MP, Literal["typ"]] +GetQuals = GetMemberType[_MP, Literal["quals"]] +GetInit = GetMemberType[_M, Literal["init"]] +GetDefiner = GetMemberType[_M, Literal["definer"]] # --- Type Construction Operators --- diff --git a/mypy/typeshed/stdlib/typing.pyi b/mypy/typeshed/stdlib/typing.pyi index 20eef734984ee..3f312daee72a6 100644 --- a/mypy/typeshed/stdlib/typing.pyi +++ b/mypy/typeshed/stdlib/typing.pyi @@ -1194,7 +1194,7 @@ if sys.version_info >= (3, 15): # Type operators "GetArg", "GetArgs", - "GetAttr", + "GetMemberType", "Members", "Attrs", "FromUnion", @@ -1249,7 +1249,7 @@ if sys.version_info >= (3, 15): GetAnnotations as GetAnnotations, GetArg as GetArg, GetArgs as GetArgs, - GetAttr as GetAttr, + GetMemberType as GetMemberType, GetDefiner as GetDefiner, GetInit as GetInit, GetName as GetName, diff --git a/test-data/unit/check-typelevel-examples.test b/test-data/unit/check-typelevel-examples.test index 8ad0fa6c57f55..bbfb5a9e65868 100644 --- a/test-data/unit/check-typelevel-examples.test +++ b/test-data/unit/check-typelevel-examples.test @@ -12,7 +12,7 @@ from typing import ( GetType, Member, GetName, - GetAttr, + GetMemberType, GetArg, ) @@ -56,7 +56,7 @@ type-annotated attribute of ``K``, while calling ``NewProtocol`` with ``GetName`` is a getter operator that fetches the name of a ``Member`` as a literal type--all of these mechanisms lean very heavily on literal types. -``GetAttr`` gets the type of an attribute from a class. +``GetMemberType`` gets the type of an attribute from a class. """ @@ -70,7 +70,7 @@ as a literal type--all of these mechanisms lean very heavily on literal types. # *[ # Member[ # GetName[c], -# ConvertField[GetAttr[ModelT, GetName[c]]], +# ConvertField[GetMemberType[ModelT, GetName[c]]], # ] # for c in Iter[Attrs[K]] # ] @@ -86,7 +86,7 @@ def select_ex[ModelT, K: BaseTypedDict]( *[ Member[ GetName[c], - ConvertField[GetAttr[ModelT, GetName[c]]], + ConvertField[GetMemberType[ModelT, GetName[c]]], ] for c in Iter[Attrs[K]] ] diff --git a/test-data/unit/check-typelevel-members.test b/test-data/unit/check-typelevel-members.test index 8564fce2e27bd..919b9bff07a71 100644 --- a/test-data/unit/check-typelevel-members.test +++ b/test-data/unit/check-typelevel-members.test @@ -285,7 +285,7 @@ x = {'name': 'Alice', 'age': 30} [typing fixtures/typing-full.pyi] [case testNewTypedDictWithComprehensionFilter] -from typing import NewTypedDict, Members, TypedDict, Iter, IsSub, GetAttr, Literal +from typing import NewTypedDict, Members, TypedDict, Iter, IsSub, GetMemberType, Literal class Person(TypedDict): name: str @@ -293,7 +293,7 @@ class Person(TypedDict): active: bool # Filter to only string fields -TD = NewTypedDict[*[m for m in Iter[Members[Person]] if IsSub[GetAttr[m, Literal['typ']], str]]] +TD = NewTypedDict[*[m for m in Iter[Members[Person]] if IsSub[GetMemberType[m, Literal['typ']], str]]] x: TD reveal_type(x) # N: Revealed type is "TypedDict({'name': builtins.str})" x = {'name': 'Alice'} diff --git a/test-data/unit/fixtures/typing-full.pyi b/test-data/unit/fixtures/typing-full.pyi index a8cad6140fde4..a0905b76c677a 100644 --- a/test-data/unit/fixtures/typing-full.pyi +++ b/test-data/unit/fixtures/typing-full.pyi @@ -265,7 +265,7 @@ class GetArgs(Generic[T, U]): ... class FromUnion(Generic[T]): ... @_type_operator -class GetAttr(Generic[T, U]): ... +class GetMemberType(Generic[T, U]): ... @_type_operator class Slice(Generic[T, U, V]): ... @@ -326,8 +326,8 @@ class Member(Generic[_Name, _Type, _Quals, _Init, _Definer]): _MP = TypeVar("_MP") _M = TypeVar("_M") -GetName = GetAttr[_MP, Literal["name"]] -GetType = GetAttr[_MP, Literal["typ"]] -GetQuals = GetAttr[_MP, Literal["quals"]] -GetInit = GetAttr[_M, Literal["init"]] -GetDefiner = GetAttr[_M, Literal["definer"]] +GetName = GetMemberType[_MP, Literal["name"]] +GetType = GetMemberType[_MP, Literal["typ"]] +GetQuals = GetMemberType[_MP, Literal["quals"]] +GetInit = GetMemberType[_M, Literal["init"]] +GetDefiner = GetMemberType[_M, Literal["definer"]] From 7b3de1abf7c72a5f2e9cc71e13415d6b6dd319aa Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Mon, 2 Feb 2026 16:32:17 -0800 Subject: [PATCH 104/161] Implement Init field in Member for Members and Attrs type operators Add support for capturing the initializer type in the Init field of Member types returned by Members[T] and Attrs[T] type operators. Changes: - Add init_type field to Var nodes to store the type of class member initializers during type checking - Set init_type in checker.py for annotated class members with explicit values, extracting literal types from Instance.last_known_value - Update create_member_type in typelevel.py to use init_type - Add serialization support for init_type in nodes.py - Copy init_type in treetransform.py For class members with initializers like: class Config: NAME: Final[str] = "app" # Init = Literal['app'] count: int = 42 # Init = Literal[42] plain: str # Init = Never (no initializer) The Init field now correctly captures the literal type of the initializer expression for annotated class members. Co-Authored-By: Claude Opus 4.5 --- mypy/checker.py | 18 ++++++- mypy/nodes.py | 10 ++++ mypy/treetransform.py | 1 + mypy/typelevel.py | 6 ++- test-data/unit/check-incremental.test | 6 ++- test-data/unit/check-typelevel-members.test | 52 ++++++++++++++++++++- 6 files changed, 87 insertions(+), 6 deletions(-) diff --git a/mypy/checker.py b/mypy/checker.py index a1ec2c017b376..13e9fbcc7a712 100644 --- a/mypy/checker.py +++ b/mypy/checker.py @@ -240,7 +240,13 @@ is_literal_type, is_named_instance, ) -from mypy.types_utils import is_overlapping_none, remove_optional, store_argument_type, strip_type +from mypy.types_utils import ( + is_overlapping_none, + remove_optional, + store_argument_type, + strip_type, + try_getting_literal, +) from mypy.typetraverser import TypeTraverserVisitor from mypy.typevars import fill_typevars, fill_typevars_with_any, has_no_typevars from mypy.util import is_dunder, is_sunder @@ -3409,6 +3415,16 @@ def check_assignment( rvalue_type, lvalue_type = self.check_simple_assignment( lvalue_type, rvalue, context=rvalue, inferred=inferred, lvalue=lvalue ) + # Store init_type for annotated class members with explicit values. + # This preserves the literal type information for the typemap Init field. + if ( + isinstance(lvalue, NameExpr) + and isinstance(lvalue.node, Var) + and lvalue.node.is_initialized_in_class + and lvalue.node.has_explicit_value + and lvalue.node.init_type is None + ): + lvalue.node.init_type = try_getting_literal(rvalue_type) # The above call may update inferred variable type. Prevent further # inference. inferred = None diff --git a/mypy/nodes.py b/mypy/nodes.py index 260bc9c0f5dd1..daa41a2c4b513 100644 --- a/mypy/nodes.py +++ b/mypy/nodes.py @@ -1358,6 +1358,7 @@ class Var(SymbolNode): "type", "setter_type", "final_value", + "init_type", "is_self", "is_cls", "is_ready", @@ -1419,6 +1420,9 @@ def __init__(self, name: str, type: mypy.types.Type | None = None) -> None: # store the literal value (unboxed) for the benefit of # tools like mypyc. self.final_value: int | float | complex | bool | str | None = None + # The type of the initializer expression, if this is a class member with + # an initializer. Used for the Init field in typemap Member types. + self.init_type: mypy.types.Type | None = None # Where the value was set (only for class attributes) self.final_unset_in_class = False self.final_set_in_init = False @@ -1471,6 +1475,8 @@ def serialize(self) -> JsonDict: } if self.final_value is not None: data["final_value"] = self.final_value + if self.init_type is not None: + data["init_type"] = self.init_type.serialize() return data @classmethod @@ -1494,6 +1500,8 @@ def deserialize(cls, data: JsonDict) -> Var: v._fullname = data["fullname"] set_flags(v, data["flags"]) v.final_value = data.get("final_value") + if data.get("init_type") is not None: + v.init_type = mypy.types.deserialize_type(data["init_type"]) return v def write(self, data: WriteBuffer) -> None: @@ -1527,6 +1535,7 @@ def write(self, data: WriteBuffer) -> None: ], ) write_literal(data, self.final_value) + mypy.types.write_type_opt(data, self.init_type) write_tag(data, END_TAG) @classmethod @@ -1567,6 +1576,7 @@ def read(cls, data: ReadBuffer) -> Var: v.final_value = complex(read_float_bare(data), read_float_bare(data)) elif tag != LITERAL_NONE: v.final_value = read_literal(data, tag) + v.init_type = mypy.types.read_type_opt(data) assert read_tag(data) == END_TAG return v diff --git a/mypy/treetransform.py b/mypy/treetransform.py index 25092de66a149..77a401c980a07 100644 --- a/mypy/treetransform.py +++ b/mypy/treetransform.py @@ -307,6 +307,7 @@ def visit_var(self, node: Var) -> Var: new.is_property = node.is_property new.is_final = node.is_final new.final_value = node.final_value + new.init_type = node.init_type new.final_unset_in_class = node.final_unset_in_class new.final_set_in_init = node.final_set_in_init new.set_line(node) diff --git a/mypy/typelevel.py b/mypy/typelevel.py index eab1ac7265a65..90420ec099d43 100644 --- a/mypy/typelevel.py +++ b/mypy/typelevel.py @@ -866,9 +866,11 @@ def create_member_type( else: quals = UninhabitedType() - # For init, we currently don't track initializer literal types - # This would require changes to semantic analysis + # For init, use init_type when available (set during type checking for class members). + # The literal type extraction is done in checker.py when init_type is set. init: Type = UninhabitedType() + if isinstance(node, Var) and node.init_type is not None: + init = node.init_type return Instance( member_type_info, diff --git a/test-data/unit/check-incremental.test b/test-data/unit/check-incremental.test index 2c7a4779dc85a..d21a0a704d978 100644 --- a/test-data/unit/check-incremental.test +++ b/test-data/unit/check-incremental.test @@ -2901,8 +2901,9 @@ x = b.c.A() import c [file c.py] +FOO = 1 class A: - x = 1 + x = FOO [file d.py] import a @@ -2913,8 +2914,9 @@ import b x: b.c.A [file c.py.3] +FOO = 2 class A: - x = 2 + x = FOO [file d.py.4] import a diff --git a/test-data/unit/check-typelevel-members.test b/test-data/unit/check-typelevel-members.test index 919b9bff07a71..ec15728dd82c3 100644 --- a/test-data/unit/check-typelevel-members.test +++ b/test-data/unit/check-typelevel-members.test @@ -35,7 +35,7 @@ class Constants: NAME: str m: Members[Constants] -reveal_type(m) # N: Revealed type is "tuple[typing.Member[Literal['PI'], builtins.float, Literal['Final'], Never, __main__.Constants], typing.Member[Literal['NAME'], builtins.str, Never, Never, __main__.Constants]]" +reveal_type(m) # N: Revealed type is "tuple[typing.Member[Literal['PI'], builtins.float, Literal['Final'], builtins.float, __main__.Constants], typing.Member[Literal['NAME'], builtins.str, Never, Never, __main__.Constants]]" [builtins fixtures/typelevel.pyi] [typing fixtures/typing-full.pyi] @@ -316,3 +316,53 @@ x = {'x': 1, 'y': 2} [builtins fixtures/typelevel.pyi] [typing fixtures/typing-full.pyi] + +[case testTypeOperatorMembersInit] +# flags: --python-version 3.12 +# Test Members Init field captures literal values from Final variables +from typing import Members, Final, GetInit, Iter + +class Config: + NAME: Final[str] = "app" + VERSION: Final[int] = 42 + ENABLED: Final[bool] = True + RATIO: Final[float] = 3.14 + plain: str # No initializer + +m: Members[Config] +reveal_type(m) # N: Revealed type is "tuple[typing.Member[Literal['NAME'], builtins.str, Literal['Final'], Literal['app'], __main__.Config], typing.Member[Literal['VERSION'], builtins.int, Literal['Final'], Literal[42], __main__.Config], typing.Member[Literal['ENABLED'], builtins.bool, Literal['Final'], Literal[True], __main__.Config], typing.Member[Literal['RATIO'], builtins.float, Literal['Final'], builtins.float, __main__.Config], typing.Member[Literal['plain'], builtins.str, Never, Never, __main__.Config]]" + +# Test GetInit accessor +type InitValues = tuple[*[GetInit[m] for m in Iter[Members[Config]]]] +x: InitValues +reveal_type(x) # N: Revealed type is "tuple[Literal['app'], Literal[42], Literal[True], builtins.float, Never]" + +[builtins fixtures/typelevel.pyi] +[typing fixtures/typing-full.pyi] + +[case testTypeOperatorMembersInitNonFinal] +# flags: --python-version 3.12 +# Test Members Init field for non-Final variables with initializers +from typing import Members, Attrs, GetInit, Iter + +class Data: + name: str = "default" + email: str = "lol@lol.com" + count: int = 42 + active: bool = True + no_init: str # No initializer + +m: Members[Data] +reveal_type(m) # N: Revealed type is "tuple[typing.Member[Literal['name'], builtins.str, Never, Literal['default'], __main__.Data], typing.Member[Literal['email'], builtins.str, Never, Literal['lol@lol.com'], __main__.Data], typing.Member[Literal['count'], builtins.int, Never, Literal[42], __main__.Data], typing.Member[Literal['active'], builtins.bool, Never, Literal[True], __main__.Data], typing.Member[Literal['no_init'], builtins.str, Never, Never, __main__.Data]]" + +# Test Attrs also captures init +a: Attrs[Data] +reveal_type(a) # N: Revealed type is "tuple[typing.Member[Literal['name'], builtins.str, Never, Literal['default'], __main__.Data], typing.Member[Literal['email'], builtins.str, Never, Literal['lol@lol.com'], __main__.Data], typing.Member[Literal['count'], builtins.int, Never, Literal[42], __main__.Data], typing.Member[Literal['active'], builtins.bool, Never, Literal[True], __main__.Data], typing.Member[Literal['no_init'], builtins.str, Never, Never, __main__.Data]]" + +# Test GetInit accessor for non-Final variables +type InitValues = tuple[*[GetInit[m] for m in Iter[Attrs[Data]]]] +x: InitValues +reveal_type(x) # N: Revealed type is "tuple[Literal['default'], Literal['lol@lol.com'], Literal[42], Literal[True], Never]" + +[builtins fixtures/typelevel.pyi] +[typing fixtures/typing-full.pyi] From 8b1139dd22cc1de5b3d55bc9158a36e67c26efef Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Tue, 3 Feb 2026 12:18:39 -0800 Subject: [PATCH 105/161] Make GetMemberType work on TypedDict --- mypy/typelevel.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/mypy/typelevel.py b/mypy/typelevel.py index 90420ec099d43..513c0abf475f6 100644 --- a/mypy/typelevel.py +++ b/mypy/typelevel.py @@ -595,12 +595,18 @@ def _eval_get_member_type(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) if name is None: return UninhabitedType() + # TODO: Use the Members logic? if isinstance(target, Instance): node = target.type.names.get(name) if node is not None and node.type is not None: # Expand the attribute type with the instance's type arguments return expand_type_by_instance(node.type, target) return UninhabitedType() + if isinstance(target, TypedDictType): + itype = target.items.get(name) + if itype is not None: + return itype + return UninhabitedType() return UninhabitedType() From ff45304881331b8863133630c57cedb55b95ff49 Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Tue, 3 Feb 2026 12:37:55 -0800 Subject: [PATCH 106/161] Add InitField and a version of the fastapi test! --- mypy/typeshed/stdlib/_typeshed/typemap.pyi | 13 +- mypy/typeshed/stdlib/typing.pyi | 2 + test-data/unit/check-typelevel-examples.test | 159 +++++++++++++++++++ test-data/unit/fixtures/typing-full.pyi | 9 ++ 4 files changed, 182 insertions(+), 1 deletion(-) diff --git a/mypy/typeshed/stdlib/_typeshed/typemap.pyi b/mypy/typeshed/stdlib/_typeshed/typemap.pyi index a9f836f37323a..d452143f57aec 100644 --- a/mypy/typeshed/stdlib/_typeshed/typemap.pyi +++ b/mypy/typeshed/stdlib/_typeshed/typemap.pyi @@ -5,7 +5,7 @@ and typemap.typing. """ import typing_extensions -from typing import Generic, Literal, TypeVar, TypedDict +from typing import Any, Generic, Literal, TypeVar, TypedDict from typing_extensions import TypeVarTuple, Unpack, Never class BaseTypedDict(TypedDict): @@ -15,6 +15,17 @@ class BaseTypedDict(TypedDict): _S = TypeVar("_S") _T = TypeVar("_T") +_KwargDict = TypeVar('_KwargDict', bound=BaseTypedDict) + +# Inherit from Any to allow the assignments. +# TODO: Should we do this in a more principled way? +class InitField(Generic[_KwargDict], Any): + def __init__(self, **kwargs: Unpack[_KwargDict]) -> None: + ... + + def _get_kwargs(self) -> _KwargDict: + ... + # Marker decorator for type operators. Classes decorated with this are treated # specially by the type checker as type-level computation operators. def _type_operator(cls: type[_T]) -> type[_T]: ... diff --git a/mypy/typeshed/stdlib/typing.pyi b/mypy/typeshed/stdlib/typing.pyi index 3f312daee72a6..b78a177762095 100644 --- a/mypy/typeshed/stdlib/typing.pyi +++ b/mypy/typeshed/stdlib/typing.pyi @@ -1235,6 +1235,7 @@ if sys.version_info >= (3, 15): "ParamQuals", # Misc "BaseTypedDict", + "InitField", ] from _typeshed.typemap import ( Attrs as Attrs, @@ -1256,6 +1257,7 @@ if sys.version_info >= (3, 15): GetQuals as GetQuals, GetType as GetType, IsSub as IsSub, + InitField as InitField, Iter as Iter, KwargsParam as KwargsParam, Length as Length, diff --git a/test-data/unit/check-typelevel-examples.test b/test-data/unit/check-typelevel-examples.test index bbfb5a9e65868..23345264fbf4a 100644 --- a/test-data/unit/check-typelevel-examples.test +++ b/test-data/unit/check-typelevel-examples.test @@ -430,5 +430,164 @@ err2: Array[float, L[3]] # err1 + err2 # XXX: We want to do this one but we get the wrong error location! +[builtins fixtures/typelevel.pyi] +[typing fixtures/typing-full.pyi] + + +[case testTypeLevel_fastapilike] +# flags: --python-version 3.14 + +from typing import ( + Callable, + Literal, + Union, + ReadOnly, + TypedDict, + Never, + Self, +) + +import typing + + +class FieldArgs(TypedDict, total=False): + hidden: ReadOnly[bool] + primary_key: ReadOnly[bool] + index: ReadOnly[bool] + default: ReadOnly[object] + + +class Field[T: FieldArgs](typing.InitField[T]): + pass + + +#### + +# TODO: Should this go into the stdlib? +type GetFieldItem[T, K] = typing.GetMemberType[ + typing.GetArg[T, typing.InitField, Literal[0]], K +] + + +## + +# XXX: This is an atrocity, but Union doesn't work with variadics yet. +type NewUnionInner[Ts] = ( + typing.Never + if typing.Matches[typing.Length[Ts], typing.Literal[0]] + else + typing.GetArg[Ts, tuple, Literal[0]] + if typing.Matches[typing.Length[Ts], typing.Literal[1]] + else + typing.GetArg[Ts, tuple, Literal[0]] | + NewUnionInner[typing.Slice[Ts, Literal[1], None]] +) +type NewUnion[*Ts] = NewUnionInner[tuple[*Ts]] +# type NewUnion[*Ts] = Union[*Ts] + +# Strip `| None` from a type by iterating over its union components +# and filtering +type NotOptional[T] = NewUnion[ + *[x for x in typing.Iter[typing.FromUnion[T]] if not typing.IsSub[x, None]] +] + +# Adjust an attribute type for use in Public below by dropping | None for +# primary keys and stripping all annotations. +type FixPublicType[T, Init] = ( + NotOptional[T] + if typing.IsSub[Literal[True], GetFieldItem[Init, Literal["primary_key"]]] + else T +) + +# Strip out everything that is Hidden and also make the primary key required +# Drop all the annotations, since this is for data getting returned to users +# from the DB, so we don't need default values. +type Public[T] = typing.NewProtocol[ + *[ + typing.Member[ + typing.GetName[p], + FixPublicType[typing.GetType[p], typing.GetInit[p]], + typing.GetQuals[p], + ] + for p in typing.Iter[typing.Attrs[T]] + if not typing.IsSub[ + Literal[True], GetFieldItem[typing.GetInit[p], Literal["hidden"]] + ] + ] +] + +# Begin PEP section: Automatically deriving FastAPI CRUD models +""" +We have a more `fully-worked example <#fastapi-test_>`_ in our test +suite, but here is a possible implementation of just ``Public`` +""" + +# Extract the default type from an Init field. +# If it is a Field, then we try pulling out the "default" field, +# otherwise we return the type itself. +type GetDefault[Init] = ( + GetFieldItem[Init, Literal["default"]] + if typing.IsSub[Init, Field] + else Init +) + +# Create takes everything but the primary key and preserves defaults +type Create[T] = typing.NewProtocol[ + *[ + typing.Member[ + typing.GetName[p], + typing.GetType[p], + typing.GetQuals[p], + GetDefault[typing.GetInit[p]], + ] + for p in typing.Iter[typing.Attrs[T]] + if not typing.IsSub[ + Literal[True], + GetFieldItem[typing.GetInit[p], Literal["primary_key"]], + ] + ] +] + +# Update takes everything but the primary key, but makes them all have +# None defaults +type Update[T] = typing.NewProtocol[ + *[ + typing.Member[ + typing.GetName[p], + typing.GetType[p] | None, + typing.GetQuals[p], + Literal[None], + ] + for p in typing.Iter[typing.Attrs[T]] + if not typing.IsSub[ + Literal[True], + GetFieldItem[typing.GetInit[p], Literal["primary_key"]], + ] + ] +] + +class Hero: + id: int | None = Field(default=None, primary_key=True) + + name: str = Field(index=True) + age: int | None = Field(default=None, index=True) + + secret_name: str = Field(hidden=True) + + +type HeroPublic = Public[Hero] +type HeroCreate = Create[Hero] +type HeroUpdate = Update[Hero] + +pub: HeroPublic +reveal_type(pub) # N: Revealed type is "__typelevel__.NewProtocol[id: builtins.int, name: builtins.str, age: builtins.int | None]" + +creat: HeroCreate +reveal_type(creat) # N: Revealed type is "__typelevel__.NewProtocol[name: builtins.str, age: builtins.int | None, secret_name: builtins.str]" + +upd: HeroUpdate +reveal_type(upd) # N: Revealed type is "__typelevel__.NewProtocol[name: builtins.str | None, age: builtins.int | None | None, secret_name: builtins.str | None]" + + [builtins fixtures/typelevel.pyi] [typing fixtures/typing-full.pyi] diff --git a/test-data/unit/fixtures/typing-full.pyi b/test-data/unit/fixtures/typing-full.pyi index a0905b76c677a..2ac59e88fda11 100644 --- a/test-data/unit/fixtures/typing-full.pyi +++ b/test-data/unit/fixtures/typing-full.pyi @@ -331,3 +331,12 @@ GetType = GetMemberType[_MP, Literal["typ"]] GetQuals = GetMemberType[_MP, Literal["quals"]] GetInit = GetMemberType[_M, Literal["init"]] GetDefiner = GetMemberType[_M, Literal["definer"]] + +_KwargDict = TypeVar('_KwargDict', bound=BaseTypedDict) + +class InitField(Generic[_KwargDict], Any): + def __init__(self, **kwargs: Unpack[_KwargDict]) -> None: + ... + + def _get_kwargs(self) -> _KwargDict: + ... From 2dde278e735d182f55070d96f3baea23c476011a Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Tue, 3 Feb 2026 12:57:21 -0800 Subject: [PATCH 107/161] Make Union of a unpacked-for work by turning it into an operator --- mypy/typeanal.py | 15 ++++- mypy/typelevel.py | 8 +++ mypy/typeshed/stdlib/_typeshed/typemap.pyi | 9 +++ mypy/typeshed/stdlib/typing.pyi | 1 + test-data/unit/check-typelevel-basic.test | 58 ++++++++++++++++++++ test-data/unit/check-typelevel-examples.test | 16 +----- test-data/unit/fixtures/typing-full.pyi | 3 + 7 files changed, 94 insertions(+), 16 deletions(-) diff --git a/mypy/typeanal.py b/mypy/typeanal.py index c11930c16cfd4..dc3e72ab4fa58 100644 --- a/mypy/typeanal.py +++ b/mypy/typeanal.py @@ -652,7 +652,20 @@ def try_analyze_special_unbound_type(self, t: UnboundType, fullname: str) -> Typ self.anal_array(t.args, allow_unpack=True), line=t.line, column=t.column ) elif fullname == "typing.Union": - items = self.anal_array(t.args) + items = self.anal_array(t.args, allow_unpack=True) + # If there are any unpacks or for comprehensions, turn the + # Union into a _NewUnion type operator. This approach has + # the strong advantage that we never need to deal with + # messed up union types. + if any( + isinstance(get_proper_type_simple(st), (UnpackType, TypeForComprehension)) + for st in items + ): + operator = self.lookup_fully_qualified("typing._NewUnion") + assert operator and isinstance(operator.node, TypeInfo) + fallback = self.named_type("builtins.object") + return TypeOperatorType(operator.node, items, fallback, t.line, t.column) + return UnionType.make_union(items, line=t.line, column=t.column) elif fullname == "typing.Optional": if len(t.args) != 1: diff --git a/mypy/typelevel.py b/mypy/typelevel.py index 513c0abf475f6..7dd9ccca46e20 100644 --- a/mypy/typelevel.py +++ b/mypy/typelevel.py @@ -29,6 +29,7 @@ Var, ) from mypy.subtypes import is_subtype +from mypy.typeops import make_simplified_union from mypy.types import ( AnyType, ComputedType, @@ -581,6 +582,13 @@ def _eval_from_union(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Ty return evaluator.tuple_type([target]) +@register_operator("_NewUnion") +def _eval_new_union(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: + """Evaluate _NewUnion[*Ts] -> union of all type arguments.""" + args = evaluator.flatten_args(typ.args) + return make_simplified_union(args) + + @register_operator("GetMemberType") @lift_over_unions def _eval_get_member_type(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: diff --git a/mypy/typeshed/stdlib/_typeshed/typemap.pyi b/mypy/typeshed/stdlib/_typeshed/typemap.pyi index d452143f57aec..0b75b50e4b9bb 100644 --- a/mypy/typeshed/stdlib/_typeshed/typemap.pyi +++ b/mypy/typeshed/stdlib/_typeshed/typemap.pyi @@ -187,6 +187,15 @@ class NewTypedDict(Generic[Unpack[_Ts]]): ... +@_type_operator +class _NewUnion(Generic[Unpack[_Ts]]): + """ + Construct a union type from the given type arguments. + _NewUnion[int, str, bool] evaluates to int | str | bool. + """ + + ... + # --- Boolean/Conditional Operators --- @_type_operator diff --git a/mypy/typeshed/stdlib/typing.pyi b/mypy/typeshed/stdlib/typing.pyi index b78a177762095..e2f6ce30234f7 100644 --- a/mypy/typeshed/stdlib/typing.pyi +++ b/mypy/typeshed/stdlib/typing.pyi @@ -1270,6 +1270,7 @@ if sys.version_info >= (3, 15): NamedParam as NamedParam, NewProtocol as NewProtocol, NewTypedDict as NewTypedDict, + _NewUnion as _NewUnion, Param as Param, ParamQuals as ParamQuals, PosDefaultParam as PosDefaultParam, diff --git a/test-data/unit/check-typelevel-basic.test b/test-data/unit/check-typelevel-basic.test index 8b4df63939da3..abd713da68809 100644 --- a/test-data/unit/check-typelevel-basic.test +++ b/test-data/unit/check-typelevel-basic.test @@ -339,6 +339,64 @@ reveal_type(y) # N: Revealed type is "tuple[builtins.int]" [builtins fixtures/typelevel.pyi] [typing fixtures/typing-full.pyi] +[case testTypeOperatorNewUnion] +# flags: --python-version 3.10 +# Test _NewUnion operator +from typing import _NewUnion + +# Basic union construction +x: _NewUnion[int, str, float] +reveal_type(x) # N: Revealed type is "builtins.int | builtins.str | builtins.float" + +# Single type stays as-is +y: _NewUnion[int] +reveal_type(y) # N: Revealed type is "builtins.int" + +# Nested unions get flattened (note: bool is subtype of int so it gets simplified away) +z: _NewUnion[int, str | bytes] +reveal_type(z) # N: Revealed type is "builtins.int | builtins.str | builtins.bytes" + +[builtins fixtures/typelevel.pyi] +[typing fixtures/typing-full.pyi] + +[case testTypeOperatorToUnion] +# flags: --python-version 3.10 +# Test FromUnion operator +from typing import Union, Iter, Bool, Literal + +a: Union[*[x for x in Iter[tuple[int, str]]]] +reveal_type(a) # N: Revealed type is "builtins.int | builtins.str" + +b: Union[*[x for x in Iter[tuple[int, str]] if Bool[Literal[False]]]] +reveal_type(b) # N: Revealed type is "Never" + +c: Union[*tuple[int, str]] +reveal_type(c) # N: Revealed type is "builtins.int | builtins.str" + +d: Union[*tuple[()]] +reveal_type(d) # N: Revealed type is "Never" + +[builtins fixtures/typelevel.pyi] +[typing fixtures/typing-full.pyi] + +[case testTypeOperatorNewUnionWithComprehension] +# flags: --python-version 3.10 +# Test _NewUnion with comprehension +from typing import _NewUnion, Iter, Attrs, GetType + +class Foo: + a: int + b: str + c: bytes + +# Build union of all attribute types +AttrTypes = _NewUnion[*[GetType[m] for m in Iter[Attrs[Foo]]]] +x: AttrTypes +reveal_type(x) # N: Revealed type is "builtins.int | builtins.str | builtins.bytes" + +[builtins fixtures/typelevel.pyi] +[typing fixtures/typing-full.pyi] + [case testTypeOperatorStringConcat] # flags: --python-version 3.10 # Test string concatenation operator diff --git a/test-data/unit/check-typelevel-examples.test b/test-data/unit/check-typelevel-examples.test index 23345264fbf4a..5cc2acd07d3d6 100644 --- a/test-data/unit/check-typelevel-examples.test +++ b/test-data/unit/check-typelevel-examples.test @@ -471,23 +471,9 @@ type GetFieldItem[T, K] = typing.GetMemberType[ ## -# XXX: This is an atrocity, but Union doesn't work with variadics yet. -type NewUnionInner[Ts] = ( - typing.Never - if typing.Matches[typing.Length[Ts], typing.Literal[0]] - else - typing.GetArg[Ts, tuple, Literal[0]] - if typing.Matches[typing.Length[Ts], typing.Literal[1]] - else - typing.GetArg[Ts, tuple, Literal[0]] | - NewUnionInner[typing.Slice[Ts, Literal[1], None]] -) -type NewUnion[*Ts] = NewUnionInner[tuple[*Ts]] -# type NewUnion[*Ts] = Union[*Ts] - # Strip `| None` from a type by iterating over its union components # and filtering -type NotOptional[T] = NewUnion[ +type NotOptional[T] = Union[ *[x for x in typing.Iter[typing.FromUnion[T]] if not typing.IsSub[x, None]] ] diff --git a/test-data/unit/fixtures/typing-full.pyi b/test-data/unit/fixtures/typing-full.pyi index 2ac59e88fda11..5ff95d873c622 100644 --- a/test-data/unit/fixtures/typing-full.pyi +++ b/test-data/unit/fixtures/typing-full.pyi @@ -303,6 +303,9 @@ class NewTypedDict(Generic[Unpack[_Ts]]): ... @_type_operator class NewProtocol(Generic[Unpack[_Ts]]): ... +@_type_operator +class _NewUnion(Generic[Unpack[_Ts]]): ... + # Member data type for type-level computation _Name = TypeVar('_Name') _Type = TypeVar('_Type') From 0f5ec8d5757f73a873259fb239ea3843f226e21c Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Tue, 3 Feb 2026 13:20:34 -0800 Subject: [PATCH 108/161] Actually hang on to the init types and put them in the name --- mypy/typelevel.py | 31 ++++++++++++++++---- test-data/unit/check-typelevel-examples.test | 10 +++++-- 2 files changed, 33 insertions(+), 8 deletions(-) diff --git a/mypy/typelevel.py b/mypy/typelevel.py index 7dd9ccca46e20..00442b7ea0f81 100644 --- a/mypy/typelevel.py +++ b/mypy/typelevel.py @@ -957,20 +957,32 @@ def _eval_new_typeddict(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> ) -def _proto_entry_str(entry: tuple[Type, bool, bool]) -> str: - typ, is_classvar, is_final = entry +def _proto_entry_str(entry: tuple[Type, Type, bool, bool]) -> str: + typ, init_type, is_classvar, is_final = entry # XXX: We fully expand the type here for stringifying, which is # potentially dangerous... # TODO: We'll need to prevent recursion or something. styp = typ.str_with_options(expand=True) + if is_classvar: styp = f"ClassVar[{styp}]" if is_final: styp = f"Final[{styp}]" + + # XXX: use evaluator? + # Put the initializers in also + init_type = get_proper_type(init_type) + if isinstance(init_type, LiteralType): + styp = f"{styp} = {init_type.value}" + elif isinstance(init_type, NoneType): + styp = f"{styp} = None" + elif not isinstance(init_type, UninhabitedType): + styp = f"{styp} = ..." + return styp -def _proto_str(map: dict[str, tuple[Type, bool, bool]]) -> str: +def _proto_str(map: dict[str, tuple[Type, Type, bool, bool]]) -> str: body = [f"{name}: {_proto_entry_str(entry)}" for name, entry in map.items()] return f"NewProtocol[{', '.join(body)}]" @@ -998,7 +1010,9 @@ def _eval_new_protocol(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> object_type = evaluator.api.named_type("builtins.object") # Build the members dictionary - member_vars: dict[str, tuple[Type, bool, bool]] = {} # name -> (type, is_classvar, is_final) + member_vars: dict[str, tuple[Type, Type, bool, bool]] = ( + {} + ) # name -> (type, init_type, is_classvar, is_final) for arg in evaluator.flatten_args(typ.args): arg = get_proper_type(arg) @@ -1030,7 +1044,11 @@ def _eval_new_protocol(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> elif qual == "Final": is_final = True - member_vars[name] = (item_type, is_classvar, is_final) + init_type: Type = UninhabitedType() + if len(arg.args) >= 4: + init_type = arg.args[3] + + member_vars[name] = (item_type, init_type, is_classvar, is_final) # Generate a unique name for the synthetic protocol # XXX: I hope that it is unique based on the inputs? @@ -1063,13 +1081,14 @@ def _eval_new_protocol(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> info.mro = [info, object_type.type] # Add members to the symbol table - for name, (member_type, is_classvar, is_final) in member_vars.items(): + for name, (member_type, init_type, is_classvar, is_final) in member_vars.items(): var = Var(name, member_type) var.info = info var._fullname = f"{info.fullname}.{name}" var.is_classvar = is_classvar var.is_final = is_final var.is_initialized_in_class = True + var.init_type = init_type # Don't mark as inferred since we have explicit types var.is_inferred = False info.names[name] = SymbolTableNode(MDEF, var) diff --git a/test-data/unit/check-typelevel-examples.test b/test-data/unit/check-typelevel-examples.test index 5cc2acd07d3d6..2a7fb42fbaf3b 100644 --- a/test-data/unit/check-typelevel-examples.test +++ b/test-data/unit/check-typelevel-examples.test @@ -569,10 +569,16 @@ pub: HeroPublic reveal_type(pub) # N: Revealed type is "__typelevel__.NewProtocol[id: builtins.int, name: builtins.str, age: builtins.int | None]" creat: HeroCreate -reveal_type(creat) # N: Revealed type is "__typelevel__.NewProtocol[name: builtins.str, age: builtins.int | None, secret_name: builtins.str]" +reveal_type(creat) # N: Revealed type is "__typelevel__.NewProtocol[name: builtins.str, age: builtins.int | None = None, secret_name: builtins.str]" upd: HeroUpdate -reveal_type(upd) # N: Revealed type is "__typelevel__.NewProtocol[name: builtins.str | None, age: builtins.int | None | None, secret_name: builtins.str | None]" +reveal_type(upd) # N: Revealed type is "__typelevel__.NewProtocol[name: builtins.str | None = None, age: builtins.int | None | None = None, secret_name: builtins.str | None = None]" + +creat_members: tuple[*[typing.GetInit[p] for p in typing.Iter[typing.Members[HeroCreate]]]] +reveal_type(creat_members) # N: Revealed type is "tuple[Never, None, Never]" + +upd_types: tuple[*[typing.GetType[p] for p in typing.Iter[typing.Members[HeroUpdate]]]] +reveal_type(upd_types) # N: Revealed type is "tuple[builtins.str | None, builtins.int | None | None, builtins.str | None]" [builtins fixtures/typelevel.pyi] From 8f754a89661d2f03a57b49603b440016830bac95 Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Mon, 2 Feb 2026 20:59:41 -0800 Subject: [PATCH 109/161] Use select+Unpack in the qblike test like intended! --- test-data/unit/check-typelevel-examples.test | 52 +++++--------------- 1 file changed, 12 insertions(+), 40 deletions(-) diff --git a/test-data/unit/check-typelevel-examples.test b/test-data/unit/check-typelevel-examples.test index 2a7fb42fbaf3b..ee4aaf125090c 100644 --- a/test-data/unit/check-typelevel-examples.test +++ b/test-data/unit/check-typelevel-examples.test @@ -61,26 +61,10 @@ as a literal type--all of these mechanisms lean very heavily on literal types. """ -# def select[ModelT, K: BaseTypedDict]( -# typ: type[ModelT], -# /, -# **kwargs: Unpack[K], -# ) -> list[ -# NewProtocol[ -# *[ -# Member[ -# GetName[c], -# ConvertField[GetMemberType[ModelT, GetName[c]]], -# ] -# for c in Iter[Attrs[K]] -# ] -# ] -# ]: -# return [] - -def select_ex[ModelT, K: BaseTypedDict]( +def select[ModelT, K: BaseTypedDict]( typ: type[ModelT], - kwargs: K, + /, + **kwargs: Unpack[K], ) -> list[ NewProtocol[ *[ @@ -133,13 +117,11 @@ type AdjustLink[Tgt, LinkTy] = list[Tgt] if IsSub[LinkTy, MultiLink] else Tgt contains all the ``Property`` attributes of ``T``. """ -type PropsOnly[T] = list[ - NewProtocol[ - *[ - Member[GetName[p], PointerArg[GetType[p]]] - for p in Iter[Attrs[T]] - if IsSub[GetType[p], Property] - ] +type PropsOnly[T] = NewProtocol[ + *[ + Member[GetName[p], PointerArg[GetType[p]]] + for p in Iter[Attrs[T]] + if IsSub[GetType[p], Property] ] ] @@ -173,28 +155,18 @@ class User: name: Property[str] email: Property[str] - posts: Link[Post] + posts: MultiLink[Post] #### -# We used TypedDicts explicitly here - -class Args0(TypedDict): - id: Literal[True] - name: Literal[True] +reveal_type(select(User, id=True, name=True)) # N: Revealed type is "builtins.list[__typelevel__.NewProtocol[id: builtins.int, name: builtins.str]]" -args0: Args0 -reveal_type(select_ex(User, args0)) # N: Revealed type is "builtins.list[__typelevel__.NewProtocol[id: builtins.int, name: builtins.str]]" +reveal_type(select(User, name=True, email=True, posts=True)) # N: Revealed type is "builtins.list[__typelevel__.NewProtocol[name: builtins.str, email: builtins.str, posts: builtins.list[__typelevel__.NewProtocol[id: builtins.int, title: builtins.str, content: builtins.str]]]]" -class Args1(TypedDict): - name: Literal[True] - email: Literal[True] - posts: Literal[True] +reveal_type(select(Post, title=True, comments=True, author=True)) # N: Revealed type is "builtins.list[__typelevel__.NewProtocol[title: builtins.str, comments: builtins.list[__typelevel__.NewProtocol[id: builtins.int, name: builtins.str]], author: __typelevel__.NewProtocol[id: builtins.int, name: builtins.str]]]" -args1: Args1 -reveal_type(select_ex(User, args1)) # N: Revealed type is "builtins.list[__typelevel__.NewProtocol[name: builtins.str, email: builtins.str, posts: builtins.list[__typelevel__.NewProtocol[id: builtins.int, title: builtins.str, content: builtins.str]]]]" [builtins fixtures/typelevel.pyi] [typing fixtures/typing-full.pyi] From 3df457d63c54038e430696446adcc315149ac430 Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Tue, 3 Feb 2026 14:10:52 -0800 Subject: [PATCH 110/161] Delete TYPEMAP_IMPLEMENTATION_PLAN.md I don't think there's anything remotely useful still in it --- TYPEMAP_IMPLEMENTATION_PLAN.md | 1563 -------------------------------- 1 file changed, 1563 deletions(-) delete mode 100644 TYPEMAP_IMPLEMENTATION_PLAN.md diff --git a/TYPEMAP_IMPLEMENTATION_PLAN.md b/TYPEMAP_IMPLEMENTATION_PLAN.md deleted file mode 100644 index f11ae7daa7103..0000000000000 --- a/TYPEMAP_IMPLEMENTATION_PLAN.md +++ /dev/null @@ -1,1563 +0,0 @@ -# Implementation Plan: Type-Level Computation for Mypy - -This document outlines a plan for implementing the type-level -computation proposal described in `pep.rst`. - -## Overview - -The proposal introduces TypeScript-inspired type-level introspection and construction facilities: - -1. **Type Operators**: `GetArg`, `GetArgs`, `FromUnion`, `IsSub` (subtype check), `_Cond` (conditional) -2. **Conditional Types**: `_Cond[IsSub[T, Base], TrueType, FalseType]` (also supports ternary syntax `X if IsSub[T, Base] else Y`) -3. **Type-Level Iteration**: `*[... for t in Iter[...]]` -4. **Object Inspection**: `Members`, `Attrs`, `Member`, `NewProtocol`, `NewTypedDict` -5. **Callable Extension**: `Param` type with qualifiers for extended callable syntax -6. **String Operations**: `Slice`, `Concat`, `Uppercase`, `Lowercase`, etc. -7. **Annotated Operations**: `GetAnnotations`, `DropAnnotations` -8. **TypedDict `**kwargs` Inference**: `Unpack[K]` where K is a TypeVar bounded by TypedDict - ---- - -## Phase 1: Foundation Types and Infrastructure - -### 1.1 Core Design: Unified `TypeOperatorType` Class - -Rather than creating a separate class for each type operator, we use a single unified -`TypeOperatorType` class modeled after `TypeAliasType` and `Instance`. This approach: - -- Keeps mypy's type system minimal and extensible -- Allows new operators to be added in typeshed without modifying mypy's core -- Treats type operators as "unevaluated" types that expand to concrete types - -### 1.2 Add `ComputedType` Base Class (`mypy/types.py`) - -All type-level computation types share a common base class that defines the `expand()` interface: - -```python -class ComputedType(Type): - """ - Base class for types that represent unevaluated type-level computations. - - NOT a ProperType - must be expanded/evaluated before use in most type - operations. Analogous to TypeAliasType in that it wraps a computation - that produces a concrete type. - - Subclasses: - - TypeOperatorType: e.g., GetArg[T, Base, 0], Members[T], _Cond[IsSub[T, Base], X, Y] - - TypeForComprehension: e.g., *[Expr for x in Iter[T] if Cond] - - Note: Conditional types are represented as _Cond[...] TypeOperatorType, not a separate class. - """ - - __slots__ = () - - def expand(self) -> Type: - """ - Evaluate this computed type to produce a concrete type. - Returns self if evaluation is not yet possible (e.g., contains unresolved type vars). - - Subclasses must implement this method. - """ - raise NotImplementedError -``` - -### 1.3 Add `TypeOperatorType` (`mypy/types.py`) - -```python -class TypeOperatorType(ComputedType): - """ - Represents an unevaluated type operator application, e.g., GetArg[T, Base, 0]. - - Stores a reference to the operator's TypeInfo and the type arguments. - Type operators are generic classes in typeshed marked with @_type_operator. - """ - - __slots__ = ("type", "args") - - def __init__( - self, - type: TypeInfo, # The TypeInfo for the operator (e.g., typing.GetArg) - args: list[Type], # The type arguments - line: int = -1, - column: int = -1, - ) -> None: - super().__init__(line, column) - self.type = type - self.args = args - - def accept(self, visitor: TypeVisitor[T]) -> T: - return visitor.visit_type_operator_type(self) - - @property - def fullname(self) -> str: - return self.type.fullname - - def expand(self) -> Type: - """Evaluate this type operator to produce a concrete type.""" - from mypy.typelevel import evaluate_type_operator - return evaluate_type_operator(self) - - def serialize(self) -> JsonDict: - return { - ".class": "TypeOperatorType", - "type_ref": self.type.fullname, - "args": [a.serialize() for a in self.args], - } - - @classmethod - def deserialize(cls, data: JsonDict) -> TypeOperatorType: - # Similar to TypeAliasType deserialization - ... - - def copy_modified(self, *, args: list[Type] | None = None) -> TypeOperatorType: - return TypeOperatorType( - self.type, - args if args is not None else self.args.copy(), - self.line, - self.column, - ) -``` - -### 1.4 Conditional Types and Comprehensions (`mypy/types.py`) - -**Note:** Conditional types are now represented as `_Cond[condition, TrueType, FalseType]` using -`TypeOperatorType`, not a separate `ConditionalType` class. This simplifies the type system by -having one unified mechanism for all type-level computations. The ternary syntax -`X if IsSub[T, Base] else Y` is parsed and converted directly to `_Cond[IsSub[T, Base], X, Y]`. - -```python -class TypeForComprehension(ComputedType): - """ - Represents *[Expr for var in Iter[T] if Cond]. - - Expands to a tuple of types. - """ - - __slots__ = ("element_expr", "iter_var", "iter_type", "conditions") - - def __init__( - self, - element_expr: Type, - iter_var: str, - iter_type: Type, # The type being iterated (should be a tuple type) - conditions: list[Type], # Each should be IsSub[...] or boolean combo - line: int = -1, - column: int = -1, - ) -> None: - super().__init__(line, column) - self.element_expr = element_expr - self.iter_var = iter_var - self.iter_type = iter_type - self.conditions = conditions - - def accept(self, visitor: TypeVisitor[T]) -> T: - return visitor.visit_type_for_comprehension(self) - - def expand(self) -> Type: - """Evaluate the comprehension to produce a tuple type.""" - from mypy.typelevel import evaluate_comprehension - return evaluate_comprehension(self) -``` - -### 1.5 Update Type Visitor (`mypy/type_visitor.py`) - -Add visitor methods for the new types (note: no visitor for `ComputedType` base class - each subclass has its own): - -```python -class TypeVisitor(Generic[T]): - # ... existing methods ... - - def visit_type_operator_type(self, t: TypeOperatorType) -> T: ... - def visit_type_for_comprehension(self, t: TypeForComprehension) -> T: ... -``` - -Note: There is no `visit_conditional_type` - conditional types are represented as `_Cond[...]` -TypeOperatorType and handled by `visit_type_operator_type`. - -### 1.6 Declare Type Operators in Typeshed (`mypy/typeshed/stdlib/typing.pyi`) - -All type operators are declared as generic classes with the `@_type_operator` decorator. -This decorator marks them for special handling by the type checker. - -```python -# In typing.pyi - -def _type_operator(cls: type[T]) -> type[T]: ... - -# --- Data Types (used in type computations) --- - -class Member(Generic[_Name, _Type, _Quals, _Init, _Definer]): - """ - Represents a class member with name, type, qualifiers, initializer, and definer. - - _Name: Literal[str] - the member name - - _Type: the member's type - - _Quals: Literal['ClassVar'] | Literal['Final'] | Never - qualifiers - - _Init: the literal type of the initializer expression - - _Definer: the class that defined this member - """ - name: _Name - typ: _Type - quals: _Quals - init: _Init - definer: _Definer - - -class Param(Generic[_Name, _Type, _Quals]): - """ - Represents a function parameter for extended callable syntax. - - _Name: Literal[str] | None - the parameter name - - _Type: the parameter's type - - _Quals: Literal['positional', 'keyword', 'default', '*', '**'] - qualifiers - """ - name: _Name - typ: _Type - quals: _Quals - -# Convenience aliases for Param -type PosParam[N, T] = Param[N, T, Literal["positional"]] -type PosDefaultParam[N, T] = Param[N, T, Literal["positional", "default"]] -type DefaultParam[N, T] = Param[N, T, Literal["default"]] -type NamedParam[N, T] = Param[N, T, Literal["keyword"]] -type NamedDefaultParam[N, T] = Param[N, T, Literal["keyword", "default"]] -type ArgsParam[T] = Param[None, T, Literal["*"]] -type KwargsParam[T] = Param[None, T, Literal["**"]] - -# --- Type Introspection Operators --- - -@_type_operator -class GetArg(Generic[_T, _Base, _Idx]): - """ - Get type argument at index _Idx from _T when viewed as _Base. - Returns Never if _T does not inherit from _Base or index is out of bounds. - """ - ... - -@_type_operator -class GetArgs(Generic[_T, _Base]): - """ - Get all type arguments from _T when viewed as _Base, as a tuple. - Returns Never if _T does not inherit from _Base. - """ - ... - -@_type_operator -class GetAttr(Generic[_T, _Name]): - """ - Get the type of attribute _Name from type _T. - _Name must be a Literal[str]. - """ - ... - -@_type_operator -class Members(Generic[_T]): - """ - Get all members of type _T as a tuple of Member types. - Includes methods, class variables, and instance attributes. - """ - ... - -@_type_operator -class Attrs(Generic[_T]): - """ - Get annotated instance attributes of _T as a tuple of Member types. - Excludes methods and ClassVar members. - """ - ... - -@_type_operator -class FromUnion(Generic[_T]): - """ - Convert a union type to a tuple of its constituent types. - If _T is not a union, returns a 1-tuple containing _T. - """ - ... - -# --- Member/Param Accessors (sugar for GetArg) --- - -type GetName[T: Member | Param] = GetAttr[T, Literal["name"]] -type GetType[T: Member | Param] = GetAttr[T, Literal["typ"]] -type GetQuals[T: Member | Param] = GetAttr[T, Literal["quals"]] -type GetInit[T: Member] = GetAttr[T, Literal["init"]] -type GetDefiner[T: Member] = GetAttr[T, Literal["definer"]] - -# --- Type Construction Operators --- - -@_type_operator -class NewProtocol(Generic[Unpack[_Ts]]): - """ - Construct a new structural (protocol) type from Member types. - NewProtocol[Member[...], Member[...], ...] creates an anonymous protocol. - """ - ... - -@_type_operator -class NewTypedDict(Generic[Unpack[_Ts]]): - """ - Construct a new TypedDict from Member types. - NewTypedDict[Member[...], Member[...], ...] creates an anonymous TypedDict. - """ - ... - -# --- Boolean/Conditional Operators --- - -@_type_operator -class IsSub(Generic[_T, _Base]): - """ - Type-level subtype check. Evaluates to a type-level boolean. - Used in conditional type expressions: `_Cond[IsSub[T, Base], TrueType, FalseType]` - """ - ... - -@_type_operator -class _Cond(Generic[_T, _TrueType, _FalseType]): - """ - Type-level conditional expression. - _Cond[IsSub[T, Base], TrueType, FalseType] evaluates to TrueType if T is a subtype of Base, - otherwise FalseType. - - The ternary syntax `X if IsSub[T, Base] else Y` is converted to `_Cond[IsSub[T, Base], X, Y]`. - """ - ... - -@_type_operator -class Iter(Generic[_T]): - """ - Marks a type for iteration in type comprehensions. - `for x in Iter[T]` iterates over elements of tuple type T. - """ - ... - -# --- String Operations --- - -@_type_operator -class Slice(Generic[_S, _Start, _End]): - """ - Slice a literal string type. - Slice[Literal["hello"], Literal[1], Literal[3]] = Literal["el"] - """ - ... - -@_type_operator -class Concat(Generic[_S1, _S2]): - """ - Concatenate two literal string types. - Concat[Literal["hello"], Literal["world"]] = Literal["helloworld"] - """ - ... - -@_type_operator -class Uppercase(Generic[_S]): - """Convert literal string to uppercase.""" - ... - -@_type_operator -class Lowercase(Generic[_S]): - """Convert literal string to lowercase.""" - ... - -@_type_operator -class Capitalize(Generic[_S]): - """Capitalize first character of literal string.""" - ... - -@_type_operator -class Uncapitalize(Generic[_S]): - """Lowercase first character of literal string.""" - ... - -# --- Annotated Operations --- - -@_type_operator -class GetAnnotations(Generic[_T]): - """ - Extract Annotated metadata from a type. - GetAnnotations[Annotated[int, 'foo', 'bar']] = Literal['foo', 'bar'] - GetAnnotations[int] = Never - """ - ... - -@_type_operator -class DropAnnotations(Generic[_T]): - """ - Strip Annotated wrapper from a type. - DropAnnotations[Annotated[int, 'foo']] = int - DropAnnotations[int] = int - """ - ... - -# --- Utility Operators --- - -@_type_operator -class Length(Generic[_T]): - """ - Get the length of a tuple type as a Literal[int]. - Returns Literal[None] for unbounded tuples. - """ - ... -``` - -### 1.7 Detecting Type Operators (`mypy/nodes.py`) - -Add a flag to TypeInfo to mark type operators: - -```python -class TypeInfo(SymbolNode): - # ... existing fields ... - - is_type_operator: bool = False # True if decorated with @_type_operator -``` - -### 1.8 How Expansion Works - -The key insight is that `ComputedType` (and its subclasses) is NOT a `ProperType`. -Like `TypeAliasType`, it must be expanded before most type operations can use it. -The expansion happens via: - -1. **`get_proper_type()`** in `mypy/typeops.py` - already handles `TypeAliasType`, extend to handle `ComputedType` -2. **Explicit `.expand()` calls** when we need to evaluate - -```python -# In mypy/typeops.py -def get_proper_type(typ: Type) -> ProperType: - while True: - if isinstance(typ, TypeAliasType): - typ = typ._expand_once() - elif isinstance(typ, ComputedType): - # Handles TypeOperatorType (including _Cond), TypeForComprehension - typ = typ.expand() - else: - break - - assert isinstance(typ, ProperType), type(typ) - return typ -``` - ---- - -## Phase 2: Type Analysis (`mypy/typeanal.py`) - -### 2.1 Detect and Construct TypeOperatorType - -Instead of special-casing each operator, we detect classes marked with `@_type_operator` -and construct a generic `TypeOperatorType`: - -```python -def analyze_unbound_type_nonoptional( - self, t: UnboundType, report_invalid_types: bool -) -> Type: - # ... existing logic to resolve the symbol ... - - node = self.lookup_qualified(t.name, t, ...) - - if isinstance(node, TypeInfo): - # Check if this is a type operator - if node.is_type_operator: - return self.analyze_type_operator(t, node) - - # ... existing instance type handling ... - - # ... rest of existing logic ... - - -def analyze_type_operator(self, t: UnboundType, type_info: TypeInfo) -> Type: - """ - Analyze a type operator application like GetArg[T, Base, 0]. - Returns a TypeOperatorType that will be expanded later. - """ - # Analyze all type arguments - args = [self.anal_type(arg) for arg in t.args] - - # Validate argument count against the operator's type parameters - # (This is optional - could also defer to expansion time) - expected = len(type_info.type_vars) - if len(args) != expected: - self.fail( - f"Type operator {type_info.name} expects {expected} arguments, got {len(args)}", - t - ) - - return TypeOperatorType(type_info, args, line=t.line, column=t.column) -``` - -### 2.2 Parse Conditional Type Syntax - -Handle the `X if IsSub[T, Base] else Y` syntax in type contexts by extending the parser. -The ternary syntax is converted directly to `_Cond[condition, TrueType, FalseType]` TypeOperatorType. - -#### 2.2.1 AST Representation - -Python's parser already produces `IfExpr` (ternary) nodes. In type contexts, we need to -recognize these and convert them to `_Cond` type operator calls. The AST for `X if Cond else Y` is: - -```python -IfExpr( - cond=..., # The condition expression - body=..., # The "true" branch (X) - orelse=..., # The "false" branch (Y) -) -``` - -#### 2.2.2 Extend `expr_to_unanalyzed_type()` (`mypy/fastparse.py`) - -The `expr_to_unanalyzed_type()` function converts AST expressions to unanalyzed types. -Extend it to handle `IfExpr`, converting to an UnboundType for `_Cond`: - -```python -def expr_to_unanalyzed_type( - expr: ast.expr, - options: Options, - ..., -) -> ProperType | UnboundType: - # ... existing cases ... - - if isinstance(expr, IfExpr): - # Convert ternary to _Cond[condition, true_type, false_type] - condition = expr_to_unanalyzed_type(expr.cond, options, ...) - true_type = expr_to_unanalyzed_type(expr.body, options, ...) - false_type = expr_to_unanalyzed_type(expr.orelse, options, ...) - return UnboundType( - "builtins._Cond", - [condition, true_type, false_type], - line=expr.lineno, - column=expr.col_offset, - ) - - # ... rest of existing logic ... -``` - -#### 2.2.3 Handle in Type Analysis (`mypy/typeanal.py`) - -Since conditional types are now `_Cond[...]` TypeOperatorType, they are analyzed like -any other type operator via `analyze_type_operator()`. The condition validation happens -during evaluation in `mypy/typelevel.py`: - -```python -def is_valid_type_condition(self, typ: Type) -> bool: - """Check if typ is a valid type-level condition (IsSub or boolean combo).""" - if isinstance(typ, TypeOperatorType): - return typ.fullname == 'typing.IsSub' - # Could also check for And/Or/Not combinations if we support those - return False -``` - -### 2.3 Parse Type Comprehensions - -Handle `*[Expr for var in Iter[T] if Cond]` within type argument lists: - -```python -def analyze_starred_type_comprehension(self, expr: StarExpr) -> TypeForComprehension: - """Analyze *[... for x in Iter[T] if ...]""" - # This requires analyzing the list comprehension expression - # and converting it to a TypeForComprehension - pass -``` - -### 2.4 Extended Callable Parsing - -Modify `analyze_callable_type()` to accept `Param` types in the argument list: - -```python -def analyze_callable_type(self, t: UnboundType) -> Type: - # ... existing logic ... - - # Check if args contain Param types (extended callable) - if self.has_param_types(arg_types): - return self.build_extended_callable(arg_types, ret_type) - - # ... existing logic ... -``` - ---- - -## Phase 3A: Core Conditional Types (`_Cond` and `IsSub`) - -This phase implements the core conditional type evaluation - just `_Cond` and `IsSub`. -This is the foundation that enables conditional type expressions to work. - -### 3A.1 Create Type Evaluator Core - -```python -"""Type-level computation evaluation - Core conditional types.""" - -from __future__ import annotations - -from typing import Callable - -from mypy.types import ( - Type, ProperType, TypeOperatorType, TypeVarType, -) -from mypy.subtypes import is_subtype -from mypy.typeops import get_proper_type -from mypy.type_visitor import TypeQuery - - -# Registry mapping operator fullnames to their evaluation functions -_OPERATOR_EVALUATORS: dict[str, Callable[[TypeLevelEvaluator, TypeOperatorType], Type]] = {} - - -def register_operator(fullname: str): - """Decorator to register an operator evaluator.""" - def decorator(func: Callable[[TypeLevelEvaluator, TypeOperatorType], Type]): - _OPERATOR_EVALUATORS[fullname] = func - return func - return decorator - - -class TypeLevelEvaluator: - """Evaluates type-level computations to concrete types.""" - - def __init__(self, api: SemanticAnalyzerCoreInterface): - self.api = api - - def evaluate(self, typ: Type) -> Type: - """Main entry point: evaluate a type to its simplified form.""" - if isinstance(typ, TypeOperatorType): - return self.eval_operator(typ) - return typ # Already a concrete type or can't be evaluated - - def eval_operator(self, typ: TypeOperatorType) -> Type: - """Evaluate a type operator by dispatching to registered handler.""" - fullname = typ.fullname - evaluator = _OPERATOR_EVALUATORS.get(fullname) - - if evaluator is None: - # Unknown operator - return as-is (might be a data type like Member) - return typ - - return evaluator(self, typ) - - def eval_condition(self, cond: Type) -> bool | None: - """ - Evaluate a type-level condition (IsSub[T, Base]). - Returns True/False if decidable, None if undecidable. - """ - if isinstance(cond, TypeOperatorType) and cond.fullname == 'typing.IsSub': - left = self.evaluate(cond.args[0]) - right = self.evaluate(cond.args[1]) - # Handle type variables - may be undecidable - if self.contains_unresolved_typevar(left) or self.contains_unresolved_typevar(right): - return None - return is_subtype(left, right) - - # Could add support for boolean combinations (and, or, not) here - return None - - def eval_conditional(self, typ: TypeOperatorType) -> Type: - """Evaluate _Cond[condition, TrueType, FalseType]""" - if len(typ.args) != 3: - return typ - condition, true_type, false_type = typ.args - result = self.eval_condition(condition) - if result is True: - return self.evaluate(true_type) - elif result is False: - return self.evaluate(false_type) - else: - # Undecidable - keep as _Cond TypeOperatorType - return typ - - def contains_unresolved_typevar(self, typ: Type) -> bool: - """Check if type contains unresolved type variables.""" - - class HasTypeVar(TypeQuery[bool]): - def __init__(self): - super().__init__(any) - - def visit_type_var(self, t: TypeVarType) -> bool: - return True - - return typ.accept(HasTypeVar()) - - -# --- Operator Implementations for Phase 3A --- - -@register_operator('typing._Cond') -def eval_cond(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: - """Evaluate _Cond[condition, TrueType, FalseType]""" - return evaluator.eval_conditional(typ) - - -# Note: IsSub is not registered as an operator because it's not meant to be -# expanded directly - it's evaluated as a condition within _Cond. - - -# --- Public API --- - -def evaluate_type_operator(typ: TypeOperatorType) -> Type: - """Evaluate a TypeOperatorType. Called from TypeOperatorType.expand(). - - Uses typelevel_ctx.api to access the semantic analyzer. - """ - if typelevel_ctx.api is None: - # No context available - can't evaluate yet - return AnyType(TypeOfAny.special_form) - - evaluator = TypeLevelEvaluator(typelevel_ctx.api) - return evaluator.eval_operator(typ) -``` - ---- - -## Phase 3B: Remaining Type Operators - -This phase implements the remaining type operators after the core conditional types -are working. These include introspection, construction, and utility operators. - -### 3B.1 Type Introspection Operators - -Extend `TypeLevelEvaluator` with helper methods and implement the introspection operators: - -```python -# --- Additional helper methods for TypeLevelEvaluator --- - -class TypeLevelEvaluator: - # ... methods from Phase 3A ... - - def get_type_args_for_base(self, instance: Instance, base: TypeInfo) -> list[Type] | None: - """Get type args when viewing instance as base class.""" - for base_instance in instance.type.mro: - if base_instance == base: - return self.map_type_args_to_base(instance, base) - return None - - def map_type_args_to_base(self, instance: Instance, base: TypeInfo) -> list[Type]: - """Map instance's type args through inheritance chain to base.""" - from mypy.expandtype import expand_type_by_instance - for b in instance.type.bases: - b_proper = get_proper_type(b) - if isinstance(b_proper, Instance) and b_proper.type == base: - return list(expand_type_by_instance(b_proper, instance).args) - return [] - - def extract_literal_string(self, typ: Type) -> str | None: - """Extract string value from LiteralType.""" - typ = get_proper_type(typ) - if isinstance(typ, LiteralType) and isinstance(typ.value, str): - return typ.value - return None - - def extract_literal_int(self, typ: Type) -> int | None: - """Extract int value from LiteralType.""" - typ = get_proper_type(typ) - if isinstance(typ, LiteralType) and isinstance(typ.value, int): - return typ.value - return None - - def make_member_instance( - self, - name: str, - member_type: Type, - quals: Type, - init: Type, - definer: Type, - ) -> Instance: - """Create a Member[...] instance type.""" - member_info = self.api.lookup_qualified('typing.Member', ...).node - return Instance( - member_info, - [ - LiteralType(name, self.api.named_type('builtins.str')), - member_type, - quals, - init, - definer, - ], - ) - - def eval_comprehension(self, typ: TypeForComprehension) -> Type: - """Evaluate *[Expr for x in Iter[T] if Cond]""" - # First, evaluate the iter_type to get what we're iterating over - iter_type = self.evaluate(typ.iter_type) - - # If it's an Iter[T] operator, extract T - if isinstance(iter_type, TypeOperatorType) and iter_type.fullname == 'typing.Iter': - iter_type = self.evaluate(iter_type.args[0]) - - iter_type = get_proper_type(iter_type) - - if not isinstance(iter_type, TupleType): - return typ # Can't iterate over non-tuple - - results = [] - for item in iter_type.items: - # Substitute iter_var with item in element_expr - substituted = self.substitute_typevar(typ.element_expr, typ.iter_var, item) - - # Check conditions - all_conditions_true = True - for cond in typ.conditions: - cond_subst = self.substitute_typevar(cond, typ.iter_var, item) - result = self.eval_condition(cond_subst) - if result is False: - all_conditions_true = False - break - elif result is None: - # Undecidable - can't fully evaluate - return typ - - if all_conditions_true: - results.append(self.evaluate(substituted)) - - return TupleType(results, self.api.named_type('builtins.tuple')) - - # --- Helper methods --- - - def get_type_args_for_base(self, instance: Instance, base: TypeInfo) -> list[Type] | None: - """Get type args when viewing instance as base class.""" - # Walk MRO to find base and map type arguments - for base_instance in instance.type.mro: - if base_instance == base: - # Found it - now map arguments through inheritance - return self.map_type_args_to_base(instance, base) - return None - - def map_type_args_to_base(self, instance: Instance, base: TypeInfo) -> list[Type]: - """Map instance's type args through inheritance chain to base.""" - from mypy.expandtype import expand_type_by_instance - # Find the base in the MRO and get its type args - for b in instance.type.bases: - b_proper = get_proper_type(b) - if isinstance(b_proper, Instance) and b_proper.type == base: - return list(expand_type_by_instance(b_proper, instance).args) - return [] - - def contains_unresolved_typevar(self, typ: Type) -> bool: - """Check if type contains unresolved type variables.""" - from mypy.types import TypeVarType - from mypy.type_visitor import TypeQuery - - class HasTypeVar(TypeQuery[bool]): - def __init__(self): - super().__init__(any) - - def visit_type_var(self, t: TypeVarType) -> bool: - return True - - return typ.accept(HasTypeVar()) - - def substitute_typevar(self, typ: Type, var_name: str, replacement: Type) -> Type: - """Substitute a type variable by name with a concrete type.""" - from mypy.type_visitor import TypeTranslator - from mypy.types import TypeVarType - - class SubstituteVar(TypeTranslator): - def visit_type_var(self, t: TypeVarType) -> Type: - if t.name == var_name: - return replacement - return t - - return typ.accept(SubstituteVar()) - - def extract_literal_string(self, typ: Type) -> str | None: - """Extract string value from LiteralType.""" - typ = get_proper_type(typ) - if isinstance(typ, LiteralType) and isinstance(typ.value, str): - return typ.value - return None - - def extract_literal_int(self, typ: Type) -> int | None: - """Extract int value from LiteralType.""" - typ = get_proper_type(typ) - if isinstance(typ, LiteralType) and isinstance(typ.value, int): - return typ.value - return None - - def make_member_instance( - self, - name: str, - member_type: Type, - quals: Type, - init: Type, - definer: Type, - ) -> Instance: - """Create a Member[...] instance type (Member is a regular generic class).""" - member_info = self.api.lookup_qualified('typing.Member', ...).node - return Instance( - member_info, - [ - LiteralType(name, self.api.named_type('builtins.str')), - member_type, - quals, - init, - definer, - ], - ) - - def create_protocol_from_members(self, members: list[Instance]) -> Type: - """Create a new Protocol TypeInfo from Member type operators.""" - # Extract member info and create synthetic TypeInfo - # This is complex - see Phase 4 for details - pass - - -# --- Operator Implementations --- - -@register_operator('builtins._Cond') -def eval_cond(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: - """Evaluate _Cond[condition, TrueType, FalseType]""" - return evaluator.eval_conditional(typ) - - -@register_operator('typing.GetArg') -def eval_get_arg(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: - """Evaluate GetArg[T, Base, Idx]""" - if len(typ.args) != 3: - return typ - - target = evaluator.evaluate(typ.args[0]) - base = evaluator.evaluate(typ.args[1]) - idx = evaluator.evaluate(typ.args[2]) - - target = get_proper_type(target) - base = get_proper_type(base) - - # Extract index as int - index = evaluator.extract_literal_int(idx) - if index is None: - return typ # Can't evaluate without literal index - - if isinstance(target, Instance) and isinstance(base, Instance): - # This works for both regular classes and Member/Param (which are now Instances) - args = evaluator.get_type_args_for_base(target, base.type) - if args is not None and 0 <= index < len(args): - return args[index] - return UninhabitedType() # Never - - return typ - - -@register_operator('typing.GetArgs') -def eval_get_args(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: - """Evaluate GetArgs[T, Base] -> tuple of args""" - if len(typ.args) != 2: - return typ - - target = evaluator.evaluate(typ.args[0]) - base = evaluator.evaluate(typ.args[1]) - - target = get_proper_type(target) - base = get_proper_type(base) - - if isinstance(target, Instance) and isinstance(base, Instance): - args = evaluator.get_type_args_for_base(target, base.type) - if args is not None: - return TupleType(list(args), evaluator.api.named_type('builtins.tuple')) - return UninhabitedType() - - return typ - - -@register_operator('typing.Members') -def eval_members(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: - """Evaluate Members[T] -> tuple of Member instance types""" - if len(typ.args) != 1: - return typ - - target = evaluator.evaluate(typ.args[0]) - target = get_proper_type(target) - - if isinstance(target, Instance): - members = [] - for name, node in target.type.names.items(): - if node.type is not None: - member = evaluator.make_member_instance( - name=name, - member_type=node.type, - quals=extract_member_quals(node), - init=extract_member_init(node), - definer=Instance(target.type, []), - ) - members.append(member) - return TupleType(members, evaluator.api.named_type('builtins.tuple')) - - return typ - - -@register_operator('typing.Attrs') -def eval_attrs(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: - """Evaluate Attrs[T] -> tuple of Member instance types (annotated attrs only)""" - if len(typ.args) != 1: - return typ - - target = evaluator.evaluate(typ.args[0]) - target = get_proper_type(target) - - if isinstance(target, Instance): - members = [] - for name, node in target.type.names.items(): - # Filter to annotated instance attributes only - if (node.type is not None and - not node.is_classvar and - not isinstance(node.type, CallableType)): - member = evaluator.make_member_instance( - name=name, - member_type=node.type, - quals=extract_member_quals(node), - init=extract_member_init(node), - definer=Instance(target.type, []), - ) - members.append(member) - return TupleType(members, evaluator.api.named_type('builtins.tuple')) - - return typ - - -@register_operator('typing.FromUnion') -def eval_from_union(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: - """Evaluate FromUnion[T] -> tuple of union elements""" - if len(typ.args) != 1: - return typ - - target = evaluator.evaluate(typ.args[0]) - target = get_proper_type(target) - - if isinstance(target, UnionType): - return TupleType(list(target.items), evaluator.api.named_type('builtins.tuple')) - else: - # Non-union becomes 1-tuple - return TupleType([target], evaluator.api.named_type('builtins.tuple')) - - -@register_operator('typing.GetAttr') -def eval_get_attr(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: - """Evaluate GetAttr[T, Name]""" - if len(typ.args) != 2: - return typ - - target = evaluator.evaluate(typ.args[0]) - name_type = evaluator.evaluate(typ.args[1]) - - target = get_proper_type(target) - name = evaluator.extract_literal_string(name_type) - - if name is None: - return typ - - if isinstance(target, Instance): - node = target.type.names.get(name) - if node is not None and node.type is not None: - return node.type - return UninhabitedType() - - return typ - - -# --- Member/Param Accessors --- -# NOTE: GetName, GetType, GetQuals, GetInit, GetDefiner are now type aliases -# defined in typeshed using GetAttr, not type operators: -# -# type GetName[T: Member | Param] = GetAttr[T, Literal["name"]] -# type GetType[T: Member | Param] = GetAttr[T, Literal["typ"]] -# type GetQuals[T: Member | Param] = GetAttr[T, Literal["quals"]] -# type GetInit[T: Member] = GetAttr[T, Literal["init"]] -# type GetDefiner[T: Member] = GetAttr[T, Literal["definer"]] -# -# Since Member and Param are regular generic classes with attributes, -# GetAttr handles these automatically - no special operator needed. - - -# --- String Operations --- - -@register_operator('typing.Slice') -def eval_slice(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: - """Evaluate Slice[S, Start, End]""" - if len(typ.args) != 3: - return typ - - s = evaluator.extract_literal_string(evaluator.evaluate(typ.args[0])) - start = evaluator.extract_literal_int(evaluator.evaluate(typ.args[1])) - end = evaluator.extract_literal_int(evaluator.evaluate(typ.args[2])) - - # Handle None for start/end - start_arg = get_proper_type(evaluator.evaluate(typ.args[1])) - end_arg = get_proper_type(evaluator.evaluate(typ.args[2])) - if isinstance(start_arg, NoneType): - start = None - if isinstance(end_arg, NoneType): - end = None - - if s is not None: - result = s[start:end] - return LiteralType(result, evaluator.api.named_type('builtins.str')) - - return typ - - -@register_operator('typing.Concat') -def eval_concat(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: - """Evaluate Concat[S1, S2]""" - if len(typ.args) != 2: - return typ - - left = evaluator.extract_literal_string(evaluator.evaluate(typ.args[0])) - right = evaluator.extract_literal_string(evaluator.evaluate(typ.args[1])) - - if left is not None and right is not None: - return LiteralType(left + right, evaluator.api.named_type('builtins.str')) - - return typ - - -@register_operator('typing.Uppercase') -def eval_uppercase(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: - if len(typ.args) != 1: - return typ - s = evaluator.extract_literal_string(evaluator.evaluate(typ.args[0])) - if s is not None: - return LiteralType(s.upper(), evaluator.api.named_type('builtins.str')) - return typ - - -@register_operator('typing.Lowercase') -def eval_lowercase(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: - if len(typ.args) != 1: - return typ - s = evaluator.extract_literal_string(evaluator.evaluate(typ.args[0])) - if s is not None: - return LiteralType(s.lower(), evaluator.api.named_type('builtins.str')) - return typ - - -@register_operator('typing.Capitalize') -def eval_capitalize(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: - if len(typ.args) != 1: - return typ - s = evaluator.extract_literal_string(evaluator.evaluate(typ.args[0])) - if s is not None: - return LiteralType(s.capitalize(), evaluator.api.named_type('builtins.str')) - return typ - - -@register_operator('typing.Uncapitalize') -def eval_uncapitalize(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: - if len(typ.args) != 1: - return typ - s = evaluator.extract_literal_string(evaluator.evaluate(typ.args[0])) - if s is not None: - result = s[0].lower() + s[1:] if s else s - return LiteralType(result, evaluator.api.named_type('builtins.str')) - return typ - - -# --- Type Construction --- - -@register_operator('typing.NewProtocol') -def eval_new_protocol(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: - """Evaluate NewProtocol[*Members] -> create a new structural type""" - evaluated_members = [evaluator.evaluate(m) for m in typ.args] - - # All members must be Member instances (Member is a regular generic class) - member_type_info = evaluator.api.lookup_qualified('typing.Member', ...).node - for m in evaluated_members: - m = get_proper_type(m) - if not isinstance(m, Instance) or m.type != member_type_info: - return typ # Can't evaluate yet - - return evaluator.create_protocol_from_members(evaluated_members) - - -@register_operator('typing.NewTypedDict') -def eval_new_typed_dict(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: - """Evaluate NewTypedDict[*Members] -> create a new TypedDict""" - evaluated_members = [evaluator.evaluate(m) for m in typ.args] - - member_type_info = evaluator.api.lookup_qualified('typing.Member', ...).node - items = {} - required_keys = set() - - for m in evaluated_members: - m = get_proper_type(m) - if not isinstance(m, Instance) or m.type != member_type_info: - return typ # Can't evaluate yet - - # Member[name, typ, quals, init, definer] - access via type args - name = evaluator.extract_literal_string(m.args[0]) - if name is None: - return typ - - items[name] = m.args[1] # The type - # Check quals (args[2]) for Required/NotRequired - quals = get_proper_type(m.args[2]) if len(m.args) > 2 else UninhabitedType() - if not has_not_required_qual(quals): - required_keys.add(name) - - return TypedDictType( - items=items, - required_keys=required_keys, - readonly_keys=frozenset(), - fallback=evaluator.api.named_type('typing.TypedDict'), - ) - - -@register_operator('typing.Length') -def eval_length(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: - """Evaluate Length[T] -> Literal[int] for tuple length""" - if len(typ.args) != 1: - return typ - - target = evaluator.evaluate(typ.args[0]) - target = get_proper_type(target) - - if isinstance(target, TupleType): - if target.partial_fallback: - # Unbounded tuple - return NoneType() - return LiteralType(len(target.items), evaluator.api.named_type('builtins.int')) - - return typ - - -# --- Helper functions --- - -def extract_member_quals(node) -> Type: - """Extract qualifiers (ClassVar, Final) from a symbol table node.""" - # Implementation depends on how qualifiers are stored - return UninhabitedType() # Never = no qualifiers - - -def extract_member_init(node) -> Type: - """Extract the literal type of an initializer from a symbol table node.""" - # Implementation depends on how initializers are tracked - return UninhabitedType() # Never = no initializer - - -def has_not_required_qual(quals: Type) -> bool: - """Check if qualifiers include NotRequired.""" - # Implementation depends on qualifier representation - return False - - -# --- Public API --- - -def evaluate_type_operator(typ: TypeOperatorType) -> Type: - """Evaluate a TypeOperatorType. Called from TypeOperatorType.expand(). - - This handles all type operators including _Cond for conditional types. - """ - # Need to get the API somehow - this is a design question - # Option 1: Pass API through a context variable - # Option 2: Store API reference on TypeOperatorType - # Option 3: Create evaluator lazily - evaluator = TypeLevelEvaluator(...) - return evaluator.eval_operator(typ) - - -def evaluate_comprehension(typ: TypeForComprehension) -> Type: - """Evaluate a TypeForComprehension. Called from TypeForComprehension.expand().""" - evaluator = TypeLevelEvaluator(...) - return evaluator.eval_comprehension(typ) -``` - ---- - -## Phase 4: Integration Points - - -### 4.4 Type Inference with `**kwargs` TypeVar - -Handle `Unpack[K]` where K is bounded by TypedDict: - -In `mypy/checkexpr.py`, extend `check_call_expr_with_callee_type()`: - -```python -def infer_typeddict_from_kwargs( - self, - callee: CallableType, - kwargs: dict[str, Expression], -) -> dict[TypeVarId, Type]: - """Infer TypedDict type from **kwargs when unpacking a TypeVar.""" - # Find if callee has **kwargs: Unpack[K] where K is TypeVar - # Build TypedDict from provided kwargs and their inferred types - pass -``` - ---- - -## Phase 5: Extended Callable Support - -### 5.1 `Param` Type for Callable Arguments - -Support `Callable[[Param[N, T, Q], ...], R]` syntax: - -```python -# In typeanal.py -def build_extended_callable( - self, - params: list[ParamType], - ret_type: Type, -) -> CallableType: - """Build CallableType from Param types.""" - arg_types = [] - arg_kinds = [] - arg_names = [] - - for param in params: - arg_types.append(param.param_type) - arg_names.append(self.extract_param_name(param)) - arg_kinds.append(self.extract_param_kind(param)) - - return CallableType( - arg_types=arg_types, - arg_kinds=arg_kinds, - arg_names=arg_names, - ret_type=ret_type, - fallback=self.api.named_type('builtins.function'), - ) -``` - -### 5.2 Expose Callables as Extended Format - -When introspecting `Callable` via `Members` or similar, expose params as `Param` types: - -```python -def callable_to_param_types(self, callable: CallableType) -> list[ParamType]: - """Convert CallableType to list of ParamType.""" - params = [] - for i, (typ, kind, name) in enumerate(zip( - callable.arg_types, callable.arg_kinds, callable.arg_names - )): - quals = self.kind_to_param_quals(kind) - name_type = LiteralType(name, ...) if name else NoneType() - params.append(ParamType(name_type, typ, quals)) - return params -``` - ---- - -## Phase 6: Annotated Operations - -### 6.1 `GetAnnotations[T]` - -Extract `Annotated` metadata: - -```python -def eval_get_annotations(self, typ: GetAnnotationsType) -> Type: - target = self.evaluate(typ.target) - # Note: This requires changes to how we store Annotated types - # Currently mypy strips annotations - we need to preserve them - - if hasattr(target, '_annotations'): - # Return as union of Literal types - return UnionType.make_union([ - LiteralType(a, ...) for a in target._annotations - ]) - return UninhabitedType() # Never -``` - -### 6.2 Preserve Annotations in Type Representation - -Modify `analyze_annotated_type()` in `typeanal.py` to preserve annotation metadata: - -```python -class AnnotatedType(ProperType): - """Represents Annotated[T, ann1, ann2, ...]""" - inner_type: Type - annotations: tuple[Any, ...] -``` - ---- - -## Phase 7: InitField Support - -### 7.1 `InitField` Type for Field Descriptors - -Support literal type inference for field initializers: - -```python -# In semanal.py, when analyzing class body assignments -def analyze_class_attribute_with_initfield( - self, - name: str, - typ: Type, - init_expr: Expression, -) -> None: - """Handle `attr: T = InitField(...)` patterns.""" - if self.is_initfield_call(init_expr): - # Infer literal types for all kwargs - kwargs_types = self.infer_literal_kwargs(init_expr) - # Store as Member init type - init_type = self.create_initfield_literal_type(kwargs_types) - # ... store in symbol table -``` - ---- - -## Phase 8: Testing Strategy - -### 8.1 Unit Tests - -MAYBE? - -Create comprehensive tests in `mypy/test/`: - -1. **`test_typelevel_basic.py`** - Basic type operators -2. **`test_typelevel_conditional.py`** - Conditional types -3. **`test_typelevel_comprehension.py`** - Type comprehensions -4. **`test_typelevel_protocol.py`** - NewProtocol creation -5. **`test_typelevel_typeddict.py`** - NewTypedDict creation -6. **`test_typelevel_callable.py`** - Extended callable / Param -7. **`test_typelevel_string.py`** - String operations -8. **`test_typelevel_examples.py`** - Full examples from PEP - -### 8.2 Test Data Files - -Create `.test` files for each feature area: - -``` -test-data/unit/check-typelevel-getarg.test -test-data/unit/check-typelevel-conditional.test -test-data/unit/check-typelevel-members.test -test-data/unit/check-typelevel-newprotocol.test -... -``` - -### 8.3 Integration Tests - -Port examples from the PEP: -- Prisma-style ORM query builder -- FastAPI CRUD model derivation -- Dataclass-style `__init__` generation - ---- - -## Phase 9: Incremental Implementation Order - -### Milestone 1: Foundation (Weeks 1-2) ✓ COMPLETED -1. Add `ComputedType` base class, `TypeOperatorType`, `TypeForComprehension` -2. Add `is_type_operator` flag to `TypeInfo` -3. Declare type operators in typeshed with `@_type_operator` -4. Update type visitors for new types - -### Milestone 2: Type Analysis (Weeks 3-4) ✓ COMPLETED -1. Detect `@_type_operator` decorated classes in semanal.py -2. Construct `TypeOperatorType` in typeanal.py when encountering type operators -3. Parse ternary syntax `X if Cond else Y` to `_Cond[Cond, X, Y]` -4. Add context variable for API access (`typelevel_ctx`) -5. Tests for type operator detection and ternary parsing - -### Milestone 3A: Core Conditional Evaluation (Week 5) -1. Implement `TypeLevelEvaluator` core with `eval_condition` and `eval_conditional` -2. Register `typing._Cond` operator -3. Implement `IsSub` condition evaluation using `is_subtype()` -4. Wire up `typelevel_ctx` in type analysis -5. Tests for conditional type evaluation - -### Milestone 3B: Introspection Operators (Weeks 6-7) -1. Add `GetArg`, `GetArgs`, `FromUnion` operators -2. Add `GetAttr` operator -3. Add `Members`, `Attrs` operators -4. Tests for introspection operators - -### Milestone 4: Type Comprehensions (Weeks 8-9) -1. Add `TypeForComprehension` evaluation -2. Parser support for comprehension syntax `*[... for x in Iter[T]]` -3. Add `Iter` operator support -4. Tests for comprehensions - -### Milestone 5: Type Construction (Weeks 10-12) -1. Add `NewProtocol` - synthetic protocol creation -2. Add `NewTypedDict` - synthetic TypedDict creation -3. Integration with type checking -4. Tests for type construction - -### Milestone 6: Extended Callables (Weeks 13-14) -1. Full `Param` type support -2. Callable introspection -3. Extended callable construction -4. Tests for callables - -### Milestone 7: String Operations (Week 15) -1. Add `Slice`, `Concat` operators -2. Add `Uppercase`, `Lowercase`, `Capitalize`, `Uncapitalize` -3. Tests for string ops - -### Milestone 8: Annotated & InitField (Weeks 16-17) -1. Preserve Annotated metadata -2. `GetAnnotations`/`DropAnnotations` -3. InitField support -4. Tests - -### Milestone 9: TypedDict kwargs inference (Week 18) -1. `Unpack[K]` for TypeVar K -2. Inference from kwargs -3. Tests - -### Milestone 10: Integration & Polish (Weeks 19-20) -1. Full PEP examples working -2. Error messages -3. Documentation -4. Performance optimization - ---- - -## Key Design Decisions - -### 1. Unified TypeOperatorType (Not Per-Operator Classes) -**Decision**: Use a single `TypeOperatorType` class that references a TypeInfo (the operator) and contains args, rather than creating separate classes for each operator (GetArgType, MembersType, etc.). This keeps mypy's core minimal and allows new operators to be added in typeshed without modifying mypy. - -### 2. Type Operators Declared in Typeshed -**Decision**: Type operators (`GetArg`, `Members`, `GetAttr`, etc.) are declared as generic classes in `mypy/typeshed/stdlib/typing.pyi` with the `@_type_operator` decorator. `Member` and `Param` are regular generic classes (not operators) with actual attributes - they're just data containers used in type computations. - -### 2b. Member/Param Accessors as Type Aliases -**Decision**: `GetName`, `GetType`, `GetQuals`, `GetInit`, `GetDefiner` are type aliases using `GetAttr`, not separate type operators. Since `Member` and `Param` are regular classes with attributes, `GetAttr[Member[...], Literal["name"]]` works directly. - -### 3. ComputedType Hierarchy -**Decision**: All computed types (`TypeOperatorType`, `TypeForComprehension`) inherit from a common `ComputedType` base class. Like `TypeAliasType`, `ComputedType` is (unfortunately) a `ProperType` *but* must still be expanded before use in most type operations. When expansion gets stuck, it returns the same type. This is handled by a single `isinstance(typ, ComputedType)` check in `get_proper_type()`. Note: Conditional types are represented as `_Cond[...]` TypeOperatorType, not a separate class. - -### 4. Lazy Evaluation with Caching -**Decision**: Type-level computations are evaluated when needed (e.g., during subtype checking) rather than immediately during parsing. Results should be cached. - -### 5. Handling Undecidable Conditions -**Decision**: When a condition cannot be evaluated (e.g., involves unbound type variables), preserve the conditional type. It will be evaluated later when more type information is available. - -### 6. Synthetic Type Identity -**Decision**: Types created via `NewProtocol` are structural (protocols), so identity is based on structure, not name. Each creation point may produce a "different" type that is structurally equivalent. - -### 7. Error Handling -**Decision**: Invalid type-level operations (e.g., `GetArg` on non-generic type) return `Never` rather than raising errors, consistent with the spec. - -### 8. Runtime Evaluation -**Decision**: This implementation focuses on static type checking. Runtime evaluation is a separate library concern (as noted in the spec). - -### 9. Registry-Based Operator Dispatch -**Decision**: The evaluator uses a registry mapping operator fullnames to evaluation functions (via `@register_operator` decorator). This allows adding new operators without modifying the core evaluator logic. - ---- - -## Files to Create/Modify - -### New Files -- `mypy/typelevel.py` - Type-level computation evaluator with operator registry -- `mypy/test/test_typelevel_*.py` - Test files -- `test-data/unit/check-typelevel-*.test` - Test data - -### Modified Files -- `mypy/types.py` - Add `ComputedType` base class, `TypeOperatorType`, `TypeForComprehension` -- `mypy/type_visitor.py` - Add `visit_type_operator_type`, `visit_type_for_comprehension` -- `mypy/fastparse.py` - Extend `expr_to_unanalyzed_type()` to handle `IfExpr` → `_Cond[...]` UnboundType -- `mypy/typeanal.py` - Detect `@_type_operator` classes, construct `TypeOperatorType` -- `mypy/typeops.py` - Extend `get_proper_type()` to expand type operators -- `mypy/expandtype.py` - Handle type variable substitution in type operators -- `mypy/subtypes.py` - Subtype rules for unevaluated type operators -- `mypy/checkexpr.py` - kwargs TypedDict inference -- `mypy/semanal.py` - Detect `@_type_operator` decorator, InitField handling -- `mypy/nodes.py` - Add `is_type_operator` flag to `TypeInfo` -- `mypy/typeshed/stdlib/typing.pyi` - Declare type operators with `@_type_operator` (including `_Cond` for conditionals), plus `Member`/`Param` as regular generic classes and accessor aliases - ---- - -## Open Questions for Discussion - -1. **Protocol vs TypedDict creation**: Should `NewProtocol` create true protocols (with `is_protocol=True`) or just structural types? - **RESOLVED**: Obviously true protocols. - -2. **Type alias recursion**: How to handle recursive type aliases that use type-level computation? - -3. **Error recovery**: What should happen when type-level computation fails? Currently spec says return `Never`. - -4. **Caching strategy**: How aggressively to cache evaluated type-level computations? - -5. **API access in expand()**: ~~How does `TypeOperatorType.expand()` get access to the semantic analyzer API? Options: context variable, stored reference, or lazy creation.~~ - **RESOLVED**: Use a context variable (`typelevel_ctx` in `mypy/typelevel.py`). The `TypeLevelContext` class holds a reference to the `SemanticAnalyzerCoreInterface` API, set via a context manager (`typelevel_ctx.set_api(api)`) during type analysis. The evaluation functions access it via `typelevel_ctx.api`. - -6. **Type variable handling in operators**: When should type variables in operator arguments block evaluation vs. be substituted first? From 0b710fbec70bdd5d6abcc9d86f48edbf66a9c715 Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Tue, 3 Feb 2026 14:13:53 -0800 Subject: [PATCH 111/161] Fix a test comment --- test-data/unit/check-typelevel-examples.test | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test-data/unit/check-typelevel-examples.test b/test-data/unit/check-typelevel-examples.test index ee4aaf125090c..a091d97164ebf 100644 --- a/test-data/unit/check-typelevel-examples.test +++ b/test-data/unit/check-typelevel-examples.test @@ -247,7 +247,7 @@ type Merge[T, S] = ( S if Bool[Empty[T]] else T if Bool[Empty[S]] else Tup[ - # XXX: This is super wrong! + # XXX: This error message position is super wrong! *Merge[DropLast[T], DropLast[S]], # E: Broadcast mismatch: Literal[4], Literal[10] MergeOne[Last[T], Last[S]] ] From 142bcff285a62a3facb949080ee1c97791eb0cee Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Tue, 3 Feb 2026 16:42:31 -0800 Subject: [PATCH 112/161] More cases in _get_args --- mypy/typelevel.py | 38 +++++++++++++++++------ test-data/unit/check-typelevel-basic.test | 32 +++++++++++++++++++ test-data/unit/fixtures/typelevel.pyi | 23 ++++++++++++-- 3 files changed, 82 insertions(+), 11 deletions(-) diff --git a/mypy/typelevel.py b/mypy/typelevel.py index 00442b7ea0f81..9e6543fe4c427 100644 --- a/mypy/typelevel.py +++ b/mypy/typelevel.py @@ -29,7 +29,7 @@ Var, ) from mypy.subtypes import is_subtype -from mypy.typeops import make_simplified_union +from mypy.typeops import make_simplified_union, tuple_fallback from mypy.types import ( AnyType, ComputedType, @@ -44,6 +44,7 @@ TypeForComprehension, TypeOfAny, TypeOperatorType, + TypeType, TypeVarLikeType, UnboundType, UninhabitedType, @@ -513,17 +514,36 @@ def _get_args(evaluator: TypeLevelEvaluator, target: Type, base: Type) -> Sequen target = evaluator.eval_proper(target) base = evaluator.eval_proper(base) - # TODO: Other cases + # TODO: Other cases: + # * Callable (and Parameters) + # * Overloaded + if isinstance(target, Instance) and isinstance(base, Instance): + # TODO: base.is_protocol!! + # Probably implement it by filling in base with TypeVars and + # calling infer_constraints and solve. + return get_type_args_for_base(target, base.type) - if ( - isinstance(target, TupleType) - and isinstance(base, Instance) - # XXX: Do a real check - and target.partial_fallback == base - ): - return target.items + if isinstance(target, NoneType): + return _get_args(evaluator, evaluator.api.named_type("builtins.object"), base) + + if isinstance(target, TupleType): + # TODO: tuple v tuple? + # TODO: Do a real check against more classes? + if isinstance(base, Instance) and target.partial_fallback == base: + return target.items + + return _get_args(evaluator, tuple_fallback(target), base) + + if isinstance(target, TypedDictType): + return _get_args(evaluator, target.fallback, base) + + if isinstance(target, TypeType): + if isinstance(base, Instance) and base.type.fullname == "builtins.type": + return [target.item] + # TODO: metaclasses, protocols + return None return None diff --git a/test-data/unit/check-typelevel-basic.test b/test-data/unit/check-typelevel-basic.test index abd713da68809..4015534f1dbd1 100644 --- a/test-data/unit/check-typelevel-basic.test +++ b/test-data/unit/check-typelevel-basic.test @@ -568,6 +568,38 @@ reveal_type(y) # N: Revealed type is "builtins.str" args: GetArgs[Concrete, MyGeneric] reveal_type(args) # N: Revealed type is "tuple[builtins.int, builtins.str]" +[builtins fixtures/typelevel.pyi] +[typing fixtures/typing-full.pyi] + +[case testTypeOperatorGetArg4] +# flags: --python-version 3.14 + +# Test GetArg on tuple types +from typing import GetArg, Iterable, Literal, Sequence, IsSub, TypedDict, Protocol + +x0: GetArg[tuple[int, str, float], Iterable, Literal[0]] +reveal_type(x0) # N: Revealed type is "builtins.int | builtins.str | builtins.float" + +x1: GetArg[list[int], Iterable, Literal[0]] +reveal_type(x1) # N: Revealed type is "builtins.int" + +x2: GetArg[list[int], Sequence, Literal[0]] +reveal_type(x2) # N: Revealed type is "builtins.int" + +x3: GetArg[dict[str, bool], Iterable, Literal[0]] +reveal_type(x3) # N: Revealed type is "builtins.str" + +class D(TypedDict): + x: int + y: str + +x4: GetArg[D, Iterable, Literal[0]] +reveal_type(x4) # N: Revealed type is "builtins.str" + +x5: GetArg[type[bool], type, Literal[0]] +reveal_type(x5) # N: Revealed type is "builtins.bool" + + [builtins fixtures/typelevel.pyi] [typing fixtures/typing-full.pyi] diff --git a/test-data/unit/fixtures/typelevel.pyi b/test-data/unit/fixtures/typelevel.pyi index 93de287efd6d2..0b93a12cb1592 100644 --- a/test-data/unit/fixtures/typelevel.pyi +++ b/test-data/unit/fixtures/typelevel.pyi @@ -1,7 +1,7 @@ # Builtins stub used in tuple-related test cases. import _typeshed -from typing import Iterable, Iterator, TypeVar, Generic, Sequence, Optional, overload, Tuple, Type, Self, type_check_only, _type_operator +from typing import Iterable, Iterator, TypeVar, Generic, Sequence, Mapping, Optional, overload, Tuple, Type, Union, Self, type_check_only, _type_operator _T = TypeVar("_T") _Tco = TypeVar('_Tco', covariant=True) @@ -9,6 +9,7 @@ _Tco = TypeVar('_Tco', covariant=True) class object: def __init__(self) -> None: pass def __new__(cls) -> Self: ... + def __str__(self) -> str: pass class type: def __init__(self, *a: object) -> None: pass @@ -54,7 +55,25 @@ def isinstance(x: object, t: type) -> bool: pass class BaseException: pass -class dict: pass +KT = TypeVar('KT') +VT = TypeVar('VT') +T = TypeVar('T') + +class dict(Mapping[KT, VT]): + @overload + def __init__(self, **kwargs: VT) -> None: pass + @overload + def __init__(self, arg: Iterable[Tuple[KT, VT]], **kwargs: VT) -> None: pass + def __getitem__(self, key: KT) -> VT: pass + def __setitem__(self, k: KT, v: VT) -> None: pass + def __iter__(self) -> Iterator[KT]: pass + def __contains__(self, item: object) -> int: pass + @overload + def get(self, k: KT) -> Optional[VT]: pass + @overload + def get(self, k: KT, default: Union[VT, T]) -> Union[VT, T]: pass + def __len__(self) -> int: ... + # Type-level computation stuff From 8d76648c3c2f551673d5dab22286225b827b2423 Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Wed, 4 Feb 2026 17:25:26 -0800 Subject: [PATCH 113/161] Make it work when using older versions and an import from _typeshed --- mypy/typelevel.py | 21 ++++++++++++--------- mypy/typeshed/stdlib/typing.pyi | 7 ++++++- 2 files changed, 18 insertions(+), 10 deletions(-) diff --git a/mypy/typelevel.py b/mypy/typelevel.py index 9e6543fe4c427..a5fec6cbaf460 100644 --- a/mypy/typelevel.py +++ b/mypy/typelevel.py @@ -282,6 +282,15 @@ def tuple_type(self, items: list[Type]) -> TupleType: """Create a tuple type with the given items.""" return TupleType(items, self.api.named_type("builtins.tuple")) + def get_typemap_type(self, name: str) -> Instance: + # They are always in _typeshed.typemap in normal runs, but are + # sometimes missing from typing, depending on the version. + # But _typeshed.typemap doesn't exist in tests, so... + if typ := self.api.named_type_or_none(f"typing.{name}"): + return typ + else: + return self.api.named_type(f"_typeshed.typemap.{name}") + def _call_by_value(evaluator: TypeLevelEvaluator, typ: Type) -> Type: """Make sure alias arguments are evaluated before expansion. @@ -788,9 +797,7 @@ def _eval_members_impl( target = evaluator.eval_proper(typ.args[0]) # Get the Member TypeInfo - member_info = evaluator.api.named_type_or_none("typing.Member") - if member_info is None: - return UninhabitedType() + member_info = evaluator.get_typemap_type("Member") # Handle TypedDict if isinstance(target, TypedDictType): @@ -925,9 +932,7 @@ def _eval_new_typeddict(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> This is the inverse of Members[TypedDict]. """ # Get the Member TypeInfo to verify arguments - member_info = evaluator.api.named_type_or_none("typing.Member") - if member_info is None: - return UninhabitedType() + member_info = evaluator.get_typemap_type("Member") items: dict[str, Type] = {} required_keys: set[str] = set() @@ -1018,9 +1023,7 @@ def _eval_new_protocol(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> # TODO: methods are probably in bad shape # Get the Member TypeInfo to verify arguments - member_info = evaluator.api.named_type_or_none("typing.Member") - if member_info is None: - return UninhabitedType() + member_info = evaluator.get_typemap_type("Member") # Get object type for the base class # N.B: We don't inherit from Protocol directly because Protocol is not always diff --git a/mypy/typeshed/stdlib/typing.pyi b/mypy/typeshed/stdlib/typing.pyi index e2f6ce30234f7..3e72a410123fa 100644 --- a/mypy/typeshed/stdlib/typing.pyi +++ b/mypy/typeshed/stdlib/typing.pyi @@ -1189,6 +1189,12 @@ if sys.version_info >= (3, 13): # --- Type-level computation support --- +# HACK: Always import because its used in mypy internals. +# FIXME: Don't put this weird internals stuff here. +from _typeshed.typemap import ( + _NewUnion as _NewUnion, +) + if sys.version_info >= (3, 15): __all__ += [ # Type operators @@ -1270,7 +1276,6 @@ if sys.version_info >= (3, 15): NamedParam as NamedParam, NewProtocol as NewProtocol, NewTypedDict as NewTypedDict, - _NewUnion as _NewUnion, Param as Param, ParamQuals as ParamQuals, PosDefaultParam as PosDefaultParam, From e701e3ebf5297e207bded0275069203142c776b6 Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Wed, 4 Feb 2026 18:01:36 -0800 Subject: [PATCH 114/161] Rename IsSub to IsAssignable and Matches to IsEquivalent --- mypy/typelevel.py | 12 +- mypy/types.py | 4 +- mypy/typeshed/stdlib/_typeshed/typemap.pyi | 12 +- mypy/typeshed/stdlib/builtins.pyi | 2 +- mypy/typeshed/stdlib/typing.pyi | 8 +- test-data/unit/check-typelevel-basic.test | 196 +++++++++--------- .../unit/check-typelevel-comprehension.test | 14 +- test-data/unit/check-typelevel-examples.test | 40 ++-- test-data/unit/check-typelevel-members.test | 4 +- test-data/unit/fixtures/typing-full.pyi | 4 +- 10 files changed, 148 insertions(+), 148 deletions(-) diff --git a/mypy/typelevel.py b/mypy/typelevel.py index a5fec6cbaf460..9e3a0df4ab5fd 100644 --- a/mypy/typelevel.py +++ b/mypy/typelevel.py @@ -402,9 +402,9 @@ def _eval_iter(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: return UninhabitedType() -@register_operator("IsSub") +@register_operator("IsAssignable") def _eval_issub(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: - """Evaluate a type-level condition (IsSub[T, Base]).""" + """Evaluate a type-level condition (IsAssignable[T, Base]).""" if len(typ.args) != 2: return UninhabitedType() @@ -423,12 +423,12 @@ def _eval_issub(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: return evaluator.literal_bool(result) -@register_operator("Matches") +@register_operator("IsEquivalent") def _eval_matches(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: - """Evaluate Matches[T, S] - check if T and S are equivalent types. + """Evaluate IsEquivalent[T, S] - check if T and S are equivalent types. Returns Literal[True] if T is a subtype of S AND S is a subtype of T. - Equivalent to: IsSub[T, S] and IsSub[S, T] + Equivalent to: IsAssignable[T, S] and IsAssignable[S, T] """ if len(typ.args) != 2: return UninhabitedType() @@ -453,7 +453,7 @@ def _eval_bool(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: """Evaluate Bool[T] - check if T contains Literal[True]. Returns Literal[True] if T is Literal[True] or a union containing Literal[True]. - Equivalent to: IsSub[Literal[True], T] and not IsSub[T, Never] + Equivalent to: IsAssignable[Literal[True], T] and not IsAssignable[T, Never] """ if len(typ.args) != 1: return UninhabitedType() diff --git a/mypy/types.py b/mypy/types.py index 8879dd548df07..a43b21cddc24b 100644 --- a/mypy/types.py +++ b/mypy/types.py @@ -487,7 +487,7 @@ class ComputedType(ProperType): that produces a concrete type. Subclasses: - - TypeOperatorType: e.g., GetArg[T, Base, 0], Members[T], _Cond[IsSub[T, Base], X, Y] + - TypeOperatorType: e.g., GetArg[T, Base, 0], Members[T], _Cond[IsAssignable[T, Base], X, Y] - TypeForComprehension: e.g., *[Expr for x in Iter[T] if Cond] """ @@ -608,7 +608,7 @@ def __init__( element_expr: Type, iter_name: str, iter_type: Type, # The type being iterated (should be a tuple type) - conditions: list[Type], # Each should be IsSub[...] or boolean combo + conditions: list[Type], # Each should be IsAssignable[...] or boolean combo iter_var: TypeVarType | None = None, # Typically populated by typeanal line: int = -1, column: int = -1, diff --git a/mypy/typeshed/stdlib/_typeshed/typemap.pyi b/mypy/typeshed/stdlib/_typeshed/typemap.pyi index 0b75b50e4b9bb..351dcaac65581 100644 --- a/mypy/typeshed/stdlib/_typeshed/typemap.pyi +++ b/mypy/typeshed/stdlib/_typeshed/typemap.pyi @@ -199,20 +199,20 @@ class _NewUnion(Generic[Unpack[_Ts]]): # --- Boolean/Conditional Operators --- @_type_operator -class IsSub(Generic[_T, _Base]): +class IsAssignable(Generic[_T, _Base]): """ - Type-level subtype check. Evaluates to a type-level boolean. - Used in conditional type expressions: `_Cond[IsSub[T, Base], X, Y]` + Type-level assignability check. Evaluates to a type-level boolean. + Used in conditional type expressions: `Foo if IsAssignable[T, Base] else Bar` """ ... @_type_operator -class Matches(Generic[_T, _S]): +class IsEquivalent(Generic[_T, _S]): """ Type equivalence check. Returns Literal[True] if T is a subtype of S AND S is a subtype of T. - Equivalent to: IsSub[T, S] and IsSub[S, T] + Equivalent to: IsAssignable[T, S] and IsAssignable[S, T] """ ... @@ -222,7 +222,7 @@ class Bool(Generic[_T]): """ Check if T contains Literal[True]. Returns Literal[True] if T is Literal[True] or a union containing it. - Equivalent to: IsSub[Literal[True], T] and not IsSub[T, Never] + Equivalent to: IsAssignable[Literal[True], T] and not IsAssignable[T, Never] """ ... diff --git a/mypy/typeshed/stdlib/builtins.pyi b/mypy/typeshed/stdlib/builtins.pyi index e3e68bc0908a4..d4399a59f43cc 100644 --- a/mypy/typeshed/stdlib/builtins.pyi +++ b/mypy/typeshed/stdlib/builtins.pyi @@ -2237,7 +2237,7 @@ if sys.version_info >= (3, 13): class _Cond(Generic[_T, _T1, _T2]): """ Type-level conditional expression. - _Cond[IsSub[T, Base], TrueType, FalseType] evaluates to TrueType if T is a subtype of Base, + _Cond[IsAssignable[T, Base], TrueType, FalseType] evaluates to TrueType if T is a subtype of Base, otherwise FalseType. """ diff --git a/mypy/typeshed/stdlib/typing.pyi b/mypy/typeshed/stdlib/typing.pyi index 3e72a410123fa..924cced47c0c0 100644 --- a/mypy/typeshed/stdlib/typing.pyi +++ b/mypy/typeshed/stdlib/typing.pyi @@ -1206,8 +1206,8 @@ if sys.version_info >= (3, 15): "FromUnion", "NewProtocol", "NewTypedDict", - "IsSub", - "Matches", + "IsAssignable", + "IsEquivalent", "Bool", "Iter", "Slice", @@ -1262,13 +1262,13 @@ if sys.version_info >= (3, 15): GetName as GetName, GetQuals as GetQuals, GetType as GetType, - IsSub as IsSub, + IsAssignable as IsAssignable, InitField as InitField, Iter as Iter, KwargsParam as KwargsParam, Length as Length, Lowercase as Lowercase, - Matches as Matches, + IsEquivalent as IsEquivalent, Member as Member, MemberQuals as MemberQuals, Members as Members, diff --git a/test-data/unit/check-typelevel-basic.test b/test-data/unit/check-typelevel-basic.test index 4015534f1dbd1..b2a13ed878dd9 100644 --- a/test-data/unit/check-typelevel-basic.test +++ b/test-data/unit/check-typelevel-basic.test @@ -1,30 +1,30 @@ -[case testTypeOperatorIsSub] -from typing import IsSub +[case testTypeOperatorIsAssignable] +from typing import IsAssignable # Ternary syntax should be converted to _Cond[condition, true_type, false_type] -x: IsSub[int, object] +x: IsAssignable[int, object] reveal_type(x) # N: Revealed type is "Literal[True]" -y: IsSub[int, str] +y: IsAssignable[int, str] reveal_type(y) # N: Revealed type is "Literal[False]" [builtins fixtures/typelevel.pyi] [typing fixtures/typing-full.pyi] -[case testTypeOperatorMatches] -from typing import Matches, Literal +[case testTypeOperatorIsEquivalent] +from typing import IsEquivalent, Literal -# Matches checks type equivalence (both directions of subtype) -x1: Matches[int, int] +# IsEquivalent checks type equivalence (both directions of subtype) +x1: IsEquivalent[int, int] reveal_type(x1) # N: Revealed type is "Literal[True]" -x2: Matches[int, object] +x2: IsEquivalent[int, object] reveal_type(x2) # N: Revealed type is "Literal[False]" -x3: Matches[Literal[1], Literal[1]] +x3: IsEquivalent[Literal[1], Literal[1]] reveal_type(x3) # N: Revealed type is "Literal[True]" -x4: Matches[Literal[1], int] +x4: IsEquivalent[Literal[1], int] reveal_type(x4) # N: Revealed type is "Literal[False]" [builtins fixtures/typelevel.pyi] @@ -32,7 +32,7 @@ reveal_type(x4) # N: Revealed type is "Literal[False]" [case testTypeOperatorBool] # flags: --python-version 3.10 -from typing import Bool, Literal, IsSub, Union +from typing import Bool, Literal, IsAssignable, Union # Bool checks if type contains Literal[True] x1: Bool[Literal[True]] @@ -47,96 +47,96 @@ reveal_type(x3) # N: Revealed type is "Literal[True]" x4: Bool[bool] reveal_type(x4) # N: Revealed type is "Literal[True]" -# Using Bool to check result of IsSub -x5: Bool[IsSub[int, object]] +# Using Bool to check result of IsAssignable +x5: Bool[IsAssignable[int, object]] reveal_type(x5) # N: Revealed type is "Literal[True]" -x6: Bool[IsSub[int, str]] +x6: Bool[IsAssignable[int, str]] reveal_type(x6) # N: Revealed type is "Literal[False]" [builtins fixtures/typelevel.pyi] [typing fixtures/typing-full.pyi] [case testTypeOperatorAnd] -from typing import IsSub, Literal +from typing import IsAssignable, Literal # _And is the internal representation for `and` in type booleans -# We test it directly via IsSub compositions +# We test it directly via IsAssignable compositions # True and True = True -x1: Literal[True] if IsSub[int, object] and IsSub[str, object] else Literal[False] +x1: Literal[True] if IsAssignable[int, object] and IsAssignable[str, object] else Literal[False] reveal_type(x1) # N: Revealed type is "Literal[True]" # True and False = False -x2: Literal[True] if IsSub[int, object] and IsSub[int, str] else Literal[False] +x2: Literal[True] if IsAssignable[int, object] and IsAssignable[int, str] else Literal[False] reveal_type(x2) # N: Revealed type is "Literal[False]" # False and True = False (short-circuit) -x3: Literal[True] if IsSub[int, str] and IsSub[str, object] else Literal[False] +x3: Literal[True] if IsAssignable[int, str] and IsAssignable[str, object] else Literal[False] reveal_type(x3) # N: Revealed type is "Literal[False]" # False and False = False -x4: Literal[True] if IsSub[int, str] and IsSub[str, int] else Literal[False] +x4: Literal[True] if IsAssignable[int, str] and IsAssignable[str, int] else Literal[False] reveal_type(x4) # N: Revealed type is "Literal[False]" [builtins fixtures/typelevel.pyi] [typing fixtures/typing-full.pyi] [case testTypeOperatorOr] -from typing import IsSub, Literal +from typing import IsAssignable, Literal # _Or is the internal representation for `or` in type booleans # True or True = True -x1: Literal[True] if IsSub[int, object] or IsSub[str, object] else Literal[False] +x1: Literal[True] if IsAssignable[int, object] or IsAssignable[str, object] else Literal[False] reveal_type(x1) # N: Revealed type is "Literal[True]" # True or False = True (short-circuit) -x2: Literal[True] if IsSub[int, object] or IsSub[int, str] else Literal[False] +x2: Literal[True] if IsAssignable[int, object] or IsAssignable[int, str] else Literal[False] reveal_type(x2) # N: Revealed type is "Literal[True]" # False or True = True -x3: Literal[True] if IsSub[int, str] or IsSub[str, object] else Literal[False] +x3: Literal[True] if IsAssignable[int, str] or IsAssignable[str, object] else Literal[False] reveal_type(x3) # N: Revealed type is "Literal[True]" # False or False = False -x4: Literal[True] if IsSub[int, str] or IsSub[str, int] else Literal[False] +x4: Literal[True] if IsAssignable[int, str] or IsAssignable[str, int] else Literal[False] reveal_type(x4) # N: Revealed type is "Literal[False]" [builtins fixtures/typelevel.pyi] [typing fixtures/typing-full.pyi] [case testTypeOperatorNot] -from typing import IsSub, Literal +from typing import IsAssignable, Literal # _Not is the internal representation for `not` in type booleans # not True = False -x1: Literal[True] if not IsSub[int, object] else Literal[False] +x1: Literal[True] if not IsAssignable[int, object] else Literal[False] reveal_type(x1) # N: Revealed type is "Literal[False]" # not False = True -x2: Literal[True] if not IsSub[int, str] else Literal[False] +x2: Literal[True] if not IsAssignable[int, str] else Literal[False] reveal_type(x2) # N: Revealed type is "Literal[True]" [builtins fixtures/typelevel.pyi] [typing fixtures/typing-full.pyi] [case testTypeOperatorBoolCombinations] -from typing import IsSub, Literal +from typing import IsAssignable, Literal # Test combinations of and, or, not # not (True and False) = True -x1: Literal[True] if not (IsSub[int, object] and IsSub[int, str]) else Literal[False] +x1: Literal[True] if not (IsAssignable[int, object] and IsAssignable[int, str]) else Literal[False] reveal_type(x1) # N: Revealed type is "Literal[True]" # (True or False) and True = True -x2: Literal[True] if (IsSub[int, object] or IsSub[int, str]) and IsSub[str, object] else Literal[False] +x2: Literal[True] if (IsAssignable[int, object] or IsAssignable[int, str]) and IsAssignable[str, object] else Literal[False] reveal_type(x2) # N: Revealed type is "Literal[True]" # not False or False = True -x3: Literal[True] if not IsSub[int, str] or IsSub[str, int] else Literal[False] +x3: Literal[True] if not IsAssignable[int, str] or IsAssignable[str, int] else Literal[False] reveal_type(x3) # N: Revealed type is "Literal[True]" [builtins fixtures/typelevel.pyi] @@ -144,10 +144,10 @@ reveal_type(x3) # N: Revealed type is "Literal[True]" [case testTypeOperatorTernarySyntax] -from typing import IsSub +from typing import IsAssignable # Ternary syntax should be converted to _Cond[condition, true_type, false_type] -x: str if IsSub[int, object] else int +x: str if IsAssignable[int, object] else int x = 0 # E: Incompatible types in assignment (expression has type "int", variable has type "str") @@ -156,14 +156,14 @@ x = 0 # E: Incompatible types in assignment (expression has type "int", variabl [case testTypeOperatorInGenericClass] # Test type operators in generic class context -from typing import Generic, TypeVar, IsSub +from typing import Generic, TypeVar, IsAssignable T = TypeVar('T') class MyClass(Generic[T]): - attr: str if IsSub[T, int] else float + attr: str if IsAssignable[T, int] else float - def method(self, x: list[T] if IsSub[T, str] else T) -> None: + def method(self, x: list[T] if IsAssignable[T, str] else T) -> None: pass x: MyClass[int] @@ -187,10 +187,10 @@ z.method(['0']) [case testTypeOperatorNestedTernary] # Test nested ternary expressions -from typing import IsSub +from typing import IsAssignable # Nested ternary should work -z: int if IsSub[int, str] else (float if IsSub[float, object] else str) +z: int if IsAssignable[int, str] else (float if IsAssignable[float, object] else str) z = 'xxx' # E: Incompatible types in assignment (expression has type "str", variable has type "float") [builtins fixtures/typelevel.pyi] @@ -198,14 +198,14 @@ z = 'xxx' # E: Incompatible types in assignment (expression has type "str", var [case testTypeOperatorMultipleTypeArgs] # Test type operators with various argument types -from typing import Generic, TypeVar, IsSub +from typing import Generic, TypeVar, IsAssignable T = TypeVar('T') U = TypeVar('U') class Container(Generic[T, U]): # Complex conditional with multiple type variables - value: list[U] if IsSub[T, int] else tuple[T, U] + value: list[U] if IsAssignable[T, int] else tuple[T, U] x: Container[int, bool] reveal_type(x.value) # N: Revealed type is "builtins.list[builtins.bool]" @@ -218,15 +218,15 @@ reveal_type(y.value) # N: Revealed type is "tuple[builtins.str, builtins.bool]" [case testTypeOperatorCall0] # Test type operators in function signatures -from typing import TypeVar, IsSub +from typing import TypeVar, IsAssignable T = TypeVar('T') # XXX: resolving this seems basically impossible!! def process( - x: bytes if IsSub[T, str] else T, - y: str if IsSub[T, int] else float -) -> int if IsSub[T, list[object]] else str: + x: bytes if IsAssignable[T, str] else T, + y: str if IsAssignable[T, int] else float +) -> int if IsAssignable[T, list[object]] else str: ... [builtins fixtures/typelevel.pyi] @@ -235,9 +235,9 @@ def process( [case testTypeOperatorCall1] # flags: --python-version 3.14 # Test type operators in function signatures -from typing import IsSub +from typing import IsAssignable -def process[T](x: T) -> IsSub[T, str]: +def process[T](x: T) -> IsAssignable[T, str]: ... reveal_type(process(0)) # N: Revealed type is "Literal[False]" @@ -251,9 +251,9 @@ reveal_type(process('test')) # N: Revealed type is "Literal[True]" # Test a type operator in an *argument*, but one that should be resolvable # No sweat!! -from typing import Callable, IsSub, Literal +from typing import Callable, IsAssignable, Literal -def process[T](x: T, y: IsSub[T, str]) -> None: +def process[T](x: T, y: IsAssignable[T, str]) -> None: ... process(0, False) @@ -262,8 +262,8 @@ process('test', False) # E: Argument 2 to "process" has incompatible type "Lite process('test', True) x0: Callable[[int, Literal[False]], None] = process -x1: Callable[[int, Literal[True]], None] = process # E: Incompatible types in assignment (expression has type "Callable[[T, typing.IsSub[T, str]], None]", variable has type "Callable[[int, Literal[True]], None]") -x2: Callable[[str, Literal[False]], None] = process # E: Incompatible types in assignment (expression has type "Callable[[T, typing.IsSub[T, str]], None]", variable has type "Callable[[str, Literal[False]], None]") +x1: Callable[[int, Literal[True]], None] = process # E: Incompatible types in assignment (expression has type "Callable[[T, typing.IsAssignable[T, str]], None]", variable has type "Callable[[int, Literal[True]], None]") +x2: Callable[[str, Literal[False]], None] = process # E: Incompatible types in assignment (expression has type "Callable[[T, typing.IsAssignable[T, str]], None]", variable has type "Callable[[str, Literal[False]], None]") x3: Callable[[str, Literal[True]], None] = process @@ -275,9 +275,9 @@ x3: Callable[[str, Literal[True]], None] = process # Test a type operator in an *argument*, but one that should be resolvable # No sweat!! -from typing import Callable, IsSub, Literal +from typing import Callable, IsAssignable, Literal -def process[T](x: IsSub[T, str], y: T) -> None: +def process[T](x: IsAssignable[T, str], y: T) -> None: ... process(False, 0) @@ -286,11 +286,11 @@ process(False, 'test') # E: Argument 1 to "process" has incompatible type "Lite process(True, 'test') x0: Callable[[Literal[False], int], None] = process -x1: Callable[[Literal[True], int], None] = process # E: Incompatible types in assignment (expression has type "Callable[[typing.IsSub[T, str], T], None]", variable has type "Callable[[Literal[True], int], None]") -x2: Callable[[Literal[False], str], None] = process # E: Incompatible types in assignment (expression has type "Callable[[typing.IsSub[T, str], T], None]", variable has type "Callable[[Literal[False], str], None]") +x1: Callable[[Literal[True], int], None] = process # E: Incompatible types in assignment (expression has type "Callable[[typing.IsAssignable[T, str], T], None]", variable has type "Callable[[Literal[True], int], None]") +x2: Callable[[Literal[False], str], None] = process # E: Incompatible types in assignment (expression has type "Callable[[typing.IsAssignable[T, str], T], None]", variable has type "Callable[[Literal[False], str], None]") x3: Callable[[Literal[True], str], None] = process -reveal_type(process) # N: Revealed type is "def [T] (x: typing.IsSub[T`-1, builtins.str], y: T`-1)" +reveal_type(process) # N: Revealed type is "def [T] (x: typing.IsAssignable[T`-1, builtins.str], y: T`-1)" [builtins fixtures/typelevel.pyi] [typing fixtures/typing-full.pyi] @@ -298,10 +298,10 @@ reveal_type(process) # N: Revealed type is "def [T] (x: typing.IsSub[T`-1, buil [case testTypeOperatorInTypeAlias] # flags: --python-version 3.14 # Test type operators in type aliases (using concrete types to avoid unbound typevar issues) -from typing import IsSub +from typing import IsAssignable # Type alias using _Cond with concrete types -type ConditionalType = str if IsSub[int, object] else bytes +type ConditionalType = str if IsAssignable[int, object] else bytes z: ConditionalType = b'lol' # E: Incompatible types in assignment (expression has type "bytes", variable has type "str") @@ -311,9 +311,9 @@ z: ConditionalType = b'lol' # E: Incompatible types in assignment (expression h [case testTypeOperatorGenericTypeAlias] # flags: --python-version 3.14 # Test generic type alias with conditional type -from typing import IsSub +from typing import IsAssignable -type IntOptional[T] = T | None if IsSub[T, int] else T +type IntOptional[T] = T | None if IsAssignable[T, int] else T x0: IntOptional[int] reveal_type(x0) # N: Revealed type is "builtins.int | None" @@ -575,7 +575,7 @@ reveal_type(args) # N: Revealed type is "tuple[builtins.int, builtins.str]" # flags: --python-version 3.14 # Test GetArg on tuple types -from typing import GetArg, Iterable, Literal, Sequence, IsSub, TypedDict, Protocol +from typing import GetArg, Iterable, Literal, Sequence, IsAssignable, TypedDict, Protocol x0: GetArg[tuple[int, str, float], Iterable, Literal[0]] reveal_type(x0) # N: Revealed type is "builtins.int | builtins.str | builtins.float" @@ -688,7 +688,7 @@ reveal_type(z2) # N: Revealed type is "Never" [case testTypeComprehensionBasicTy] # flags: --python-version 3.14 # Test basic type comprehension -from typing import IsSub, Iter +from typing import IsAssignable, Iter class A[*T]: pass @@ -704,7 +704,7 @@ reveal_type(f) # N: Revealed type is "def (x: __main__.A[builtins.int, builtins [case testTypeComprehensionBasic] # Test basic type comprehension -from typing import IsSub, Iter +from typing import IsAssignable, Iter # Basic comprehension - maps over tuple elements def f(x: tuple[*[T for T in Iter[tuple[int, str, float]]]]) -> None: @@ -720,10 +720,10 @@ reveal_type(z) # N: Revealed type is "tuple[()]" [case testTypeComprehensionWithCondition] # Test type comprehension with filtering condition -from typing import IsSub, Iter +from typing import IsAssignable, Iter # Filter elements - only keep subtypes of int (int, bool) -def f(x: tuple[*[T for T in Iter[tuple[int, str, bool, float]] if IsSub[T, int]]]) -> None: +def f(x: tuple[*[T for T in Iter[tuple[int, str, bool, float]] if IsAssignable[T, int]]]) -> None: pass reveal_type(f) # N: Revealed type is "def (x: tuple[builtins.int, builtins.bool])" @@ -787,10 +787,10 @@ reveal_type(x) # N: Revealed type is "tuple[Literal['A'], Literal['B'], Literal [case testTypeOperatorSubType0] # flags: --python-version 3.14 -from typing import IsSub +from typing import IsAssignable -def process[T](x: IsSub[T, str], y: IsSub[T, int]) -> None: - x = y # E: Incompatible types in assignment (expression has type "typing.IsSub[T, int]", variable has type "typing.IsSub[T, str]") +def process[T](x: IsAssignable[T, str], y: IsAssignable[T, int]) -> None: + x = y # E: Incompatible types in assignment (expression has type "typing.IsAssignable[T, int]", variable has type "typing.IsAssignable[T, str]") [builtins fixtures/typelevel.pyi] @@ -799,10 +799,10 @@ def process[T](x: IsSub[T, str], y: IsSub[T, int]) -> None: [case testTypeOperatorSubType1] # flags: --python-version 3.14 -from typing import Callable, IsSub, Literal +from typing import Callable, IsAssignable, Literal class A: - def process[T](self, x: IsSub[T, str], y: T) -> None: + def process[T](self, x: IsAssignable[T, str], y: T) -> None: ... @@ -811,18 +811,18 @@ class B(A): class C(A): - def process[T](self, x: IsSub[T, str], y: T) -> None: + def process[T](self, x: IsAssignable[T, str], y: T) -> None: ... class D(A): - def process[T](self, x: IsSub[T, int], y: T) -> None: # E: Argument 1 of "process" is incompatible with supertype "A"; supertype defines the argument type as "typing.IsSub[T, str]" \ + def process[T](self, x: IsAssignable[T, int], y: T) -> None: # E: Argument 1 of "process" is incompatible with supertype "A"; supertype defines the argument type as "typing.IsAssignable[T, str]" \ # N: This violates the Liskov substitution principle \ # N: See https://mypy.readthedocs.io/en/stable/common_issues.html#incompatible-overrides ... class E(A): - def process[T](self, x: str, y: T) -> None: # E: Argument 1 of "process" is incompatible with supertype "A"; supertype defines the argument type as "typing.IsSub[T, str]" \ + def process[T](self, x: str, y: T) -> None: # E: Argument 1 of "process" is incompatible with supertype "A"; supertype defines the argument type as "typing.IsAssignable[T, str]" \ # N: This violates the Liskov substitution principle \ # N: See https://mypy.readthedocs.io/en/stable/common_issues.html#incompatible-overrides ... @@ -834,10 +834,10 @@ class E(A): [case testTypeOperatorSubType2] # flags: --python-version 3.14 -from typing import Callable, IsSub, Literal +from typing import Callable, IsAssignable, Literal class A: - def process[T](self, y: T) -> IsSub[T, str]: + def process[T](self, y: T) -> IsAssignable[T, str]: ... @@ -846,17 +846,17 @@ class B(A): class C(A): - def process[T](self, y: T) -> IsSub[T, str]: + def process[T](self, y: T) -> IsAssignable[T, str]: ... class D(A): - def process[T](self, y: T) -> IsSub[T, int]: # E: Return type "typing.IsSub[T, int]" of "process" incompatible with return type "typing.IsSub[T, str]" in supertype "A" + def process[T](self, y: T) -> IsAssignable[T, int]: # E: Return type "typing.IsAssignable[T, int]" of "process" incompatible with return type "typing.IsAssignable[T, str]" in supertype "A" ... class E(A): - def process[T](self, y: T) -> str: # E: Return type "str" of "process" incompatible with return type "typing.IsSub[T, str]" in supertype "A" + def process[T](self, y: T) -> str: # E: Return type "str" of "process" incompatible with return type "typing.IsAssignable[T, str]" in supertype "A" ... @@ -867,7 +867,7 @@ class E(A): [case testTypeOperatorSubType3] # flags: --python-version 3.14 -from typing import Callable, IsSub, Literal, Iter +from typing import Callable, IsAssignable, Literal, Iter class A: def process[Ts](self, x: Ts) -> tuple[*[t for t in Iter[Ts]]]: @@ -899,18 +899,18 @@ class D(A): [case testTypeOperatorJoin1] # flags: --python-version 3.14 -from typing import Callable, IsSub +from typing import Callable, IsAssignable -def f0[T](x: IsSub[T, str], y: IsSub[T, str]) -> None: +def f0[T](x: IsAssignable[T, str], y: IsAssignable[T, str]) -> None: z = [x, y] - reveal_type(z) # N: Revealed type is "builtins.list[typing.IsSub[T`-1, builtins.str]]" + reveal_type(z) # N: Revealed type is "builtins.list[typing.IsAssignable[T`-1, builtins.str]]" -def f1[T](x: IsSub[T, str], y: IsSub[T, str] | None) -> None: +def f1[T](x: IsAssignable[T, str], y: IsAssignable[T, str] | None) -> None: z = [x, y] - reveal_type(z) # N: Revealed type is "builtins.list[typing.IsSub[T`-1, builtins.str] | None]" + reveal_type(z) # N: Revealed type is "builtins.list[typing.IsAssignable[T`-1, builtins.str] | None]" -def g[T](x: IsSub[T, str], y: IsSub[T, int]) -> None: +def g[T](x: IsAssignable[T, str], y: IsAssignable[T, int]) -> None: z = [x, y] reveal_type(z) # N: Revealed type is "builtins.list[builtins.object]" @@ -922,18 +922,18 @@ def g[T](x: IsSub[T, str], y: IsSub[T, int]) -> None: [case testTypeOperatorMeet1] # flags: --python-version 3.14 -from typing import Callable, IsSub +from typing import Callable, IsAssignable def g[T]( - x: Callable[[IsSub[T, str]], None], - x0: Callable[[IsSub[T, str]], None], - y: Callable[[IsSub[T, int]], None], + x: Callable[[IsAssignable[T, str]], None], + x0: Callable[[IsAssignable[T, str]], None], + y: Callable[[IsAssignable[T, int]], None], ) -> None: z = [x, y] reveal_type(z) # N: Revealed type is "builtins.list[builtins.function]" w = [x, x0] - reveal_type(w) # N: Revealed type is "builtins.list[def (typing.IsSub[T`-1, builtins.str])]" + reveal_type(w) # N: Revealed type is "builtins.list[def (typing.IsAssignable[T`-1, builtins.str])]" [builtins fixtures/typelevel.pyi] [typing fixtures/typing-full.pyi] @@ -979,14 +979,14 @@ z: Alias[bool] # E: wrong: bool [typing fixtures/typing-full.pyi] [case testTypeOperatorRaiseErrorInConditional] -from typing import RaiseError, IsSub, Literal +from typing import RaiseError, IsAssignable, Literal # RaiseError in a conditional - error only if condition triggers it T = int -x: str if IsSub[T, str] else RaiseError[Literal['T must be a str']] # E: T must be a str +x: str if IsAssignable[T, str] else RaiseError[Literal['T must be a str']] # E: T must be a str reveal_type(x) # N: Revealed type is "Never" -y: str if IsSub[T, int] else RaiseError[Literal['T must be a int']] +y: str if IsAssignable[T, int] else RaiseError[Literal['T must be a int']] reveal_type(y) # N: Revealed type is "builtins.str" @@ -997,9 +997,9 @@ reveal_type(y) # N: Revealed type is "builtins.str" [case testTypeOperatorConditionalArg1] # flags: --python-version 3.14 -from typing import IsSub +from typing import IsAssignable -def foo[T](x: T, y: list[T] if IsSub[T, int] else T) -> T: +def foo[T](x: T, y: list[T] if IsAssignable[T, int] else T) -> T: return x @@ -1015,9 +1015,9 @@ foo('a', ['a']) # E: Argument 2 to "foo" has incompatible type "list[str]"; exp [case testTypeOperatorConditionalArg2] # flags: --python-version 3.14 -from typing import IsSub +from typing import IsAssignable -def foo[T](y: list[T] if IsSub[T, int] else T, x: T) -> T: +def foo[T](y: list[T] if IsAssignable[T, int] else T, x: T) -> T: return x diff --git a/test-data/unit/check-typelevel-comprehension.test b/test-data/unit/check-typelevel-comprehension.test index 882249b06bb67..6fc5aaa693c7b 100644 --- a/test-data/unit/check-typelevel-comprehension.test +++ b/test-data/unit/check-typelevel-comprehension.test @@ -9,11 +9,11 @@ reveal_type(x) # N: Revealed type is "tuple[builtins.list[builtins.int], builti [typing fixtures/typing-full.pyi] [case testTypeComprehensionWithCondition] -# Test type comprehension with IsSub condition -from typing import Iter, IsSub +# Test type comprehension with IsAssignable condition +from typing import Iter, IsAssignable # Filter to only types that are subtypes of int (just int in this case) -x: tuple[*[list[T] for T in Iter[tuple[int, str, float]] if IsSub[T, int]]] +x: tuple[*[list[T] for T in Iter[tuple[int, str, float]] if IsAssignable[T, int]]] reveal_type(x) # N: Revealed type is "tuple[builtins.list[builtins.int]]" [builtins fixtures/typelevel.pyi] @@ -21,9 +21,9 @@ reveal_type(x) # N: Revealed type is "tuple[builtins.list[builtins.int]]" [case testTypeComprehensionFilterSubtypeObject] # Test type comprehension filtering subtypes of object (everything passes) -from typing import Iter, IsSub +from typing import Iter, IsAssignable -x: tuple[*[T for T in Iter[tuple[int, str, bool]] if IsSub[T, object]]] +x: tuple[*[T for T in Iter[tuple[int, str, bool]] if IsAssignable[T, object]]] reveal_type(x) # N: Revealed type is "tuple[builtins.int, builtins.str, builtins.bool]" [builtins fixtures/typelevel.pyi] @@ -31,10 +31,10 @@ reveal_type(x) # N: Revealed type is "tuple[builtins.int, builtins.str, builtin [case testTypeComprehensionFilterNone] # Test type comprehension where no items pass the condition -from typing import Iter, IsSub +from typing import Iter, IsAssignable # No type in the tuple is a subtype of str (str is not in the tuple) -x: tuple[*[T for T in Iter[tuple[int, float, bool]] if IsSub[T, str]]] +x: tuple[*[T for T in Iter[tuple[int, float, bool]] if IsAssignable[T, str]]] reveal_type(x) # N: Revealed type is "tuple[()]" [builtins fixtures/typelevel.pyi] diff --git a/test-data/unit/check-typelevel-examples.test b/test-data/unit/check-typelevel-examples.test index a091d97164ebf..10851fce77dca 100644 --- a/test-data/unit/check-typelevel-examples.test +++ b/test-data/unit/check-typelevel-examples.test @@ -8,7 +8,7 @@ from typing import ( BaseTypedDict, Iter, Attrs, - IsSub, + IsAssignable, GetType, Member, GetName, @@ -90,7 +90,7 @@ producing a new target type containing only properties and wrapping """ type ConvertField[T] = ( - AdjustLink[PropsOnly[PointerArg[T]], T] if IsSub[T, Link] else PointerArg[T] + AdjustLink[PropsOnly[PointerArg[T]], T] if IsAssignable[T, Link] else PointerArg[T] ) """``PointerArg`` gets the type argument to ``Pointer`` or a subclass. @@ -111,7 +111,7 @@ type PointerArg[T] = GetArg[T, Pointer, Literal[0]] we've discussed already. """ -type AdjustLink[Tgt, LinkTy] = list[Tgt] if IsSub[LinkTy, MultiLink] else Tgt +type AdjustLink[Tgt, LinkTy] = list[Tgt] if IsAssignable[LinkTy, MultiLink] else Tgt """And the final helper, ``PropsOnly[T]``, generates a new type that contains all the ``Property`` attributes of ``T``. @@ -121,7 +121,7 @@ type PropsOnly[T] = NewProtocol[ *[ Member[GetName[p], PointerArg[GetType[p]]] for p in Iter[Attrs[T]] - if IsSub[GetType[p], Property] + if IsAssignable[GetType[p], Property] ] ] @@ -184,11 +184,11 @@ from typing import ( Never, GetArg, Bool, - Matches, + IsEquivalent, Iter, Slice, Length, - IsSub, + IsAssignable, RaiseError, ) @@ -227,8 +227,8 @@ def add2[DType, Shape1, Shape2]( type MergeOne[T, S] = ( T - if Matches[T, S] or Matches[S, Literal[1]] - else S if Matches[T, Literal[1]] + if IsEquivalent[T, S] or IsEquivalent[S, Literal[1]] + else S if IsEquivalent[T, Literal[1]] else RaiseError[Literal["Broadcast mismatch"], T, S] ) @@ -239,7 +239,7 @@ type Last[T] = GetArg[T, tuple, Literal[-1]] # Matching on Never here is intentional; it prevents stupid # infinite recursions. -type Empty[T] = IsSub[Length[T], Literal[0]] +type Empty[T] = IsAssignable[Length[T], Literal[0]] type Tup[*Ts] = tuple[*Ts] @@ -335,11 +335,11 @@ from typing import ( Never, GetArg, Bool, - Matches, + IsEquivalent, Iter, Slice, Length, - IsSub, + IsAssignable, RaiseError, ) @@ -354,8 +354,8 @@ class Array[DType, *Shape]: type MergeOne[T, S] = ( T - if Matches[T, S] or Matches[S, Literal[1]] - else S if Matches[T, Literal[1]] + if IsEquivalent[T, S] or IsEquivalent[S, Literal[1]] + else S if IsEquivalent[T, Literal[1]] else RaiseError[Literal["Broadcast mismatch"], T, S] ) @@ -364,7 +364,7 @@ type Last[T] = GetArg[T, tuple, Literal[-1]] # Matching on Never here is intentional; it prevents stupid # infinite recursions. -type Empty[T] = IsSub[Length[T], Literal[0]] +type Empty[T] = IsAssignable[Length[T], Literal[0]] type Merge[T, S] = ( S if Bool[Empty[T]] else T if Bool[Empty[S]] @@ -446,14 +446,14 @@ type GetFieldItem[T, K] = typing.GetMemberType[ # Strip `| None` from a type by iterating over its union components # and filtering type NotOptional[T] = Union[ - *[x for x in typing.Iter[typing.FromUnion[T]] if not typing.IsSub[x, None]] + *[x for x in typing.Iter[typing.FromUnion[T]] if not typing.IsAssignable[x, None]] ] # Adjust an attribute type for use in Public below by dropping | None for # primary keys and stripping all annotations. type FixPublicType[T, Init] = ( NotOptional[T] - if typing.IsSub[Literal[True], GetFieldItem[Init, Literal["primary_key"]]] + if typing.IsAssignable[Literal[True], GetFieldItem[Init, Literal["primary_key"]]] else T ) @@ -468,7 +468,7 @@ type Public[T] = typing.NewProtocol[ typing.GetQuals[p], ] for p in typing.Iter[typing.Attrs[T]] - if not typing.IsSub[ + if not typing.IsAssignable[ Literal[True], GetFieldItem[typing.GetInit[p], Literal["hidden"]] ] ] @@ -485,7 +485,7 @@ suite, but here is a possible implementation of just ``Public`` # otherwise we return the type itself. type GetDefault[Init] = ( GetFieldItem[Init, Literal["default"]] - if typing.IsSub[Init, Field] + if typing.IsAssignable[Init, Field] else Init ) @@ -499,7 +499,7 @@ type Create[T] = typing.NewProtocol[ GetDefault[typing.GetInit[p]], ] for p in typing.Iter[typing.Attrs[T]] - if not typing.IsSub[ + if not typing.IsAssignable[ Literal[True], GetFieldItem[typing.GetInit[p], Literal["primary_key"]], ] @@ -517,7 +517,7 @@ type Update[T] = typing.NewProtocol[ Literal[None], ] for p in typing.Iter[typing.Attrs[T]] - if not typing.IsSub[ + if not typing.IsAssignable[ Literal[True], GetFieldItem[typing.GetInit[p], Literal["primary_key"]], ] diff --git a/test-data/unit/check-typelevel-members.test b/test-data/unit/check-typelevel-members.test index ec15728dd82c3..38d038f71442f 100644 --- a/test-data/unit/check-typelevel-members.test +++ b/test-data/unit/check-typelevel-members.test @@ -285,7 +285,7 @@ x = {'name': 'Alice', 'age': 30} [typing fixtures/typing-full.pyi] [case testNewTypedDictWithComprehensionFilter] -from typing import NewTypedDict, Members, TypedDict, Iter, IsSub, GetMemberType, Literal +from typing import NewTypedDict, Members, TypedDict, Iter, IsAssignable, GetMemberType, Literal class Person(TypedDict): name: str @@ -293,7 +293,7 @@ class Person(TypedDict): active: bool # Filter to only string fields -TD = NewTypedDict[*[m for m in Iter[Members[Person]] if IsSub[GetMemberType[m, Literal['typ']], str]]] +TD = NewTypedDict[*[m for m in Iter[Members[Person]] if IsAssignable[GetMemberType[m, Literal['typ']], str]]] x: TD reveal_type(x) # N: Revealed type is "TypedDict({'name': builtins.str})" x = {'name': 'Alice'} diff --git a/test-data/unit/fixtures/typing-full.pyi b/test-data/unit/fixtures/typing-full.pyi index 5ff95d873c622..67fb19349a1bc 100644 --- a/test-data/unit/fixtures/typing-full.pyi +++ b/test-data/unit/fixtures/typing-full.pyi @@ -247,10 +247,10 @@ _Ts = TypeVarTuple("_Ts") class Iter(Generic[T]): ... @_type_operator -class IsSub(Generic[T, U]): ... +class IsAssignable(Generic[T, U]): ... @_type_operator -class Matches(Generic[T, U]): ... +class IsEquivalent(Generic[T, U]): ... @_type_operator class Bool(Generic[T]): ... From f1808a1d9cfd4b53bad3499b1f510322a7baa5ca Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Thu, 12 Feb 2026 13:53:01 -0800 Subject: [PATCH 115/161] Add pythoneval test for fastapilike example with full stubs --- test-data/unit/check-typelevel-examples.test | 1 - test-data/unit/pythoneval.test | 153 +++++++++++++++++++ 2 files changed, 153 insertions(+), 1 deletion(-) diff --git a/test-data/unit/check-typelevel-examples.test b/test-data/unit/check-typelevel-examples.test index 10851fce77dca..ccda761af3d5f 100644 --- a/test-data/unit/check-typelevel-examples.test +++ b/test-data/unit/check-typelevel-examples.test @@ -405,7 +405,6 @@ err2: Array[float, L[3]] [builtins fixtures/typelevel.pyi] [typing fixtures/typing-full.pyi] - [case testTypeLevel_fastapilike] # flags: --python-version 3.14 diff --git a/test-data/unit/pythoneval.test b/test-data/unit/pythoneval.test index 2d3f867d8dfdf..4cf9233ec1bcd 100644 --- a/test-data/unit/pythoneval.test +++ b/test-data/unit/pythoneval.test @@ -2229,3 +2229,156 @@ def f(x: int, y: list[str]): x in y [out] _testStrictEqualityWithList.py:3: error: Non-overlapping container check (element type: "int", container item type: "str") + +[case testTypeLevel] +# flags: --python-version 3.15 +from __future__ import annotations + +from typing import ( + Callable, + Literal, + Union, + ReadOnly, + TypedDict, + Never, + Self, +) + +import typing + + +class FieldArgs(TypedDict, total=False): + hidden: ReadOnly[bool] + primary_key: ReadOnly[bool] + index: ReadOnly[bool] + default: ReadOnly[object] + + +class Field[T: FieldArgs](typing.InitField[T]): + pass + + +#### + +# TODO: Should this go into the stdlib? +type GetFieldItem[T, K] = typing.GetMemberType[ + typing.GetArg[T, typing.InitField, Literal[0]], K +] + + +## + +# Strip `| None` from a type by iterating over its union components +# and filtering +type NotOptional[T] = Union[ + *[x for x in typing.Iter[typing.FromUnion[T]] if not typing.IsAssignable[x, None]] +] + +# Adjust an attribute type for use in Public below by dropping | None for +# primary keys and stripping all annotations. +type FixPublicType[T, Init] = ( + NotOptional[T] + if typing.IsAssignable[Literal[True], GetFieldItem[Init, Literal["primary_key"]]] + else T +) + +# Strip out everything that is Hidden and also make the primary key required +# Drop all the annotations, since this is for data getting returned to users +# from the DB, so we don't need default values. +type Public[T] = typing.NewProtocol[ + *[ + typing.Member[ + typing.GetName[p], + FixPublicType[typing.GetType[p], typing.GetInit[p]], + typing.GetQuals[p], + ] + for p in typing.Iter[typing.Attrs[T]] + if not typing.IsAssignable[ + Literal[True], GetFieldItem[typing.GetInit[p], Literal["hidden"]] + ] + ] +] + +# Begin PEP section: Automatically deriving FastAPI CRUD models +""" +We have a more `fully-worked example <#fastapi-test_>`_ in our test +suite, but here is a possible implementation of just ``Public`` +""" + +# Extract the default type from an Init field. +# If it is a Field, then we try pulling out the "default" field, +# otherwise we return the type itself. +type GetDefault[Init] = ( + GetFieldItem[Init, Literal["default"]] + if typing.IsAssignable[Init, Field] + else Init +) + +# Create takes everything but the primary key and preserves defaults +type Create[T] = typing.NewProtocol[ + *[ + typing.Member[ + typing.GetName[p], + typing.GetType[p], + typing.GetQuals[p], + GetDefault[typing.GetInit[p]], + ] + for p in typing.Iter[typing.Attrs[T]] + if not typing.IsAssignable[ + Literal[True], + GetFieldItem[typing.GetInit[p], Literal["primary_key"]], + ] + ] +] + +# Update takes everything but the primary key, but makes them all have +# None defaults +type Update[T] = typing.NewProtocol[ + *[ + typing.Member[ + typing.GetName[p], + typing.GetType[p] | None, + typing.GetQuals[p], + Literal[None], + ] + for p in typing.Iter[typing.Attrs[T]] + if not typing.IsAssignable[ + Literal[True], + GetFieldItem[typing.GetInit[p], Literal["primary_key"]], + ] + ] +] + +class Hero: + id: int | None = Field(default=None, primary_key=True) + + name: str = Field(index=True) + age: int | None = Field(default=None, index=True) + + secret_name: str = Field(hidden=True) + + +type HeroPublic = Public[Hero] +type HeroCreate = Create[Hero] +type HeroUpdate = Update[Hero] + +pub: HeroPublic +reveal_type(pub) + +creat: HeroCreate +reveal_type(creat) + +upd: HeroUpdate +reveal_type(upd) + +creat_members: tuple[*[typing.GetInit[p] for p in typing.Iter[typing.Members[HeroCreate]]]] +reveal_type(creat_members) + +upd_types: tuple[*[typing.GetType[p] for p in typing.Iter[typing.Members[HeroUpdate]]]] +reveal_type(upd_types) +[out] +_program.py:133: note: Revealed type is "__typelevel__.NewProtocol[id: builtins.int, name: builtins.str, age: builtins.int | None]" +_program.py:136: note: Revealed type is "__typelevel__.NewProtocol[name: builtins.str, age: builtins.int | None = None, secret_name: builtins.str]" +_program.py:139: note: Revealed type is "__typelevel__.NewProtocol[name: builtins.str | None = None, age: builtins.int | None | None = None, secret_name: builtins.str | None = None]" +_program.py:142: note: Revealed type is "tuple[Never, None, Never]" +_program.py:145: note: Revealed type is "tuple[builtins.str | None, builtins.int | None | None, builtins.str | None]" From 933af5bcef61e07b2836d64ee64be061469396f8 Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Thu, 12 Feb 2026 13:11:50 -0800 Subject: [PATCH 116/161] Add dot notation for type-level attribute access on type variables T.attr in type context desugars to _TypeGetAttr[T, Literal["attr"]], enabling concise member field access like m.name and m.typ instead of GetName[m] and GetType[m]. --- mypy/semanal.py | 3 + mypy/typeanal.py | 40 +++++++++++++- mypy/typelevel.py | 11 ++++ mypy/typeshed/stdlib/_typeshed/typemap.pyi | 10 ++++ mypy/typeshed/stdlib/typing.pyi | 1 + test-data/unit/check-typelevel-members.test | 61 +++++++++++++++++++++ test-data/unit/fixtures/typing-full.pyi | 3 + test-data/unit/pythoneval.test | 6 ++ 8 files changed, 134 insertions(+), 1 deletion(-) diff --git a/mypy/semanal.py b/mypy/semanal.py index 02b525dd3547f..b602a1ce0c26b 100644 --- a/mypy/semanal.py +++ b/mypy/semanal.py @@ -6828,6 +6828,9 @@ def lookup_qualified( # See https://github.com/python/mypy/pull/13468 if isinstance(node, ParamSpecExpr) and part in ("args", "kwargs"): return None + # Allow attribute access on type variables in type expression context + if isinstance(node, TypeVarExpr) and self.allow_unbound_tvars: + return None # Lookup through invalid node, such as variable or function nextsym = None if not nextsym or nextsym.module_hidden: diff --git a/mypy/typeanal.py b/mypy/typeanal.py index dc3e72ab4fa58..e965a1e378d82 100644 --- a/mypy/typeanal.py +++ b/mypy/typeanal.py @@ -300,7 +300,7 @@ def not_declared_in_type_params(self, tvar_name: str) -> bool: ) def visit_unbound_type_nonoptional(self, t: UnboundType, defining_literal: bool) -> Type: - sym = self.lookup_qualified(t.name, t) + sym = self.lookup_qualified(t.name, t, suppress_errors="." in t.name) param_spec_name = None if t.name.endswith((".args", ".kwargs")): param_spec_name = t.name.rsplit(".", 1)[0] @@ -526,6 +526,32 @@ def visit_unbound_type_nonoptional(self, t: UnboundType, defining_literal: bool) else: return self.analyze_unbound_type_without_type_info(t, sym, defining_literal) else: # sym is None + # Try dot notation for type-level attribute access: T.attr -> _TypeGetAttr[T, Literal["attr"]] + # Only applies when the prefix is a type variable (not a module, class, etc.) + if "." in t.name: + prefix, attr = t.name.rsplit(".", 1) + prefix_sym = self.lookup_qualified(prefix, t, suppress_errors=True) + if prefix_sym is not None and isinstance(prefix_sym.node, TypeVarExpr): + operator_sym = self.api.lookup_fully_qualified_or_none("typing._TypeGetAttr") + if operator_sym and isinstance(operator_sym.node, TypeInfo): + prefix_type = self.anal_type( + UnboundType(prefix, t.args, line=t.line, column=t.column) + ) + attr_literal = LiteralType( + attr, self.named_type("builtins.str"), line=t.line, column=t.column + ) + fallback = self.named_type("builtins.object") + return TypeOperatorType( + operator_sym.node, + [prefix_type, attr_literal], + fallback, + t.line, + t.column, + ) + else: + # Prefix is not a type variable — re-issue the original lookup + # without suppressed errors so the proper error is generated + self.lookup_qualified(t.name, t) return AnyType(TypeOfAny.special_form) def pack_paramspec_args(self, an_args: Sequence[Type], empty_tuple_index: bool) -> list[Type]: @@ -2706,6 +2732,18 @@ def visit_unbound_type(self, t: UnboundType) -> None: # really don't want to bother to put them in the symbol table. if name in self.internal_vars: return + + # Skip type-level attribute access (T.attr, m.name) where the prefix + # is a comprehension variable or a type variable — these are handled + # as _TypeGetAttr during type analysis, not as qualified name lookups. + if "." in name: + prefix = name.rsplit(".", 1)[0] + if prefix in self.internal_vars: + return + prefix_node = self.api.lookup_qualified(prefix, t, suppress_errors=True) + if prefix_node and isinstance(prefix_node.node, TypeVarExpr): + name = prefix + node = self.api.lookup_qualified(name, t) if node and node.fullname in SELF_TYPE_NAMES: self.has_self_type = True diff --git a/mypy/typelevel.py b/mypy/typelevel.py index 9e3a0df4ab5fd..71c93f8eb4ba7 100644 --- a/mypy/typelevel.py +++ b/mypy/typelevel.py @@ -648,6 +648,17 @@ def _eval_get_member_type(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) return UninhabitedType() +@register_operator("_TypeGetAttr") +@lift_over_unions +def _eval_type_get_attr(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: + """Evaluate _TypeGetAttr[T, Name] - get attribute type from T. + + Internal operator for dot notation: T.attr desugars to _TypeGetAttr[T, Literal["attr"]]. + Semantically equivalent to GetMemberType. + """ + return _eval_get_member_type(evaluator, typ) + + @register_operator("Slice") @lift_over_unions def _eval_slice(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: diff --git a/mypy/typeshed/stdlib/_typeshed/typemap.pyi b/mypy/typeshed/stdlib/_typeshed/typemap.pyi index 351dcaac65581..3df54e6528846 100644 --- a/mypy/typeshed/stdlib/_typeshed/typemap.pyi +++ b/mypy/typeshed/stdlib/_typeshed/typemap.pyi @@ -123,6 +123,16 @@ class GetMemberType(Generic[_T, _Name]): ... +@_type_operator +class _TypeGetAttr(Generic[_T, _Name]): + """ + Internal type operator for dot notation on types. + T.attr in type context desugars to _TypeGetAttr[T, Literal["attr"]]. + Semantically equivalent to GetMemberType. + """ + + ... + @_type_operator class Members(Generic[_T]): """ diff --git a/mypy/typeshed/stdlib/typing.pyi b/mypy/typeshed/stdlib/typing.pyi index 924cced47c0c0..425afaec3ce16 100644 --- a/mypy/typeshed/stdlib/typing.pyi +++ b/mypy/typeshed/stdlib/typing.pyi @@ -1284,4 +1284,5 @@ if sys.version_info >= (3, 15): Slice as Slice, Uncapitalize as Uncapitalize, Uppercase as Uppercase, + _TypeGetAttr as _TypeGetAttr, ) diff --git a/test-data/unit/check-typelevel-members.test b/test-data/unit/check-typelevel-members.test index 38d038f71442f..5a35ac7ae5ce4 100644 --- a/test-data/unit/check-typelevel-members.test +++ b/test-data/unit/check-typelevel-members.test @@ -366,3 +366,64 @@ reveal_type(x) # N: Revealed type is "tuple[Literal['default'], Literal['lol@lo [builtins fixtures/typelevel.pyi] [typing fixtures/typing-full.pyi] + +[case testDotNotationMemberAccess] +# flags: --python-version 3.12 +# Test dot notation for Member component access +from typing import Members, TypedDict, Iter, Member, Literal, Never + +class Person(TypedDict): + name: str + age: int + +m: Members[Person] +reveal_type(m) # N: Revealed type is "tuple[typing.Member[Literal['name'], builtins.str, Never, Never, Never], typing.Member[Literal['age'], builtins.int, Never, Never, Never]]" + +# Use dot notation to access Member fields +type Names = tuple[*[T.name for T in Iter[Members[Person]]]] +x: Names +reveal_type(x) # N: Revealed type is "tuple[Literal['name'], Literal['age']]" + +type Types = tuple[*[T.typ for T in Iter[Members[Person]]]] +y: Types +reveal_type(y) # N: Revealed type is "tuple[builtins.str, builtins.int]" + +[builtins fixtures/typelevel.pyi] +[typing fixtures/typing-full.pyi] + +[case testDotNotationNewTypedDict] +# flags: --python-version 3.12 +# Test dot notation in dict comprehension with NewTypedDict +from typing import NewTypedDict, Members, TypedDict, Iter + +class Person(TypedDict): + name: str + age: int + +# Use dot notation in dict comprehension +type TD = NewTypedDict[{m.name: m.typ for m in Iter[Members[Person]]}] +x: TD +reveal_type(x) # N: Revealed type is "TypedDict({'name': builtins.str, 'age': builtins.int})" +x = {'name': 'Alice', 'age': 30} + +[builtins fixtures/typelevel.pyi] +[typing fixtures/typing-full.pyi] + +[case testDotNotationWithCondition] +# flags: --python-version 3.12 +# Test dot notation with comprehension condition +from typing import NewTypedDict, Members, TypedDict, Iter, IsAssignable + +class Person(TypedDict): + name: str + age: int + active: bool + +# Filter to only string fields using dot notation +type TD = NewTypedDict[{m.name: m.typ for m in Iter[Members[Person]] if IsAssignable[m.typ, str]}] +x: TD +reveal_type(x) # N: Revealed type is "TypedDict({'name': builtins.str})" +x = {'name': 'Alice'} + +[builtins fixtures/typelevel.pyi] +[typing fixtures/typing-full.pyi] diff --git a/test-data/unit/fixtures/typing-full.pyi b/test-data/unit/fixtures/typing-full.pyi index 67fb19349a1bc..baeae9ae795ce 100644 --- a/test-data/unit/fixtures/typing-full.pyi +++ b/test-data/unit/fixtures/typing-full.pyi @@ -267,6 +267,9 @@ class FromUnion(Generic[T]): ... @_type_operator class GetMemberType(Generic[T, U]): ... +@_type_operator +class _TypeGetAttr(Generic[T, U]): ... + @_type_operator class Slice(Generic[T, U, V]): ... diff --git a/test-data/unit/pythoneval.test b/test-data/unit/pythoneval.test index 4cf9233ec1bcd..b646ca11b23da 100644 --- a/test-data/unit/pythoneval.test +++ b/test-data/unit/pythoneval.test @@ -2376,9 +2376,15 @@ reveal_type(creat_members) upd_types: tuple[*[typing.GetType[p] for p in typing.Iter[typing.Members[HeroUpdate]]]] reveal_type(upd_types) + +upd_types_dot: tuple[*[p.type for p in typing.Iter[typing.Members[HeroUpdate]]]] +reveal_type(upd_types) + + [out] _program.py:133: note: Revealed type is "__typelevel__.NewProtocol[id: builtins.int, name: builtins.str, age: builtins.int | None]" _program.py:136: note: Revealed type is "__typelevel__.NewProtocol[name: builtins.str, age: builtins.int | None = None, secret_name: builtins.str]" _program.py:139: note: Revealed type is "__typelevel__.NewProtocol[name: builtins.str | None = None, age: builtins.int | None | None = None, secret_name: builtins.str | None = None]" _program.py:142: note: Revealed type is "tuple[Never, None, Never]" _program.py:145: note: Revealed type is "tuple[builtins.str | None, builtins.int | None | None, builtins.str | None]" +_program.py:148: note: Revealed type is "tuple[builtins.str | None, builtins.int | None | None, builtins.str | None]" From 9b9fefcb086150f67687d228a78a5b7da1fcae6b Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Thu, 12 Feb 2026 11:31:00 -0800 Subject: [PATCH 117/161] Add dict comprehension syntax for type-level computation Desugar {k: v for x in foo} in type context to *[_DictEntry[k, v] for x in foo], where _DictEntry is a new internal type operator that evaluates to Member[name, typ, Never, Never, Never]. This enables concise dict comprehension syntax for constructing TypedDicts and Protocols from member introspection. --- mypy/exprtotype.py | 36 +++++++++++++ mypy/fastparse.py | 39 ++++++++++++++ mypy/typelevel.py | 16 ++++++ mypy/typeshed/stdlib/builtins.pyi | 10 ++++ .../unit/check-typelevel-comprehension.test | 52 +++++++++++++++++++ test-data/unit/fixtures/typelevel.pyi | 4 ++ test-data/unit/pythoneval.test | 11 ++++ 7 files changed, 168 insertions(+) diff --git a/mypy/exprtotype.py b/mypy/exprtotype.py index ff4c613010b64..403cc9db6f069 100644 --- a/mypy/exprtotype.py +++ b/mypy/exprtotype.py @@ -13,6 +13,7 @@ ConditionalExpr, Context, DictExpr, + DictionaryComprehension, EllipsisExpr, Expression, FloatExpr, @@ -344,6 +345,41 @@ def expr_to_unanalyzed_type( ) result.extra_items_from = extra_items_from return result + elif isinstance(expr, DictionaryComprehension): + # Dict comprehension in type context: {k: v for x in foo} + # desugars to *[_DictEntry[k, v] for x in foo] + if len(expr.sequences) != 1: + raise TypeTranslationError() + index = expr.indices[0] + if not isinstance(index, NameExpr): + raise TypeTranslationError() + iter_name = index.name + key_type = expr_to_unanalyzed_type( + expr.key, options, allow_new_syntax, lookup_qualified=lookup_qualified + ) + value_type = expr_to_unanalyzed_type( + expr.value, options, allow_new_syntax, lookup_qualified=lookup_qualified + ) + iter_type = expr_to_unanalyzed_type( + expr.sequences[0], options, allow_new_syntax, lookup_qualified=lookup_qualified + ) + cond_types: list[Type] = [ + expr_to_unanalyzed_type( + cond, options, allow_new_syntax, lookup_qualified=lookup_qualified + ) + for cond in expr.condlists[0] + ] + element_expr = UnboundType( + "__builtins__._DictEntry", [key_type, value_type], line=expr.line, column=expr.column + ) + return TypeForComprehension( + element_expr=element_expr, + iter_name=iter_name, + iter_type=iter_type, + conditions=cond_types, + line=expr.line, + column=expr.column, + ) elif isinstance(expr, ConditionalExpr): # Use __builtins__ so it can be resolved without explicit import diff --git a/mypy/fastparse.py b/mypy/fastparse.py index 282b46490bf90..943f56f7339ac 100644 --- a/mypy/fastparse.py +++ b/mypy/fastparse.py @@ -2155,6 +2155,45 @@ def visit_Dict(self, n: ast3.Dict) -> Type: result.extra_items_from = extra_items_from return result + def visit_DictComp(self, n: ast3.DictComp) -> Type: + """Convert {k: v for x in Iter[T]} to *[_DictEntry[k, v] for x in Iter[T]]. + + Dict comprehensions in type context desugar to unpacked type comprehensions + where each element is a _DictEntry[key, value]. + """ + if len(n.generators) != 1: + return self.invalid_type( + n, note="Type comprehensions only support a single 'for' clause" + ) + + gen = n.generators[0] + + if not isinstance(gen.target, ast3.Name): + return self.invalid_type(n, note="Type comprehension variable must be a simple name") + + iter_name = gen.target.id + key_type = self.visit(n.key) + value_type = self.visit(n.value) + iter_type = self.visit(gen.iter) + conditions = [self.visit(cond) for cond in gen.ifs] + + # Create _DictEntry[k, v] as the element expression + element_expr = UnboundType( + "__builtins__._DictEntry", + [key_type, value_type], + line=self.line, + column=self.convert_column(n.col_offset), + ) + + return TypeForComprehension( + element_expr=element_expr, + iter_name=iter_name, + iter_type=iter_type, + conditions=conditions, + line=self.line, + column=self.convert_column(n.col_offset), + ) + # Attribute(expr value, identifier attr, expr_context ctx) def visit_Attribute(self, n: Attribute) -> Type: before_dot = self.visit(n.value) diff --git a/mypy/typelevel.py b/mypy/typelevel.py index 71c93f8eb4ba7..c7d26bd398d72 100644 --- a/mypy/typelevel.py +++ b/mypy/typelevel.py @@ -389,6 +389,22 @@ def _eval_not(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: return evaluator.literal_bool(not result) +@register_operator("_DictEntry") +def _eval_dict_entry(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: + """Evaluate _DictEntry[name, typ] -> Member[name, typ, Never, Never, Never]. + + This is the internal type operator for dict comprehension syntax in type context. + {k: v for x in foo} desugars to *[_DictEntry[k, v] for x in foo]. + """ + if len(typ.args) != 2: + return UninhabitedType() + + name_type, value_type = typ.args + member_info = evaluator.get_typemap_type("Member") + never = UninhabitedType() + return Instance(member_info.type, [name_type, value_type, never, never, never]) + + @register_operator("Iter") def _eval_iter(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: """Evaluate a type-level iterator (Iter[T]).""" diff --git a/mypy/typeshed/stdlib/builtins.pyi b/mypy/typeshed/stdlib/builtins.pyi index d4399a59f43cc..0e205e5225708 100644 --- a/mypy/typeshed/stdlib/builtins.pyi +++ b/mypy/typeshed/stdlib/builtins.pyi @@ -2272,3 +2272,13 @@ class _Not(Generic[_T]): """ ... + +@_type_operator +class _DictEntry(Generic[_T1, _T2]): + """ + Internal type operator for dict comprehension syntax in type context. + {k: v for x in foo} desugars to *[_DictEntry[k, v] for x in foo]. + _DictEntry[name, typ] evaluates to Member[name, typ, Never, Never, Never]. + """ + + ... diff --git a/test-data/unit/check-typelevel-comprehension.test b/test-data/unit/check-typelevel-comprehension.test index 6fc5aaa693c7b..a8d53ab761843 100644 --- a/test-data/unit/check-typelevel-comprehension.test +++ b/test-data/unit/check-typelevel-comprehension.test @@ -73,3 +73,55 @@ reveal_type(x) # N: Revealed type is "tuple[builtins.list[builtins.int], builti [builtins fixtures/typelevel.pyi] [typing fixtures/typing-full.pyi] + +[case testDictComprehensionNewTypedDict] +# Test dict comprehension in type context with NewTypedDict +from typing import NewTypedDict, Members, TypedDict, Iter, GetName, GetType + +class Person(TypedDict): + name: str + age: int + +# Dict comprehension produces Members, fed into NewTypedDict +TD = NewTypedDict[{GetName[m]: GetType[m] for m in Iter[Members[Person]]}] +x: TD +reveal_type(x) # N: Revealed type is "TypedDict({'name': builtins.str, 'age': builtins.int})" +x = {'name': 'Alice', 'age': 30} + +[builtins fixtures/typelevel.pyi] +[typing fixtures/typing-full.pyi] + +[case testDictComprehensionNewTypedDictWithCondition] +# Test dict comprehension with filtering condition +from typing import NewTypedDict, Members, TypedDict, Iter, IsAssignable, GetName, GetType + +class Person(TypedDict): + name: str + age: int + active: bool + +# Filter to only string fields using dict comprehension +TD = NewTypedDict[{GetName[m]: GetType[m] for m in Iter[Members[Person]] if IsAssignable[GetType[m], str]}] +x: TD +reveal_type(x) # N: Revealed type is "TypedDict({'name': builtins.str})" +x = {'name': 'Alice'} + +[builtins fixtures/typelevel.pyi] +[typing fixtures/typing-full.pyi] + +[case testDictComprehensionFromClass] +# Test dict comprehension with class attributes +from typing import NewTypedDict, Attrs, Iter, GetName, GetType + +class Point: + x: int + y: int + +# Create TypedDict from class attributes using dict comprehension +TD = NewTypedDict[{GetName[m]: GetType[m] for m in Iter[Attrs[Point]]}] +x: TD +reveal_type(x) # N: Revealed type is "TypedDict({'x': builtins.int, 'y': builtins.int})" +x = {'x': 1, 'y': 2} + +[builtins fixtures/typelevel.pyi] +[typing fixtures/typing-full.pyi] diff --git a/test-data/unit/fixtures/typelevel.pyi b/test-data/unit/fixtures/typelevel.pyi index 0b93a12cb1592..f2f53313f6bb4 100644 --- a/test-data/unit/fixtures/typelevel.pyi +++ b/test-data/unit/fixtures/typelevel.pyi @@ -97,3 +97,7 @@ class _Or(Generic[_T, _T2]): ... @type_check_only @_type_operator class _Not(Generic[_T]): ... + +@type_check_only +@_type_operator +class _DictEntry(Generic[_T, _T2]): ... diff --git a/test-data/unit/pythoneval.test b/test-data/unit/pythoneval.test index b646ca11b23da..64f6fe9801da6 100644 --- a/test-data/unit/pythoneval.test +++ b/test-data/unit/pythoneval.test @@ -2380,7 +2380,17 @@ reveal_type(upd_types) upd_types_dot: tuple[*[p.type for p in typing.Iter[typing.Members[HeroUpdate]]]] reveal_type(upd_types) +# Test dict comprehension syntax for NewTypedDict +type HeroDict[T] = typing.NewTypedDict[{ + typing.GetName[p]: typing.GetType[p] + for p in typing.Iter[typing.Attrs[T]] + if not typing.IsAssignable[ + Literal[True], GetFieldItem[typing.GetInit[p], Literal["hidden"]] + ] +}] +hd: HeroDict[Hero] +reveal_type(hd) [out] _program.py:133: note: Revealed type is "__typelevel__.NewProtocol[id: builtins.int, name: builtins.str, age: builtins.int | None]" _program.py:136: note: Revealed type is "__typelevel__.NewProtocol[name: builtins.str, age: builtins.int | None = None, secret_name: builtins.str]" @@ -2388,3 +2398,4 @@ _program.py:139: note: Revealed type is "__typelevel__.NewProtocol[name: builtin _program.py:142: note: Revealed type is "tuple[Never, None, Never]" _program.py:145: note: Revealed type is "tuple[builtins.str | None, builtins.int | None | None, builtins.str | None]" _program.py:148: note: Revealed type is "tuple[builtins.str | None, builtins.int | None | None, builtins.str | None]" +_program.py:160: note: Revealed type is "TypedDict({'id': builtins.int | None, 'name': builtins.str, 'age': builtins.int | None})" From f8a61c59a9ea72f7b098236f34940ab1b9ed9c92 Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Thu, 12 Feb 2026 14:21:56 -0800 Subject: [PATCH 118/161] Restrict _TypeGetAttr to only work on Member instances --- mypy/typelevel.py | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/mypy/typelevel.py b/mypy/typelevel.py index c7d26bd398d72..d90912beae7e9 100644 --- a/mypy/typelevel.py +++ b/mypy/typelevel.py @@ -665,13 +665,27 @@ def _eval_get_member_type(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) @register_operator("_TypeGetAttr") -@lift_over_unions def _eval_type_get_attr(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: - """Evaluate _TypeGetAttr[T, Name] - get attribute type from T. + """Evaluate _TypeGetAttr[T, Name] - get attribute from a Member. - Internal operator for dot notation: T.attr desugars to _TypeGetAttr[T, Literal["attr"]]. - Semantically equivalent to GetMemberType. + Internal operator for dot notation: m.attr desugars to _TypeGetAttr[m, Literal["attr"]]. + Unlike GetMemberType, this only works on Member instances, not arbitrary types. """ + if len(typ.args) != 2: + return UninhabitedType() + + target = evaluator.eval_proper(typ.args[0]) + + member_info = evaluator.get_typemap_type("Member") + if not isinstance(target, Instance) or target.type != member_info.type: + name_type = evaluator.eval_proper(typ.args[1]) + name = extract_literal_string(name_type) + ctx = evaluator.ctx or typ + evaluator.api.fail( + f"Dot notation .{name} requires a Member type, got {target}", ctx, serious=True + ) + return UninhabitedType() + return _eval_get_member_type(evaluator, typ) From bb9947736e1e34158022f722555872af4c2f77b9 Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Thu, 12 Feb 2026 14:30:08 -0800 Subject: [PATCH 119/161] Refactor operator evaluators to take evaluator as keyword-only argument --- mypy/typelevel.py | 93 ++++++++++++++++++++++------------------------- 1 file changed, 43 insertions(+), 50 deletions(-) diff --git a/mypy/typelevel.py b/mypy/typelevel.py index d90912beae7e9..a7bba5882329d 100644 --- a/mypy/typelevel.py +++ b/mypy/typelevel.py @@ -106,7 +106,9 @@ def set_api(self, api: SemanticAnalyzerInterface) -> Iterator[None]: # Registry mapping operator names (not full!) to their evaluation functions -_OPERATOR_EVALUATORS: dict[str, Callable[[TypeLevelEvaluator, TypeOperatorType], Type]] = {} +OperatorFunc = Callable[..., Type] + +_OPERATOR_EVALUATORS: dict[str, OperatorFunc] = {} EXPANSION_ANY = AnyType(TypeOfAny.expansion_stuck) @@ -114,26 +116,17 @@ def set_api(self, api: SemanticAnalyzerInterface) -> Iterator[None]: EXPANSION_OVERFLOW = AnyType(TypeOfAny.from_error) -def register_operator( - name: str, -) -> Callable[ - [Callable[[TypeLevelEvaluator, TypeOperatorType], Type]], - Callable[[TypeLevelEvaluator, TypeOperatorType], Type], -]: +def register_operator(name: str) -> Callable[[OperatorFunc], OperatorFunc]: """Decorator to register an operator evaluator.""" - def decorator( - func: Callable[[TypeLevelEvaluator, TypeOperatorType], Type], - ) -> Callable[[TypeLevelEvaluator, TypeOperatorType], Type]: + def decorator(func: OperatorFunc) -> OperatorFunc: _OPERATOR_EVALUATORS[name] = func return func return decorator -def lift_over_unions( - func: Callable[[TypeLevelEvaluator, TypeOperatorType], Type], -) -> Callable[[TypeLevelEvaluator, TypeOperatorType], Type]: +def lift_over_unions(func: OperatorFunc) -> OperatorFunc: """Decorator that lifts an operator to work over union types. If any argument is a union type, the operator is applied to each @@ -143,7 +136,7 @@ def lift_over_unions( becomes Literal['ac'] | Literal['bc']. """ - def wrapper(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: + def wrapper(typ: TypeOperatorType, *, evaluator: TypeLevelEvaluator) -> Type: # Expand each argument, collecting union alternatives expanded_args: list[list[Type]] = [] for arg in typ.args: @@ -158,13 +151,13 @@ def wrapper(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: # If there's only one combination, just call the function directly if len(combinations) == 1: - return func(evaluator, typ) + return func(typ, evaluator=evaluator) # Apply the operator to each combination results: list[Type] = [] for combo in combinations: new_typ = typ.copy_modified(args=list(combo)) - result = func(evaluator, new_typ) + result = func(new_typ, evaluator=evaluator) # Don't include Never in unions # XXX: or should we get_proper_type again?? if not (isinstance(result, ProperType) and isinstance(result, UninhabitedType)): @@ -244,7 +237,7 @@ def eval_operator(self, typ: TypeOperatorType) -> Type: # Unknown operator - return Any for now return EXPANSION_ANY - return evaluator(self, typ) + return evaluator(typ, evaluator=self) # --- Type construction helpers --- @@ -317,7 +310,7 @@ def _call_by_value(evaluator: TypeLevelEvaluator, typ: Type) -> Type: @register_operator("_Cond") -def _eval_cond(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: +def _eval_cond(typ: TypeOperatorType, *, evaluator: TypeLevelEvaluator) -> Type: """Evaluate _Cond[condition, TrueType, FalseType].""" if len(typ.args) != 3: @@ -337,7 +330,7 @@ def _eval_cond(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: @register_operator("_And") -def _eval_and(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: +def _eval_and(typ: TypeOperatorType, *, evaluator: TypeLevelEvaluator) -> Type: """Evaluate _And[cond1, cond2] - logical AND of type booleans.""" if len(typ.args) != 2: return UninhabitedType() @@ -357,7 +350,7 @@ def _eval_and(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: @register_operator("_Or") -def _eval_or(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: +def _eval_or(typ: TypeOperatorType, *, evaluator: TypeLevelEvaluator) -> Type: """Evaluate _Or[cond1, cond2] - logical OR of type booleans.""" if len(typ.args) != 2: return UninhabitedType() @@ -377,7 +370,7 @@ def _eval_or(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: @register_operator("_Not") -def _eval_not(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: +def _eval_not(typ: TypeOperatorType, *, evaluator: TypeLevelEvaluator) -> Type: """Evaluate _Not[cond] - logical NOT of a type boolean.""" if len(typ.args) != 1: return UninhabitedType() @@ -390,7 +383,7 @@ def _eval_not(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: @register_operator("_DictEntry") -def _eval_dict_entry(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: +def _eval_dict_entry(typ: TypeOperatorType, *, evaluator: TypeLevelEvaluator) -> Type: """Evaluate _DictEntry[name, typ] -> Member[name, typ, Never, Never, Never]. This is the internal type operator for dict comprehension syntax in type context. @@ -406,7 +399,7 @@ def _eval_dict_entry(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Ty @register_operator("Iter") -def _eval_iter(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: +def _eval_iter(typ: TypeOperatorType, *, evaluator: TypeLevelEvaluator) -> Type: """Evaluate a type-level iterator (Iter[T]).""" if len(typ.args) != 1: return UninhabitedType() # ??? @@ -419,7 +412,7 @@ def _eval_iter(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: @register_operator("IsAssignable") -def _eval_issub(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: +def _eval_issub(typ: TypeOperatorType, *, evaluator: TypeLevelEvaluator) -> Type: """Evaluate a type-level condition (IsAssignable[T, Base]).""" if len(typ.args) != 2: @@ -440,7 +433,7 @@ def _eval_issub(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: @register_operator("IsEquivalent") -def _eval_matches(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: +def _eval_matches(typ: TypeOperatorType, *, evaluator: TypeLevelEvaluator) -> Type: """Evaluate IsEquivalent[T, S] - check if T and S are equivalent types. Returns Literal[True] if T is a subtype of S AND S is a subtype of T. @@ -465,7 +458,7 @@ def _eval_matches(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: @register_operator("Bool") -def _eval_bool(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: +def _eval_bool(typ: TypeOperatorType, *, evaluator: TypeLevelEvaluator) -> Type: """Evaluate Bool[T] - check if T contains Literal[True]. Returns Literal[True] if T is Literal[True] or a union containing Literal[True]. @@ -575,7 +568,7 @@ def _get_args(evaluator: TypeLevelEvaluator, target: Type, base: Type) -> Sequen @register_operator("GetArg") @lift_over_unions -def _eval_get_arg(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: +def _eval_get_arg(typ: TypeOperatorType, *, evaluator: TypeLevelEvaluator) -> Type: """Evaluate GetArg[T, Base, Idx] - get type argument at index from T as Base.""" if len(typ.args) != 3: return UninhabitedType() @@ -600,7 +593,7 @@ def _eval_get_arg(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: @register_operator("GetArgs") @lift_over_unions -def _eval_get_args(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: +def _eval_get_args(typ: TypeOperatorType, *, evaluator: TypeLevelEvaluator) -> Type: """Evaluate GetArgs[T, Base] -> tuple of all type args from T as Base.""" if len(typ.args) != 2: return UninhabitedType() @@ -613,7 +606,7 @@ def _eval_get_args(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type @register_operator("FromUnion") -def _eval_from_union(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: +def _eval_from_union(typ: TypeOperatorType, *, evaluator: TypeLevelEvaluator) -> Type: """Evaluate FromUnion[T] -> tuple of union elements.""" if len(typ.args) != 1: return UninhabitedType() @@ -628,7 +621,7 @@ def _eval_from_union(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Ty @register_operator("_NewUnion") -def _eval_new_union(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: +def _eval_new_union(typ: TypeOperatorType, *, evaluator: TypeLevelEvaluator) -> Type: """Evaluate _NewUnion[*Ts] -> union of all type arguments.""" args = evaluator.flatten_args(typ.args) return make_simplified_union(args) @@ -636,7 +629,7 @@ def _eval_new_union(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Typ @register_operator("GetMemberType") @lift_over_unions -def _eval_get_member_type(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: +def _eval_get_member_type(typ: TypeOperatorType, *, evaluator: TypeLevelEvaluator) -> Type: """Evaluate GetMemberType[T, Name] - get attribute type from T.""" if len(typ.args) != 2: return UninhabitedType() @@ -665,7 +658,7 @@ def _eval_get_member_type(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) @register_operator("_TypeGetAttr") -def _eval_type_get_attr(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: +def _eval_type_get_attr(typ: TypeOperatorType, *, evaluator: TypeLevelEvaluator) -> Type: """Evaluate _TypeGetAttr[T, Name] - get attribute from a Member. Internal operator for dot notation: m.attr desugars to _TypeGetAttr[m, Literal["attr"]]. @@ -686,12 +679,12 @@ def _eval_type_get_attr(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> ) return UninhabitedType() - return _eval_get_member_type(evaluator, typ) + return _eval_get_member_type(typ, evaluator=evaluator) @register_operator("Slice") @lift_over_unions -def _eval_slice(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: +def _eval_slice(typ: TypeOperatorType, *, evaluator: TypeLevelEvaluator) -> Type: """Evaluate Slice[S, Start, End] - slice a literal string or tuple type.""" if len(typ.args) != 3: return UninhabitedType() @@ -732,7 +725,7 @@ def _eval_slice(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: @register_operator("Concat") @lift_over_unions -def _eval_concat(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: +def _eval_concat(typ: TypeOperatorType, *, evaluator: TypeLevelEvaluator) -> Type: """Evaluate Concat[S1, S2] - concatenate two literal strings.""" if len(typ.args) != 2: return UninhabitedType() @@ -748,7 +741,7 @@ def _eval_concat(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: @register_operator("Uppercase") @lift_over_unions -def _eval_uppercase(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: +def _eval_uppercase(typ: TypeOperatorType, *, evaluator: TypeLevelEvaluator) -> Type: """Evaluate Uppercase[S] - convert literal string to uppercase.""" if len(typ.args) != 1: return UninhabitedType() @@ -762,7 +755,7 @@ def _eval_uppercase(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Typ @register_operator("Lowercase") @lift_over_unions -def _eval_lowercase(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: +def _eval_lowercase(typ: TypeOperatorType, *, evaluator: TypeLevelEvaluator) -> Type: """Evaluate Lowercase[S] - convert literal string to lowercase.""" if len(typ.args) != 1: return UninhabitedType() @@ -776,7 +769,7 @@ def _eval_lowercase(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Typ @register_operator("Capitalize") @lift_over_unions -def _eval_capitalize(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: +def _eval_capitalize(typ: TypeOperatorType, *, evaluator: TypeLevelEvaluator) -> Type: """Evaluate Capitalize[S] - capitalize first character of literal string.""" if len(typ.args) != 1: return UninhabitedType() @@ -790,7 +783,7 @@ def _eval_capitalize(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Ty @register_operator("Uncapitalize") @lift_over_unions -def _eval_uncapitalize(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: +def _eval_uncapitalize(typ: TypeOperatorType, *, evaluator: TypeLevelEvaluator) -> Type: """Evaluate Uncapitalize[S] - lowercase first character of literal string.""" if len(typ.args) != 1: return UninhabitedType() @@ -805,26 +798,26 @@ def _eval_uncapitalize(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> @register_operator("Members") @lift_over_unions -def _eval_members(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: +def _eval_members(typ: TypeOperatorType, *, evaluator: TypeLevelEvaluator) -> Type: """Evaluate Members[T] -> tuple of Member types for all members of T. Includes methods, class variables, and instance attributes. """ - return _eval_members_impl(evaluator, typ, attrs_only=False) + return _eval_members_impl(typ, evaluator=evaluator, attrs_only=False) @register_operator("Attrs") @lift_over_unions -def _eval_attrs(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: +def _eval_attrs(typ: TypeOperatorType, *, evaluator: TypeLevelEvaluator) -> Type: """Evaluate Attrs[T] -> tuple of Member types for annotated attributes only. Excludes methods but includes ClassVar members. """ - return _eval_members_impl(evaluator, typ, attrs_only=True) + return _eval_members_impl(typ, evaluator=evaluator, attrs_only=True) def _eval_members_impl( - evaluator: TypeLevelEvaluator, typ: TypeOperatorType, *, attrs_only: bool + typ: TypeOperatorType, *, evaluator: TypeLevelEvaluator, attrs_only: bool ) -> Type: """Common implementation for Members and Attrs operators. @@ -842,7 +835,7 @@ def _eval_members_impl( # Handle TypedDict if isinstance(target, TypedDictType): - return _eval_typeddict_members(evaluator, target, member_info.type) + return _eval_typeddict_members(target, member_info.type, evaluator=evaluator) if not isinstance(target, Instance): return UninhabitedType() @@ -893,7 +886,7 @@ def _eval_members_impl( def _eval_typeddict_members( - evaluator: TypeLevelEvaluator, target: TypedDictType, member_type_info: TypeInfo + target: TypedDictType, member_type_info: TypeInfo, *, evaluator: TypeLevelEvaluator ) -> Type: """Evaluate Members/Attrs for a TypedDict type.""" members: list[Type] = [] @@ -967,7 +960,7 @@ def create_member_type( @register_operator("NewTypedDict") -def _eval_new_typeddict(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: +def _eval_new_typeddict(typ: TypeOperatorType, *, evaluator: TypeLevelEvaluator) -> Type: """Evaluate NewTypedDict[*Members] -> create a new TypedDict from Member types. This is the inverse of Members[TypedDict]. @@ -1054,7 +1047,7 @@ def _proto_str(map: dict[str, tuple[Type, Type, bool, bool]]) -> str: @register_operator("NewProtocol") -def _eval_new_protocol(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: +def _eval_new_protocol(typ: TypeOperatorType, *, evaluator: TypeLevelEvaluator) -> Type: """Evaluate NewProtocol[*Members] -> create a new structural protocol type. This creates a synthetic protocol class with members defined by the Member arguments. @@ -1162,7 +1155,7 @@ def _eval_new_protocol(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> @register_operator("Length") @lift_over_unions -def _eval_length(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: +def _eval_length(typ: TypeOperatorType, *, evaluator: TypeLevelEvaluator) -> Type: """Evaluate Length[T] -> Literal[int] for tuple length.""" if len(typ.args) != 1: return UninhabitedType() @@ -1185,7 +1178,7 @@ def _eval_length(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: @register_operator("RaiseError") -def _eval_raise_error(evaluator: TypeLevelEvaluator, typ: TypeOperatorType) -> Type: +def _eval_raise_error(typ: TypeOperatorType, *, evaluator: TypeLevelEvaluator) -> Type: """Evaluate RaiseError[S] -> emit a type error with message S. RaiseError is used to emit custom type errors during type-level computation. From 9a44c1bd0ac86fd4ea94d0f88edc775ff4220cc0 Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Thu, 12 Feb 2026 14:56:46 -0800 Subject: [PATCH 120/161] Refactor operator evaluators to take unpacked arguments Centralize argument count validation in the dispatch instead of repeating len(typ.args) checks in every operator. Each operator now receives its arguments as individual positional parameters, with the registry inspecting function signatures to auto-detect arity. --- mypy/typelevel.py | 302 +++++++++++++++++++++------------------------- 1 file changed, 138 insertions(+), 164 deletions(-) diff --git a/mypy/typelevel.py b/mypy/typelevel.py index a7bba5882329d..866e520019de0 100644 --- a/mypy/typelevel.py +++ b/mypy/typelevel.py @@ -9,9 +9,12 @@ from __future__ import annotations +import functools +import inspect import itertools from collections.abc import Callable, Iterator, Sequence from contextlib import contextmanager +from dataclasses import dataclass from typing import TYPE_CHECKING, Final from mypy.expandtype import expand_type, expand_type_by_instance @@ -108,7 +111,16 @@ def set_api(self, api: SemanticAnalyzerInterface) -> Iterator[None]: # Registry mapping operator names (not full!) to their evaluation functions OperatorFunc = Callable[..., Type] -_OPERATOR_EVALUATORS: dict[str, OperatorFunc] = {} + +@dataclass(frozen=True) +class OperatorInfo: + """Metadata about a registered operator evaluator.""" + + func: OperatorFunc + expected_argc: int | None # None for variadic + + +_OPERATOR_EVALUATORS: dict[str, OperatorInfo] = {} EXPANSION_ANY = AnyType(TypeOfAny.expansion_stuck) @@ -117,10 +129,24 @@ def set_api(self, api: SemanticAnalyzerInterface) -> Iterator[None]: def register_operator(name: str) -> Callable[[OperatorFunc], OperatorFunc]: - """Decorator to register an operator evaluator.""" + """Decorator to register an operator evaluator. + + The function signature is inspected to determine the expected number of + positional arguments (excluding the keyword-only ``evaluator`` parameter). + If a ``*args`` parameter is found, the operator is treated as variadic. + """ def decorator(func: OperatorFunc) -> OperatorFunc: - _OPERATOR_EVALUATORS[name] = func + # inspect.signature follows __wrapped__ set by functools.wraps, + # so this sees through the lift_over_unions wrapper automatically. + sig = inspect.signature(func) + argc = ( + None + if any(p.kind == inspect.Parameter.VAR_POSITIONAL for p in sig.parameters.values()) + else len(sig.parameters) - 1 + ) + + _OPERATOR_EVALUATORS[name] = OperatorInfo(func=func, expected_argc=argc) return func return decorator @@ -134,32 +160,29 @@ def lift_over_unions(func: OperatorFunc) -> OperatorFunc: For example, Concat[Literal['a'] | Literal['b'], Literal['c']] becomes Literal['ac'] | Literal['bc']. + + Works at the unpacked-args level: the wrapped function receives individual + ``Type`` positional arguments plus a keyword-only ``evaluator``. """ - def wrapper(typ: TypeOperatorType, *, evaluator: TypeLevelEvaluator) -> Type: - # Expand each argument, collecting union alternatives - expanded_args: list[list[Type]] = [] - for arg in typ.args: + @functools.wraps(func) + def wrapper(*args: Type, evaluator: TypeLevelEvaluator) -> Type: + expanded: list[list[Type]] = [] + for arg in args: proper = get_proper_type(arg) if isinstance(proper, UnionType): - expanded_args.append(list(proper.items)) + expanded.append(list(proper.items)) else: - expanded_args.append([arg]) + expanded.append([arg]) - # Generate all combinations - combinations = list(itertools.product(*expanded_args)) + combinations = list(itertools.product(*expanded)) - # If there's only one combination, just call the function directly if len(combinations) == 1: - return func(typ, evaluator=evaluator) + return func(*args, evaluator=evaluator) - # Apply the operator to each combination results: list[Type] = [] for combo in combinations: - new_typ = typ.copy_modified(args=list(combo)) - result = func(new_typ, evaluator=evaluator) - # Don't include Never in unions - # XXX: or should we get_proper_type again?? + result = func(*combo, evaluator=evaluator) if not (isinstance(result, ProperType) and isinstance(result, UninhabitedType)): results.append(result) @@ -183,9 +206,17 @@ def __init__(self, api: SemanticAnalyzerInterface, ctx: Context | None) -> None: self.api = api self.ctx = ctx self.depth = 0 + self._current_op: TypeOperatorType | None = None self.cache: dict[Type, Type] = {} + @property + def error_ctx(self) -> Context: + """Get the best available error context for reporting.""" + ctx = self.ctx or self._current_op + assert ctx is not None, "No error context available" + return ctx + def evaluate(self, typ: Type) -> Type: """Main entry point: evaluate a type to its simplified form.""" @@ -229,15 +260,23 @@ def eval_proper(self, typ: Type) -> ProperType: def eval_operator(self, typ: TypeOperatorType) -> Type: """Evaluate a type operator by dispatching to registered handler.""" - evaluator = _OPERATOR_EVALUATORS.get(typ.type.name) - - if evaluator is None: - # print("NO EVALUATOR", fullname) + name = typ.type.name - # Unknown operator - return Any for now + info = _OPERATOR_EVALUATORS.get(name) + if info is None: return EXPANSION_ANY - return evaluator(typ, evaluator=self) + old_op = self._current_op + self._current_op = typ + try: + args = typ.args + if info.expected_argc is None: + args = self.flatten_args(args) + elif len(args) != info.expected_argc: + return UninhabitedType() + return info.func(*args, evaluator=self) + finally: + self._current_op = old_op # --- Type construction helpers --- @@ -310,13 +349,10 @@ def _call_by_value(evaluator: TypeLevelEvaluator, typ: Type) -> Type: @register_operator("_Cond") -def _eval_cond(typ: TypeOperatorType, *, evaluator: TypeLevelEvaluator) -> Type: +def _eval_cond( + condition: Type, true_type: Type, false_type: Type, *, evaluator: TypeLevelEvaluator +) -> Type: """Evaluate _Cond[condition, TrueType, FalseType].""" - - if len(typ.args) != 3: - return UninhabitedType() - - condition, true_type, false_type = typ.args result = extract_literal_bool(evaluator.eval_proper(condition)) if result is True: @@ -330,19 +366,16 @@ def _eval_cond(typ: TypeOperatorType, *, evaluator: TypeLevelEvaluator) -> Type: @register_operator("_And") -def _eval_and(typ: TypeOperatorType, *, evaluator: TypeLevelEvaluator) -> Type: +def _eval_and(left_arg: Type, right_arg: Type, *, evaluator: TypeLevelEvaluator) -> Type: """Evaluate _And[cond1, cond2] - logical AND of type booleans.""" - if len(typ.args) != 2: - return UninhabitedType() - - left = extract_literal_bool(evaluator.eval_proper(typ.args[0])) + left = extract_literal_bool(evaluator.eval_proper(left_arg)) if left is False: # Short-circuit: False and X = False return evaluator.literal_bool(False) if left is None: return UninhabitedType() - right = extract_literal_bool(evaluator.eval_proper(typ.args[1])) + right = extract_literal_bool(evaluator.eval_proper(right_arg)) if right is None: return UninhabitedType() @@ -350,19 +383,16 @@ def _eval_and(typ: TypeOperatorType, *, evaluator: TypeLevelEvaluator) -> Type: @register_operator("_Or") -def _eval_or(typ: TypeOperatorType, *, evaluator: TypeLevelEvaluator) -> Type: +def _eval_or(left_arg: Type, right_arg: Type, *, evaluator: TypeLevelEvaluator) -> Type: """Evaluate _Or[cond1, cond2] - logical OR of type booleans.""" - if len(typ.args) != 2: - return UninhabitedType() - - left = extract_literal_bool(evaluator.eval_proper(typ.args[0])) + left = extract_literal_bool(evaluator.eval_proper(left_arg)) if left is True: # Short-circuit: True or X = True return evaluator.literal_bool(True) if left is None: return UninhabitedType() - right = extract_literal_bool(evaluator.eval_proper(typ.args[1])) + right = extract_literal_bool(evaluator.eval_proper(right_arg)) if right is None: return UninhabitedType() @@ -370,12 +400,9 @@ def _eval_or(typ: TypeOperatorType, *, evaluator: TypeLevelEvaluator) -> Type: @register_operator("_Not") -def _eval_not(typ: TypeOperatorType, *, evaluator: TypeLevelEvaluator) -> Type: +def _eval_not(arg: Type, *, evaluator: TypeLevelEvaluator) -> Type: """Evaluate _Not[cond] - logical NOT of a type boolean.""" - if len(typ.args) != 1: - return UninhabitedType() - - result = extract_literal_bool(evaluator.eval_proper(typ.args[0])) + result = extract_literal_bool(evaluator.eval_proper(arg)) if result is None: return UninhabitedType() @@ -383,28 +410,21 @@ def _eval_not(typ: TypeOperatorType, *, evaluator: TypeLevelEvaluator) -> Type: @register_operator("_DictEntry") -def _eval_dict_entry(typ: TypeOperatorType, *, evaluator: TypeLevelEvaluator) -> Type: +def _eval_dict_entry(name_type: Type, value_type: Type, *, evaluator: TypeLevelEvaluator) -> Type: """Evaluate _DictEntry[name, typ] -> Member[name, typ, Never, Never, Never]. This is the internal type operator for dict comprehension syntax in type context. {k: v for x in foo} desugars to *[_DictEntry[k, v] for x in foo]. """ - if len(typ.args) != 2: - return UninhabitedType() - - name_type, value_type = typ.args member_info = evaluator.get_typemap_type("Member") never = UninhabitedType() return Instance(member_info.type, [name_type, value_type, never, never, never]) @register_operator("Iter") -def _eval_iter(typ: TypeOperatorType, *, evaluator: TypeLevelEvaluator) -> Type: +def _eval_iter(arg: Type, *, evaluator: TypeLevelEvaluator) -> Type: """Evaluate a type-level iterator (Iter[T]).""" - if len(typ.args) != 1: - return UninhabitedType() # ??? - - target = evaluator.eval_proper(typ.args[0]) + target = evaluator.eval_proper(arg) if isinstance(target, TupleType): return target else: @@ -412,14 +432,8 @@ def _eval_iter(typ: TypeOperatorType, *, evaluator: TypeLevelEvaluator) -> Type: @register_operator("IsAssignable") -def _eval_issub(typ: TypeOperatorType, *, evaluator: TypeLevelEvaluator) -> Type: +def _eval_issub(lhs: Type, rhs: Type, *, evaluator: TypeLevelEvaluator) -> Type: """Evaluate a type-level condition (IsAssignable[T, Base]).""" - - if len(typ.args) != 2: - return UninhabitedType() - - lhs, rhs = typ.args - left_proper = evaluator.eval_proper(lhs) right_proper = evaluator.eval_proper(rhs) @@ -433,17 +447,12 @@ def _eval_issub(typ: TypeOperatorType, *, evaluator: TypeLevelEvaluator) -> Type @register_operator("IsEquivalent") -def _eval_matches(typ: TypeOperatorType, *, evaluator: TypeLevelEvaluator) -> Type: +def _eval_matches(lhs: Type, rhs: Type, *, evaluator: TypeLevelEvaluator) -> Type: """Evaluate IsEquivalent[T, S] - check if T and S are equivalent types. Returns Literal[True] if T is a subtype of S AND S is a subtype of T. Equivalent to: IsAssignable[T, S] and IsAssignable[S, T] """ - if len(typ.args) != 2: - return UninhabitedType() - - lhs, rhs = typ.args - left_proper = evaluator.eval_proper(lhs) right_proper = evaluator.eval_proper(rhs) @@ -458,16 +467,13 @@ def _eval_matches(typ: TypeOperatorType, *, evaluator: TypeLevelEvaluator) -> Ty @register_operator("Bool") -def _eval_bool(typ: TypeOperatorType, *, evaluator: TypeLevelEvaluator) -> Type: +def _eval_bool(arg: Type, *, evaluator: TypeLevelEvaluator) -> Type: """Evaluate Bool[T] - check if T contains Literal[True]. Returns Literal[True] if T is Literal[True] or a union containing Literal[True]. Equivalent to: IsAssignable[Literal[True], T] and not IsAssignable[T, Never] """ - if len(typ.args) != 1: - return UninhabitedType() - - arg_proper = evaluator.eval_proper(typ.args[0]) + arg_proper = evaluator.eval_proper(arg) # Check if Literal[True] is a subtype of arg (i.e., arg contains True) # and arg is not Never @@ -568,18 +574,17 @@ def _get_args(evaluator: TypeLevelEvaluator, target: Type, base: Type) -> Sequen @register_operator("GetArg") @lift_over_unions -def _eval_get_arg(typ: TypeOperatorType, *, evaluator: TypeLevelEvaluator) -> Type: +def _eval_get_arg( + target: Type, base: Type, index_arg: Type, *, evaluator: TypeLevelEvaluator +) -> Type: """Evaluate GetArg[T, Base, Idx] - get type argument at index from T as Base.""" - if len(typ.args) != 3: - return UninhabitedType() - - args = _get_args(evaluator, typ.args[0], typ.args[1]) + args = _get_args(evaluator, target, base) if args is None: return UninhabitedType() # Extract index as int - index = extract_literal_int(evaluator.eval_proper(typ.args[2])) + index = extract_literal_int(evaluator.eval_proper(index_arg)) if index is None: return UninhabitedType() # Can't evaluate without literal index @@ -593,12 +598,9 @@ def _eval_get_arg(typ: TypeOperatorType, *, evaluator: TypeLevelEvaluator) -> Ty @register_operator("GetArgs") @lift_over_unions -def _eval_get_args(typ: TypeOperatorType, *, evaluator: TypeLevelEvaluator) -> Type: +def _eval_get_args(target: Type, base: Type, *, evaluator: TypeLevelEvaluator) -> Type: """Evaluate GetArgs[T, Base] -> tuple of all type args from T as Base.""" - if len(typ.args) != 2: - return UninhabitedType() - - args = _get_args(evaluator, typ.args[0], typ.args[1]) + args = _get_args(evaluator, target, base) if args is None: return UninhabitedType() @@ -606,12 +608,9 @@ def _eval_get_args(typ: TypeOperatorType, *, evaluator: TypeLevelEvaluator) -> T @register_operator("FromUnion") -def _eval_from_union(typ: TypeOperatorType, *, evaluator: TypeLevelEvaluator) -> Type: +def _eval_from_union(arg: Type, *, evaluator: TypeLevelEvaluator) -> Type: """Evaluate FromUnion[T] -> tuple of union elements.""" - if len(typ.args) != 1: - return UninhabitedType() - - target = evaluator.eval_proper(typ.args[0]) + target = evaluator.eval_proper(arg) if isinstance(target, UnionType): return evaluator.tuple_type(list(target.items)) @@ -621,21 +620,19 @@ def _eval_from_union(typ: TypeOperatorType, *, evaluator: TypeLevelEvaluator) -> @register_operator("_NewUnion") -def _eval_new_union(typ: TypeOperatorType, *, evaluator: TypeLevelEvaluator) -> Type: +def _eval_new_union(*args: Type, evaluator: TypeLevelEvaluator) -> Type: """Evaluate _NewUnion[*Ts] -> union of all type arguments.""" - args = evaluator.flatten_args(typ.args) - return make_simplified_union(args) + return make_simplified_union(list(args)) @register_operator("GetMemberType") @lift_over_unions -def _eval_get_member_type(typ: TypeOperatorType, *, evaluator: TypeLevelEvaluator) -> Type: +def _eval_get_member_type( + target_arg: Type, name_arg: Type, *, evaluator: TypeLevelEvaluator +) -> Type: """Evaluate GetMemberType[T, Name] - get attribute type from T.""" - if len(typ.args) != 2: - return UninhabitedType() - - target = evaluator.eval_proper(typ.args[0]) - name_type = evaluator.eval_proper(typ.args[1]) + target = evaluator.eval_proper(target_arg) + name_type = evaluator.eval_proper(name_arg) name = extract_literal_string(name_type) if name is None: @@ -658,41 +655,41 @@ def _eval_get_member_type(typ: TypeOperatorType, *, evaluator: TypeLevelEvaluato @register_operator("_TypeGetAttr") -def _eval_type_get_attr(typ: TypeOperatorType, *, evaluator: TypeLevelEvaluator) -> Type: +def _eval_type_get_attr( + target_arg: Type, name_arg: Type, *, evaluator: TypeLevelEvaluator +) -> Type: """Evaluate _TypeGetAttr[T, Name] - get attribute from a Member. Internal operator for dot notation: m.attr desugars to _TypeGetAttr[m, Literal["attr"]]. Unlike GetMemberType, this only works on Member instances, not arbitrary types. """ - if len(typ.args) != 2: - return UninhabitedType() - - target = evaluator.eval_proper(typ.args[0]) + target = evaluator.eval_proper(target_arg) member_info = evaluator.get_typemap_type("Member") if not isinstance(target, Instance) or target.type != member_info.type: - name_type = evaluator.eval_proper(typ.args[1]) + name_type = evaluator.eval_proper(name_arg) name = extract_literal_string(name_type) - ctx = evaluator.ctx or typ evaluator.api.fail( - f"Dot notation .{name} requires a Member type, got {target}", ctx, serious=True + f"Dot notation .{name} requires a Member type, got {target}", + evaluator.error_ctx, + serious=True, ) return UninhabitedType() - return _eval_get_member_type(typ, evaluator=evaluator) + # Direct call bypasses union lifting, which is correct for Member access + return _eval_get_member_type(target_arg, name_arg, evaluator=evaluator) @register_operator("Slice") @lift_over_unions -def _eval_slice(typ: TypeOperatorType, *, evaluator: TypeLevelEvaluator) -> Type: +def _eval_slice( + target_arg: Type, start_arg: Type, end_arg: Type, *, evaluator: TypeLevelEvaluator +) -> Type: """Evaluate Slice[S, Start, End] - slice a literal string or tuple type.""" - if len(typ.args) != 3: - return UninhabitedType() - - target = evaluator.eval_proper(typ.args[0]) + target = evaluator.eval_proper(target_arg) # Handle start - can be int or None - start_type = evaluator.eval_proper(typ.args[1]) + start_type = evaluator.eval_proper(start_arg) if isinstance(start_type, NoneType): start: int | None = None else: @@ -701,7 +698,7 @@ def _eval_slice(typ: TypeOperatorType, *, evaluator: TypeLevelEvaluator) -> Type return UninhabitedType() # Handle end - can be int or None - end_type = evaluator.eval_proper(typ.args[2]) + end_type = evaluator.eval_proper(end_arg) if isinstance(end_type, NoneType): end: int | None = None else: @@ -725,13 +722,10 @@ def _eval_slice(typ: TypeOperatorType, *, evaluator: TypeLevelEvaluator) -> Type @register_operator("Concat") @lift_over_unions -def _eval_concat(typ: TypeOperatorType, *, evaluator: TypeLevelEvaluator) -> Type: +def _eval_concat(left_arg: Type, right_arg: Type, *, evaluator: TypeLevelEvaluator) -> Type: """Evaluate Concat[S1, S2] - concatenate two literal strings.""" - if len(typ.args) != 2: - return UninhabitedType() - - left = extract_literal_string(evaluator.eval_proper(typ.args[0])) - right = extract_literal_string(evaluator.eval_proper(typ.args[1])) + left = extract_literal_string(evaluator.eval_proper(left_arg)) + right = extract_literal_string(evaluator.eval_proper(right_arg)) if left is not None and right is not None: return evaluator.literal_str(left + right) @@ -741,12 +735,9 @@ def _eval_concat(typ: TypeOperatorType, *, evaluator: TypeLevelEvaluator) -> Typ @register_operator("Uppercase") @lift_over_unions -def _eval_uppercase(typ: TypeOperatorType, *, evaluator: TypeLevelEvaluator) -> Type: +def _eval_uppercase(arg: Type, *, evaluator: TypeLevelEvaluator) -> Type: """Evaluate Uppercase[S] - convert literal string to uppercase.""" - if len(typ.args) != 1: - return UninhabitedType() - - s = extract_literal_string(evaluator.eval_proper(typ.args[0])) + s = extract_literal_string(evaluator.eval_proper(arg)) if s is not None: return evaluator.literal_str(s.upper()) @@ -755,12 +746,9 @@ def _eval_uppercase(typ: TypeOperatorType, *, evaluator: TypeLevelEvaluator) -> @register_operator("Lowercase") @lift_over_unions -def _eval_lowercase(typ: TypeOperatorType, *, evaluator: TypeLevelEvaluator) -> Type: +def _eval_lowercase(arg: Type, *, evaluator: TypeLevelEvaluator) -> Type: """Evaluate Lowercase[S] - convert literal string to lowercase.""" - if len(typ.args) != 1: - return UninhabitedType() - - s = extract_literal_string(evaluator.eval_proper(typ.args[0])) + s = extract_literal_string(evaluator.eval_proper(arg)) if s is not None: return evaluator.literal_str(s.lower()) @@ -769,12 +757,9 @@ def _eval_lowercase(typ: TypeOperatorType, *, evaluator: TypeLevelEvaluator) -> @register_operator("Capitalize") @lift_over_unions -def _eval_capitalize(typ: TypeOperatorType, *, evaluator: TypeLevelEvaluator) -> Type: +def _eval_capitalize(arg: Type, *, evaluator: TypeLevelEvaluator) -> Type: """Evaluate Capitalize[S] - capitalize first character of literal string.""" - if len(typ.args) != 1: - return UninhabitedType() - - s = extract_literal_string(evaluator.eval_proper(typ.args[0])) + s = extract_literal_string(evaluator.eval_proper(arg)) if s is not None: return evaluator.literal_str(s.capitalize()) @@ -783,12 +768,9 @@ def _eval_capitalize(typ: TypeOperatorType, *, evaluator: TypeLevelEvaluator) -> @register_operator("Uncapitalize") @lift_over_unions -def _eval_uncapitalize(typ: TypeOperatorType, *, evaluator: TypeLevelEvaluator) -> Type: +def _eval_uncapitalize(arg: Type, *, evaluator: TypeLevelEvaluator) -> Type: """Evaluate Uncapitalize[S] - lowercase first character of literal string.""" - if len(typ.args) != 1: - return UninhabitedType() - - s = extract_literal_string(evaluator.eval_proper(typ.args[0])) + s = extract_literal_string(evaluator.eval_proper(arg)) if s is not None: result = s[0].lower() + s[1:] if s else s return evaluator.literal_str(result) @@ -798,26 +780,26 @@ def _eval_uncapitalize(typ: TypeOperatorType, *, evaluator: TypeLevelEvaluator) @register_operator("Members") @lift_over_unions -def _eval_members(typ: TypeOperatorType, *, evaluator: TypeLevelEvaluator) -> Type: +def _eval_members(target: Type, *, evaluator: TypeLevelEvaluator) -> Type: """Evaluate Members[T] -> tuple of Member types for all members of T. Includes methods, class variables, and instance attributes. """ - return _eval_members_impl(typ, evaluator=evaluator, attrs_only=False) + return _eval_members_impl(target, evaluator=evaluator, attrs_only=False) @register_operator("Attrs") @lift_over_unions -def _eval_attrs(typ: TypeOperatorType, *, evaluator: TypeLevelEvaluator) -> Type: +def _eval_attrs(target: Type, *, evaluator: TypeLevelEvaluator) -> Type: """Evaluate Attrs[T] -> tuple of Member types for annotated attributes only. Excludes methods but includes ClassVar members. """ - return _eval_members_impl(typ, evaluator=evaluator, attrs_only=True) + return _eval_members_impl(target, evaluator=evaluator, attrs_only=True) def _eval_members_impl( - typ: TypeOperatorType, *, evaluator: TypeLevelEvaluator, attrs_only: bool + target_arg: Type, *, evaluator: TypeLevelEvaluator, attrs_only: bool ) -> Type: """Common implementation for Members and Attrs operators. @@ -825,10 +807,7 @@ def _eval_members_impl( attrs_only: If True, filter to attributes only (excludes methods). If False, include all members. """ - if len(typ.args) != 1: - return UninhabitedType() - - target = evaluator.eval_proper(typ.args[0]) + target = evaluator.eval_proper(target_arg) # Get the Member TypeInfo member_info = evaluator.get_typemap_type("Member") @@ -960,7 +939,7 @@ def create_member_type( @register_operator("NewTypedDict") -def _eval_new_typeddict(typ: TypeOperatorType, *, evaluator: TypeLevelEvaluator) -> Type: +def _eval_new_typeddict(*args: Type, evaluator: TypeLevelEvaluator) -> Type: """Evaluate NewTypedDict[*Members] -> create a new TypedDict from Member types. This is the inverse of Members[TypedDict]. @@ -972,7 +951,7 @@ def _eval_new_typeddict(typ: TypeOperatorType, *, evaluator: TypeLevelEvaluator) required_keys: set[str] = set() readonly_keys: set[str] = set() - for arg in evaluator.flatten_args(typ.args): + for arg in args: arg = get_proper_type(arg) # Each argument should be a Member[name, typ, quals, init, definer] @@ -1047,7 +1026,7 @@ def _proto_str(map: dict[str, tuple[Type, Type, bool, bool]]) -> str: @register_operator("NewProtocol") -def _eval_new_protocol(typ: TypeOperatorType, *, evaluator: TypeLevelEvaluator) -> Type: +def _eval_new_protocol(*args: Type, evaluator: TypeLevelEvaluator) -> Type: """Evaluate NewProtocol[*Members] -> create a new structural protocol type. This creates a synthetic protocol class with members defined by the Member arguments. @@ -1071,7 +1050,7 @@ def _eval_new_protocol(typ: TypeOperatorType, *, evaluator: TypeLevelEvaluator) {} ) # name -> (type, init_type, is_classvar, is_final) - for arg in evaluator.flatten_args(typ.args): + for arg in args: arg = get_proper_type(arg) # Each argument should be a Member[name, typ, quals, init, definer] @@ -1155,12 +1134,9 @@ def _eval_new_protocol(typ: TypeOperatorType, *, evaluator: TypeLevelEvaluator) @register_operator("Length") @lift_over_unions -def _eval_length(typ: TypeOperatorType, *, evaluator: TypeLevelEvaluator) -> Type: +def _eval_length(arg: Type, *, evaluator: TypeLevelEvaluator) -> Type: """Evaluate Length[T] -> Literal[int] for tuple length.""" - if len(typ.args) != 1: - return UninhabitedType() - - target = evaluator.eval_proper(typ.args[0]) + target = evaluator.eval_proper(arg) if isinstance(target, TupleType): # Need to evaluate the elements before we inspect them @@ -1178,7 +1154,7 @@ def _eval_length(typ: TypeOperatorType, *, evaluator: TypeLevelEvaluator) -> Typ @register_operator("RaiseError") -def _eval_raise_error(typ: TypeOperatorType, *, evaluator: TypeLevelEvaluator) -> Type: +def _eval_raise_error(*args: Type, evaluator: TypeLevelEvaluator) -> Type: """Evaluate RaiseError[S] -> emit a type error with message S. RaiseError is used to emit custom type errors during type-level computation. @@ -1186,7 +1162,6 @@ def _eval_raise_error(typ: TypeOperatorType, *, evaluator: TypeLevelEvaluator) - Returns Never after emitting the error. """ - args = evaluator.flatten_args(typ.args) if not args: msg = "RaiseError called without arguments!" else: @@ -1196,10 +1171,9 @@ def _eval_raise_error(typ: TypeOperatorType, *, evaluator: TypeLevelEvaluator) - msg += ": " + ", ".join(str(t) for t in args[1:]) # TODO: We could also print a stack trace? - ctx = evaluator.ctx or typ # Use serious=True to bypass in_checked_function() check which requires # self.options to be set on the SemanticAnalyzer - evaluator.api.fail(msg, ctx, serious=True) + evaluator.api.fail(msg, evaluator.error_ctx, serious=True) return UninhabitedType() From 3323c4b21690890415d7d7c82f609853cf21561a Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Thu, 12 Feb 2026 17:03:38 -0800 Subject: [PATCH 121/161] Rename _eval_issub and _eval_matches to match their operator names --- mypy/typelevel.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mypy/typelevel.py b/mypy/typelevel.py index 866e520019de0..24e177a2c5e2e 100644 --- a/mypy/typelevel.py +++ b/mypy/typelevel.py @@ -432,7 +432,7 @@ def _eval_iter(arg: Type, *, evaluator: TypeLevelEvaluator) -> Type: @register_operator("IsAssignable") -def _eval_issub(lhs: Type, rhs: Type, *, evaluator: TypeLevelEvaluator) -> Type: +def _eval_isass(lhs: Type, rhs: Type, *, evaluator: TypeLevelEvaluator) -> Type: """Evaluate a type-level condition (IsAssignable[T, Base]).""" left_proper = evaluator.eval_proper(lhs) right_proper = evaluator.eval_proper(rhs) @@ -447,7 +447,7 @@ def _eval_issub(lhs: Type, rhs: Type, *, evaluator: TypeLevelEvaluator) -> Type: @register_operator("IsEquivalent") -def _eval_matches(lhs: Type, rhs: Type, *, evaluator: TypeLevelEvaluator) -> Type: +def _eval_isequiv(lhs: Type, rhs: Type, *, evaluator: TypeLevelEvaluator) -> Type: """Evaluate IsEquivalent[T, S] - check if T and S are equivalent types. Returns Literal[True] if T is a subtype of S AND S is a subtype of T. From 5a05a53ac3c237146b2d5caf954ce9cb00f59f41 Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Fri, 13 Feb 2026 13:19:14 -0800 Subject: [PATCH 122/161] Fix comment --- test-data/unit/fixtures/typelevel.pyi | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test-data/unit/fixtures/typelevel.pyi b/test-data/unit/fixtures/typelevel.pyi index f2f53313f6bb4..2244ab50a42ba 100644 --- a/test-data/unit/fixtures/typelevel.pyi +++ b/test-data/unit/fixtures/typelevel.pyi @@ -1,4 +1,4 @@ -# Builtins stub used in tuple-related test cases. +# Builtins stub used in typelevel-related test cases. import _typeshed from typing import Iterable, Iterator, TypeVar, Generic, Sequence, Mapping, Optional, overload, Tuple, Type, Union, Self, type_check_only, _type_operator From 20f97c4a5922353134ffdff8a014fa20c05e30e6 Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Wed, 4 Mar 2026 17:40:18 -0800 Subject: [PATCH 123/161] Fix debug code that snuck in --- mypy/checker.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/mypy/checker.py b/mypy/checker.py index 13e9fbcc7a712..fa6f7b59c2a8a 100644 --- a/mypy/checker.py +++ b/mypy/checker.py @@ -7476,8 +7476,6 @@ def check_subtype( if is_subtype(subtype, supertype, options=self.options): return True - is_subtype(subtype, supertype, options=self.options) - if isinstance(msg, str): msg = ErrorMessage(msg, code=code) From b0bbbe93b0bca088de49099200aebd370a1e1eb0 Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Tue, 17 Feb 2026 12:32:13 -0800 Subject: [PATCH 124/161] Add a note to follow up on something --- mypy/typeanal.py | 1 + 1 file changed, 1 insertion(+) diff --git a/mypy/typeanal.py b/mypy/typeanal.py index e965a1e378d82..5edfa1f5038db 100644 --- a/mypy/typeanal.py +++ b/mypy/typeanal.py @@ -526,6 +526,7 @@ def visit_unbound_type_nonoptional(self, t: UnboundType, defining_literal: bool) else: return self.analyze_unbound_type_without_type_info(t, sym, defining_literal) else: # sym is None + # TODO: XXX: I'm not sure if this is where I want this. # Try dot notation for type-level attribute access: T.attr -> _TypeGetAttr[T, Literal["attr"]] # Only applies when the prefix is a type variable (not a module, class, etc.) if "." in t.name: From bcab7bfff1b46e40d50aae5290b7e67e90b54346 Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Tue, 17 Feb 2026 13:22:21 -0800 Subject: [PATCH 125/161] Support dot notation on subscripted and conditional type expressions Make `X[A, B].attr` and `(X if C else Y).attr` work in type contexts by desugaring to `_TypeGetAttr[..., Literal["attr"]]` in both the annotation path (fastparse.py) and the type alias path (exprtotype.py). Move _TypeGetAttr from typing to builtins (alongside _Cond, _DictEntry, etc.) so the `__builtins__._TypeGetAttr` reference resolves without needing an explicit import. --- mypy/exprtotype.py | 17 +++++- mypy/fastparse.py | 14 +++++ mypy/typeanal.py | 2 +- mypy/typeshed/stdlib/_typeshed/typemap.pyi | 10 ---- mypy/typeshed/stdlib/builtins.pyi | 10 ++++ mypy/typeshed/stdlib/typing.pyi | 1 - test-data/unit/check-typelevel-members.test | 60 +++++++++++++++++++++ test-data/unit/fixtures/typelevel.pyi | 4 ++ test-data/unit/fixtures/typing-full.pyi | 3 -- 9 files changed, 105 insertions(+), 16 deletions(-) diff --git a/mypy/exprtotype.py b/mypy/exprtotype.py index 403cc9db6f069..0a45f74d52f7e 100644 --- a/mypy/exprtotype.py +++ b/mypy/exprtotype.py @@ -102,7 +102,22 @@ def expr_to_unanalyzed_type( if fullname: return UnboundType(fullname, line=expr.line, column=expr.column) else: - raise TypeTranslationError() + # Attribute access on a complex type expression (subscripted, conditional, etc.) + # Desugar X.attr to _TypeGetAttr[X, Literal["attr"]] + before_dot = expr_to_unanalyzed_type( + expr.expr, options, allow_new_syntax, expr, lookup_qualified=lookup_qualified + ) + attr_literal = UnboundType( + "Literal", + [RawExpressionType(expr.name, "builtins.str", line=expr.line)], + line=expr.line, + ) + return UnboundType( + "__builtins__._TypeGetAttr", + [before_dot, attr_literal], + line=expr.line, + column=expr.column, + ) elif isinstance(expr, IndexExpr): base = expr_to_unanalyzed_type( expr.base, options, allow_new_syntax, expr, lookup_qualified=lookup_qualified diff --git a/mypy/fastparse.py b/mypy/fastparse.py index 943f56f7339ac..2dc8c8dc56a53 100644 --- a/mypy/fastparse.py +++ b/mypy/fastparse.py @@ -2200,6 +2200,20 @@ def visit_Attribute(self, n: Attribute) -> Type: if isinstance(before_dot, UnboundType) and not before_dot.args: return UnboundType(f"{before_dot.name}.{n.attr}", line=self.line, column=n.col_offset) + elif isinstance(before_dot, UnboundType): + # Subscripted type with attribute access: GetMember[T, K].type + # Desugar to _TypeGetAttr[GetMember[T, K], Literal["attr"]] + attr_literal = UnboundType( + "Literal", + [RawExpressionType(n.attr, "builtins.str", line=self.line)], + line=self.line, + ) + return UnboundType( + "__builtins__._TypeGetAttr", + [before_dot, attr_literal], + line=self.line, + column=n.col_offset, + ) else: return self.invalid_type(n) diff --git a/mypy/typeanal.py b/mypy/typeanal.py index 5edfa1f5038db..85500be219d39 100644 --- a/mypy/typeanal.py +++ b/mypy/typeanal.py @@ -533,7 +533,7 @@ def visit_unbound_type_nonoptional(self, t: UnboundType, defining_literal: bool) prefix, attr = t.name.rsplit(".", 1) prefix_sym = self.lookup_qualified(prefix, t, suppress_errors=True) if prefix_sym is not None and isinstance(prefix_sym.node, TypeVarExpr): - operator_sym = self.api.lookup_fully_qualified_or_none("typing._TypeGetAttr") + operator_sym = self.api.lookup_fully_qualified_or_none("builtins._TypeGetAttr") if operator_sym and isinstance(operator_sym.node, TypeInfo): prefix_type = self.anal_type( UnboundType(prefix, t.args, line=t.line, column=t.column) diff --git a/mypy/typeshed/stdlib/_typeshed/typemap.pyi b/mypy/typeshed/stdlib/_typeshed/typemap.pyi index 3df54e6528846..351dcaac65581 100644 --- a/mypy/typeshed/stdlib/_typeshed/typemap.pyi +++ b/mypy/typeshed/stdlib/_typeshed/typemap.pyi @@ -123,16 +123,6 @@ class GetMemberType(Generic[_T, _Name]): ... -@_type_operator -class _TypeGetAttr(Generic[_T, _Name]): - """ - Internal type operator for dot notation on types. - T.attr in type context desugars to _TypeGetAttr[T, Literal["attr"]]. - Semantically equivalent to GetMemberType. - """ - - ... - @_type_operator class Members(Generic[_T]): """ diff --git a/mypy/typeshed/stdlib/builtins.pyi b/mypy/typeshed/stdlib/builtins.pyi index 0e205e5225708..28eeae575874e 100644 --- a/mypy/typeshed/stdlib/builtins.pyi +++ b/mypy/typeshed/stdlib/builtins.pyi @@ -2282,3 +2282,13 @@ class _DictEntry(Generic[_T1, _T2]): """ ... + +@_type_operator +class _TypeGetAttr(Generic[_T1, _T2]): + """ + Internal type operator for dot notation on types. + X[A].attr in type context desugars to _TypeGetAttr[X[A], Literal["attr"]]. + Semantically equivalent to GetMemberType. + """ + + ... diff --git a/mypy/typeshed/stdlib/typing.pyi b/mypy/typeshed/stdlib/typing.pyi index 425afaec3ce16..924cced47c0c0 100644 --- a/mypy/typeshed/stdlib/typing.pyi +++ b/mypy/typeshed/stdlib/typing.pyi @@ -1284,5 +1284,4 @@ if sys.version_info >= (3, 15): Slice as Slice, Uncapitalize as Uncapitalize, Uppercase as Uppercase, - _TypeGetAttr as _TypeGetAttr, ) diff --git a/test-data/unit/check-typelevel-members.test b/test-data/unit/check-typelevel-members.test index 5a35ac7ae5ce4..410d674c9ead7 100644 --- a/test-data/unit/check-typelevel-members.test +++ b/test-data/unit/check-typelevel-members.test @@ -427,3 +427,63 @@ x = {'name': 'Alice'} [builtins fixtures/typelevel.pyi] [typing fixtures/typing-full.pyi] + +[case testDotNotationOnSubscriptedType] +# flags: --python-version 3.12 +# Test dot notation on a subscripted type expression +from typing import Member, Literal, Never + +class Point: + x: int + y: str + +# Dot notation on a subscripted type: Member[...].typ +x: Member[Literal['x'], int, Never, Never, Point].typ +reveal_type(x) # N: Revealed type is "builtins.int" + +y: Member[Literal['x'], int, Never, Never, Point].name +reveal_type(y) # N: Revealed type is "Literal['x']" + +[builtins fixtures/typelevel.pyi] +[typing fixtures/typing-full.pyi] + +[case testDotNotationOnConditionalType] +# flags: --python-version 3.12 +# Test dot notation where the LHS is a conditional type expression +from typing import Member, Literal, Never, IsAssignable + +class Point: + x: int + y: str + +# Conditional evaluates to a Member, then .typ extracts the type +x: (Member[Literal['x'], int, Never, Never, Point] if IsAssignable[int, int] else Member[Literal['y'], str, Never, Never, Point]).typ +reveal_type(x) # N: Revealed type is "builtins.int" + +# Same inside a type alias +type T = (Member[Literal['x'], int, Never, Never, Point] if IsAssignable[int, str] else Member[Literal['y'], str, Never, Never, Point]).name +a: T +reveal_type(a) # N: Revealed type is "Literal['y']" + +[builtins fixtures/typelevel.pyi] +[typing fixtures/typing-full.pyi] + +[case testDotNotationOnSubscriptedTypeAlias] +# flags: --python-version 3.12 +# Test dot notation on subscripted type inside a type alias +from typing import Member, Literal, Never + +class Point: + x: int + y: str + +type T = Member[Literal['x'], int, Never, Never, Point].typ +a: T +reveal_type(a) # N: Revealed type is "builtins.int" + +type N = Member[Literal['x'], int, Never, Never, Point].name +b: N +reveal_type(b) # N: Revealed type is "Literal['x']" + +[builtins fixtures/typelevel.pyi] +[typing fixtures/typing-full.pyi] diff --git a/test-data/unit/fixtures/typelevel.pyi b/test-data/unit/fixtures/typelevel.pyi index 2244ab50a42ba..977da35cf3db9 100644 --- a/test-data/unit/fixtures/typelevel.pyi +++ b/test-data/unit/fixtures/typelevel.pyi @@ -101,3 +101,7 @@ class _Not(Generic[_T]): ... @type_check_only @_type_operator class _DictEntry(Generic[_T, _T2]): ... + +@type_check_only +@_type_operator +class _TypeGetAttr(Generic[_T, _T2]): ... diff --git a/test-data/unit/fixtures/typing-full.pyi b/test-data/unit/fixtures/typing-full.pyi index baeae9ae795ce..67fb19349a1bc 100644 --- a/test-data/unit/fixtures/typing-full.pyi +++ b/test-data/unit/fixtures/typing-full.pyi @@ -267,9 +267,6 @@ class FromUnion(Generic[T]): ... @_type_operator class GetMemberType(Generic[T, U]): ... -@_type_operator -class _TypeGetAttr(Generic[T, U]): ... - @_type_operator class Slice(Generic[T, U, V]): ... From 501adf7cf5e88f99bc30c901ff02a127726bf6a8 Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Tue, 3 Mar 2026 13:37:54 -0800 Subject: [PATCH 126/161] Fix typeshed to use .type --- mypy/typeshed/stdlib/_typeshed/typemap.pyi | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/mypy/typeshed/stdlib/_typeshed/typemap.pyi b/mypy/typeshed/stdlib/_typeshed/typemap.pyi index 351dcaac65581..83ba44e8f5184 100644 --- a/mypy/typeshed/stdlib/_typeshed/typemap.pyi +++ b/mypy/typeshed/stdlib/_typeshed/typemap.pyi @@ -55,7 +55,7 @@ class Member(Generic[_Name, _Type, _Quals, _Init, _Definer]): """ name: _Name - typ: _Type + type: _Type quals: _Quals init: _Init definer: _Definer @@ -69,7 +69,7 @@ class Param(Generic[_Name, _Type, _Quals]): """ name: _Name - typ: _Type + type: _Type quals: _Quals @@ -160,7 +160,7 @@ _M = TypeVar("_M") GetName = GetMemberType[_MP, Literal["name"]] -GetType = GetMemberType[_MP, Literal["typ"]] +GetType = GetMemberType[_MP, Literal["type"]] GetQuals = GetMemberType[_MP, Literal["quals"]] GetInit = GetMemberType[_M, Literal["init"]] GetDefiner = GetMemberType[_M, Literal["definer"]] From bb65d0fac5940a23ce2d7a9831db7307b60d1f97 Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Wed, 4 Mar 2026 14:36:43 -0800 Subject: [PATCH 127/161] Include enum members in Attrs/Members results XXX: This may be worth backing out depending on the direction of https://discuss.python.org/t/proposal-for-two-new-typing-constructs-membernames-t-and-membertypes-t/106342/12 Enum members are always type-inferred (RED = 1, not RED: int = 1), so they were being skipped by the is_inferred filter. Skip that filter when the target class is an enum. --- mypy/typelevel.py | 5 ++-- test-data/unit/check-typelevel-members.test | 31 +++++++++++++++++++++ 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/mypy/typelevel.py b/mypy/typelevel.py index 24e177a2c5e2e..1c75f31aea321 100644 --- a/mypy/typelevel.py +++ b/mypy/typelevel.py @@ -832,8 +832,9 @@ def _eval_members_impl( if sym.type is None: continue - # Skip inferred attributes (those without explicit type annotations) - if isinstance(sym.node, Var) and sym.node.is_inferred: + # Skip inferred attributes (those without explicit type annotations), + # but include them for enums since enum members are always inferred. + if isinstance(sym.node, Var) and sym.node.is_inferred and not target.type.is_enum: continue if attrs_only: diff --git a/test-data/unit/check-typelevel-members.test b/test-data/unit/check-typelevel-members.test index 410d674c9ead7..d874a50958a2b 100644 --- a/test-data/unit/check-typelevel-members.test +++ b/test-data/unit/check-typelevel-members.test @@ -468,6 +468,37 @@ reveal_type(a) # N: Revealed type is "Literal['y']" [builtins fixtures/typelevel.pyi] [typing fixtures/typing-full.pyi] +[case testAttrsOnEnum] +# flags: --python-version 3.12 +from typing import Attrs, Iter, Union, IsAssignable, Never +from enum import Enum + +class Color(Enum): + RED = 1 + GREEN = 2 + BLUE = 3 + +a: Attrs[Color] +reveal_type(a) # N: Revealed type is "tuple[typing.Member[Literal['RED'], Literal[1]?, Literal['Final'], Literal[1], __main__.Color], typing.Member[Literal['GREEN'], Literal[2]?, Literal['Final'], Literal[2], __main__.Color], typing.Member[Literal['BLUE'], Literal[3]?, Literal['Final'], Literal[3], __main__.Color]]" + +type EnumNames[T] = Union[*[ + m.name for m in Iter[Attrs[T]] + if not IsAssignable[m.init, Never] +]] +type EnumTypes[T] = Union[*[ + m.init for m in Iter[Attrs[T]] + if not IsAssignable[m.init, Never] +]] + +n: EnumNames[Color] +reveal_type(n) # N: Revealed type is "Literal['RED'] | Literal['GREEN'] | Literal['BLUE']" + +t: EnumTypes[Color] +reveal_type(t) # N: Revealed type is "Literal[1] | Literal[2] | Literal[3]" + +[builtins fixtures/typelevel.pyi] +[typing fixtures/typing-full.pyi] + [case testDotNotationOnSubscriptedTypeAlias] # flags: --python-version 3.12 # Test dot notation on subscripted type inside a type alias From 794e880934b5e398140fb9bde1e115e6e2d508b7 Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Wed, 4 Mar 2026 17:05:59 -0800 Subject: [PATCH 128/161] Refactor NewProtocol display to format lazily at display time Move NewProtocol member formatting from construction time (baked into the class name via _proto_str) to display time (TypeStrVisitor and format_type_inner). This avoids issues with recursive type aliases where str_with_options(expand=True) refuses to expand, causing member types to show up unexpanded. Add is_new_protocol flag to TypeInfo so the formatting paths can identify synthetic NewProtocol types and render their members on demand. --- mypy/messages.py | 4 ++ mypy/nodes.py | 6 +++ mypy/typelevel.py | 36 +---------------- mypy/types.py | 47 ++++++++++++++++++++++- test-data/unit/check-typelevel-basic.test | 12 +++--- 5 files changed, 65 insertions(+), 40 deletions(-) diff --git a/mypy/messages.py b/mypy/messages.py index 32c2eeecf396a..c11ed8a07b7d0 100644 --- a/mypy/messages.py +++ b/mypy/messages.py @@ -102,6 +102,7 @@ UnionType, UnpackType, flatten_nested_unions, + format_new_protocol, get_proper_type, get_proper_types, ) @@ -2624,6 +2625,9 @@ def format_literal_value(typ: LiteralType) -> str: if isinstance(typ, Instance): itype = typ + # Format NewProtocol types by showing their members + if itype.type.is_new_protocol: + return format_new_protocol(itype, format, prefix="") # Get the short name of the type. if itype.type.fullname == "types.ModuleType": # Make some common error messages simpler and tidier. diff --git a/mypy/nodes.py b/mypy/nodes.py index daa41a2c4b513..c37d7618eb71b 100644 --- a/mypy/nodes.py +++ b/mypy/nodes.py @@ -3592,6 +3592,7 @@ class is generic then it will be a type constructor of higher kind. "dataclass_transform_spec", "is_type_check_only", "is_type_operator", + "is_new_protocol", "deprecated", "type_object_type", ) @@ -3752,6 +3753,9 @@ class is generic then it will be a type constructor of higher kind. # Type operators are used for type-level computation (e.g., GetArg, Members, etc.) is_type_operator: bool + # Is set to `True` for synthetic protocol types created by NewProtocol[...] + is_new_protocol: bool + # The type's deprecation message (in case it is deprecated) deprecated: str | None @@ -3772,6 +3776,7 @@ class is generic then it will be a type constructor of higher kind. "is_disjoint_base", "is_intersection", "is_type_operator", + "is_new_protocol", ] def __init__(self, names: SymbolTable, defn: ClassDef, module_name: str) -> None: @@ -3820,6 +3825,7 @@ def __init__(self, names: SymbolTable, defn: ClassDef, module_name: str) -> None self.dataclass_transform_spec = None self.is_type_check_only = False self.is_type_operator = False + self.is_new_protocol = False self.deprecated = None self.type_object_type = None diff --git a/mypy/typelevel.py b/mypy/typelevel.py index 1c75f31aea321..bd41a71d5c99c 100644 --- a/mypy/typelevel.py +++ b/mypy/typelevel.py @@ -996,36 +996,6 @@ def _eval_new_typeddict(*args: Type, evaluator: TypeLevelEvaluator) -> Type: ) -def _proto_entry_str(entry: tuple[Type, Type, bool, bool]) -> str: - typ, init_type, is_classvar, is_final = entry - # XXX: We fully expand the type here for stringifying, which is - # potentially dangerous... - # TODO: We'll need to prevent recursion or something. - styp = typ.str_with_options(expand=True) - - if is_classvar: - styp = f"ClassVar[{styp}]" - if is_final: - styp = f"Final[{styp}]" - - # XXX: use evaluator? - # Put the initializers in also - init_type = get_proper_type(init_type) - if isinstance(init_type, LiteralType): - styp = f"{styp} = {init_type.value}" - elif isinstance(init_type, NoneType): - styp = f"{styp} = None" - elif not isinstance(init_type, UninhabitedType): - styp = f"{styp} = ..." - - return styp - - -def _proto_str(map: dict[str, tuple[Type, Type, bool, bool]]) -> str: - body = [f"{name}: {_proto_entry_str(entry)}" for name, entry in map.items()] - return f"NewProtocol[{', '.join(body)}]" - - @register_operator("NewProtocol") def _eval_new_protocol(*args: Type, evaluator: TypeLevelEvaluator) -> Type: """Evaluate NewProtocol[*Members] -> create a new structural protocol type. @@ -1087,10 +1057,7 @@ def _eval_new_protocol(*args: Type, evaluator: TypeLevelEvaluator) -> Type: member_vars[name] = (item_type, init_type, is_classvar, is_final) - # Generate a unique name for the synthetic protocol - # XXX: I hope that it is unique based on the inputs? - # Should we cache it also? - protocol_name = _proto_str(member_vars) + protocol_name = "NewProtocol" # Create the synthetic protocol TypeInfo # HACK: We create a ClassDef with an empty Block because TypeInfo requires one. @@ -1103,6 +1070,7 @@ def _eval_new_protocol(*args: Type, evaluator: TypeLevelEvaluator) -> Type: # Mark as protocol info.is_protocol = True + info.is_new_protocol = True info.runtime_protocol = False # These aren't runtime checkable # Set up bases - inherit from object (not Protocol, since Protocol is just a marker) diff --git a/mypy/types.py b/mypy/types.py index a43b21cddc24b..afed16b025209 100644 --- a/mypy/types.py +++ b/mypy/types.py @@ -4,7 +4,7 @@ import sys from abc import abstractmethod -from collections.abc import Iterable, Sequence +from collections.abc import Callable, Iterable, Sequence from typing import ( Any, ClassVar, @@ -4123,6 +4123,9 @@ def visit_instance(self, t: Instance, /) -> str: if (nt := try_expand_or_none(t)) and nt != t: return nt.accept(self) + if t.type.is_new_protocol: + return self._format_new_protocol(t) + fullname = t.type.fullname if not self.options.reveal_verbose_types and fullname.startswith("builtins."): fullname = t.type.name @@ -4146,6 +4149,9 @@ def visit_instance(self, t: Instance, /) -> str: s += f"<{self.id_mapper.id(t.type)}>" return s + def _format_new_protocol(self, t: Instance) -> str: + return format_new_protocol(t, lambda typ: typ.accept(self)) + def visit_type_var(self, t: TypeVarType, /) -> str: if not self.options.reveal_verbose_types: s = t.name @@ -4515,6 +4521,45 @@ def has_recursive_types(typ: Type) -> bool: return typ.accept(_has_recursive_type) +def format_new_protocol( + t: Instance, format: Callable[[Type], str], prefix: str = "__typelevel__." +) -> str: + """Format a NewProtocol instance by showing its members. + + Used by both TypeStrVisitor and format_type_inner in messages.py. + """ + from mypy.nodes import Var + + parts: list[str] = [] + for name, node in t.type.names.items(): + if not isinstance(node.node, Var): + continue + var = node.node + if var.type is not None: + type_str = format(var.type) + else: + type_str = "" + + if var.is_classvar: + type_str = f"ClassVar[{type_str}]" + if var.is_final: + type_str = f"Final[{type_str}]" + + # Append initializer info + if var.init_type is not None: + init = get_proper_type(var.init_type) + if isinstance(init, LiteralType): + type_str = f"{type_str} = {init.value}" + elif isinstance(init, NoneType): + type_str = f"{type_str} = None" + elif not isinstance(init, UninhabitedType): + type_str = f"{type_str} = ..." + + parts.append(f"{name}: {type_str}") + + return f"{prefix}NewProtocol[{', '.join(parts)}]" + + def split_with_prefix_and_suffix( types: tuple[Type, ...], prefix: int, suffix: int ) -> tuple[tuple[Type, ...], tuple[Type, ...], tuple[Type, ...]]: diff --git a/test-data/unit/check-typelevel-basic.test b/test-data/unit/check-typelevel-basic.test index b2a13ed878dd9..229910c811dd3 100644 --- a/test-data/unit/check-typelevel-basic.test +++ b/test-data/unit/check-typelevel-basic.test @@ -1057,10 +1057,12 @@ g: Good takes_proto(g) b: Bad -takes_proto(b) # E: Argument 1 to "takes_proto" has incompatible type "Bad"; expected "NewProtocol[x: builtins.int, y: builtins.str]" \ - # N: "Bad" is missing following "NewProtocol[x: builtins.int, y: builtins.str]" protocol member: \ +takes_proto(b) # E: Argument 1 to "takes_proto" has incompatible type "Bad"; expected "NewProtocol[x: int, y: str]" \ + # N: "Bad" is missing following "NewProtocol" protocol member: \ # N: y + + [builtins fixtures/typelevel.pyi] [typing fixtures/typing-full.pyi] @@ -1077,7 +1079,7 @@ type LinkedList = NewProtocol[ ] z: LinkedList -reveal_type(z) # N: Revealed type is "__typelevel__.NewProtocol[data: builtins.int, next: __main__.LinkedList]" +reveal_type(z) # N: Revealed type is "__typelevel__.NewProtocol[data: builtins.int, next: ...]" reveal_type(z.data) # N: Revealed type is "builtins.int" reveal_type(z.next.next.data) # N: Revealed type is "builtins.int" @@ -1098,7 +1100,7 @@ type LinkedList[T] = NewProtocol[ ] z: LinkedList[str] -reveal_type(z) # N: Revealed type is "__typelevel__.NewProtocol[data: builtins.str, next: __main__.LinkedList[builtins.str]]" +reveal_type(z) # N: Revealed type is "__typelevel__.NewProtocol[data: builtins.str, next: ...]" reveal_type(z.data) # N: Revealed type is "builtins.str" reveal_type(z.next.next.data) # N: Revealed type is "builtins.str" @@ -1186,7 +1188,7 @@ nap: NotAPoint draw(p2d) # OK - has x and y draw(p3d) # OK - has x and y (plus z is fine) -draw(nap) # E: Argument 1 to "draw" has incompatible type "NotAPoint"; expected "NewProtocol[x: builtins.int, y: builtins.int]" +draw(nap) # E: Argument 1 to "draw" has incompatible type "NotAPoint"; expected "NewProtocol[x: int, y: int]" From bafdede510e00ebb1b2d2421308fdacce9810ac2 Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Wed, 4 Mar 2026 17:24:22 -0800 Subject: [PATCH 129/161] Drop `__typelevel__` from the `NewProtocol` output --- mypy/messages.py | 2 +- mypy/types.py | 6 ++---- test-data/unit/check-typelevel-basic.test | 4 ++-- test-data/unit/check-typelevel-examples.test | 12 ++++++------ test-data/unit/pythoneval.test | 6 +++--- 5 files changed, 14 insertions(+), 16 deletions(-) diff --git a/mypy/messages.py b/mypy/messages.py index c11ed8a07b7d0..ff7b11ee5c3f4 100644 --- a/mypy/messages.py +++ b/mypy/messages.py @@ -2627,7 +2627,7 @@ def format_literal_value(typ: LiteralType) -> str: itype = typ # Format NewProtocol types by showing their members if itype.type.is_new_protocol: - return format_new_protocol(itype, format, prefix="") + return format_new_protocol(itype, format) # Get the short name of the type. if itype.type.fullname == "types.ModuleType": # Make some common error messages simpler and tidier. diff --git a/mypy/types.py b/mypy/types.py index afed16b025209..ddb11c949e54a 100644 --- a/mypy/types.py +++ b/mypy/types.py @@ -4521,9 +4521,7 @@ def has_recursive_types(typ: Type) -> bool: return typ.accept(_has_recursive_type) -def format_new_protocol( - t: Instance, format: Callable[[Type], str], prefix: str = "__typelevel__." -) -> str: +def format_new_protocol(t: Instance, format: Callable[[Type], str]) -> str: """Format a NewProtocol instance by showing its members. Used by both TypeStrVisitor and format_type_inner in messages.py. @@ -4557,7 +4555,7 @@ def format_new_protocol( parts.append(f"{name}: {type_str}") - return f"{prefix}NewProtocol[{', '.join(parts)}]" + return f"NewProtocol[{', '.join(parts)}]" def split_with_prefix_and_suffix( diff --git a/test-data/unit/check-typelevel-basic.test b/test-data/unit/check-typelevel-basic.test index 229910c811dd3..de610a67bf000 100644 --- a/test-data/unit/check-typelevel-basic.test +++ b/test-data/unit/check-typelevel-basic.test @@ -1079,7 +1079,7 @@ type LinkedList = NewProtocol[ ] z: LinkedList -reveal_type(z) # N: Revealed type is "__typelevel__.NewProtocol[data: builtins.int, next: ...]" +reveal_type(z) # N: Revealed type is "NewProtocol[data: builtins.int, next: ...]" reveal_type(z.data) # N: Revealed type is "builtins.int" reveal_type(z.next.next.data) # N: Revealed type is "builtins.int" @@ -1100,7 +1100,7 @@ type LinkedList[T] = NewProtocol[ ] z: LinkedList[str] -reveal_type(z) # N: Revealed type is "__typelevel__.NewProtocol[data: builtins.str, next: ...]" +reveal_type(z) # N: Revealed type is "NewProtocol[data: builtins.str, next: ...]" reveal_type(z.data) # N: Revealed type is "builtins.str" reveal_type(z.next.next.data) # N: Revealed type is "builtins.str" diff --git a/test-data/unit/check-typelevel-examples.test b/test-data/unit/check-typelevel-examples.test index ccda761af3d5f..37eec0a3239df 100644 --- a/test-data/unit/check-typelevel-examples.test +++ b/test-data/unit/check-typelevel-examples.test @@ -160,12 +160,12 @@ class User: #### -reveal_type(select(User, id=True, name=True)) # N: Revealed type is "builtins.list[__typelevel__.NewProtocol[id: builtins.int, name: builtins.str]]" +reveal_type(select(User, id=True, name=True)) # N: Revealed type is "builtins.list[NewProtocol[id: builtins.int, name: builtins.str]]" -reveal_type(select(User, name=True, email=True, posts=True)) # N: Revealed type is "builtins.list[__typelevel__.NewProtocol[name: builtins.str, email: builtins.str, posts: builtins.list[__typelevel__.NewProtocol[id: builtins.int, title: builtins.str, content: builtins.str]]]]" +reveal_type(select(User, name=True, email=True, posts=True)) # N: Revealed type is "builtins.list[NewProtocol[name: builtins.str, email: builtins.str, posts: builtins.list[NewProtocol[id: builtins.int, title: builtins.str, content: builtins.str]]]]" -reveal_type(select(Post, title=True, comments=True, author=True)) # N: Revealed type is "builtins.list[__typelevel__.NewProtocol[title: builtins.str, comments: builtins.list[__typelevel__.NewProtocol[id: builtins.int, name: builtins.str]], author: __typelevel__.NewProtocol[id: builtins.int, name: builtins.str]]]" +reveal_type(select(Post, title=True, comments=True, author=True)) # N: Revealed type is "builtins.list[NewProtocol[title: builtins.str, comments: builtins.list[NewProtocol[id: builtins.int, name: builtins.str]], author: NewProtocol[id: builtins.int, name: builtins.str]]]" [builtins fixtures/typelevel.pyi] @@ -537,13 +537,13 @@ type HeroCreate = Create[Hero] type HeroUpdate = Update[Hero] pub: HeroPublic -reveal_type(pub) # N: Revealed type is "__typelevel__.NewProtocol[id: builtins.int, name: builtins.str, age: builtins.int | None]" +reveal_type(pub) # N: Revealed type is "NewProtocol[id: builtins.int, name: builtins.str, age: builtins.int | None]" creat: HeroCreate -reveal_type(creat) # N: Revealed type is "__typelevel__.NewProtocol[name: builtins.str, age: builtins.int | None = None, secret_name: builtins.str]" +reveal_type(creat) # N: Revealed type is "NewProtocol[name: builtins.str, age: builtins.int | None = None, secret_name: builtins.str]" upd: HeroUpdate -reveal_type(upd) # N: Revealed type is "__typelevel__.NewProtocol[name: builtins.str | None = None, age: builtins.int | None | None = None, secret_name: builtins.str | None = None]" +reveal_type(upd) # N: Revealed type is "NewProtocol[name: builtins.str | None = None, age: builtins.int | None | None = None, secret_name: builtins.str | None = None]" creat_members: tuple[*[typing.GetInit[p] for p in typing.Iter[typing.Members[HeroCreate]]]] reveal_type(creat_members) # N: Revealed type is "tuple[Never, None, Never]" diff --git a/test-data/unit/pythoneval.test b/test-data/unit/pythoneval.test index 64f6fe9801da6..dc47cfbe0b11f 100644 --- a/test-data/unit/pythoneval.test +++ b/test-data/unit/pythoneval.test @@ -2392,9 +2392,9 @@ type HeroDict[T] = typing.NewTypedDict[{ hd: HeroDict[Hero] reveal_type(hd) [out] -_program.py:133: note: Revealed type is "__typelevel__.NewProtocol[id: builtins.int, name: builtins.str, age: builtins.int | None]" -_program.py:136: note: Revealed type is "__typelevel__.NewProtocol[name: builtins.str, age: builtins.int | None = None, secret_name: builtins.str]" -_program.py:139: note: Revealed type is "__typelevel__.NewProtocol[name: builtins.str | None = None, age: builtins.int | None | None = None, secret_name: builtins.str | None = None]" +_program.py:133: note: Revealed type is "NewProtocol[id: builtins.int, name: builtins.str, age: builtins.int | None]" +_program.py:136: note: Revealed type is "NewProtocol[name: builtins.str, age: builtins.int | None = None, secret_name: builtins.str]" +_program.py:139: note: Revealed type is "NewProtocol[name: builtins.str | None = None, age: builtins.int | None | None = None, secret_name: builtins.str | None = None]" _program.py:142: note: Revealed type is "tuple[Never, None, Never]" _program.py:145: note: Revealed type is "tuple[builtins.str | None, builtins.int | None | None, builtins.str | None]" _program.py:148: note: Revealed type is "tuple[builtins.str | None, builtins.int | None | None, builtins.str | None]" From 107baa66de20e92c3c79c5dfb969d217f2409646 Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Wed, 4 Mar 2026 17:34:29 -0800 Subject: [PATCH 130/161] Add a test for nested qblike stuff -- inference still dodgy --- test-data/unit/check-typelevel-examples.test | 132 +++++++++++++++++++ 1 file changed, 132 insertions(+) diff --git a/test-data/unit/check-typelevel-examples.test b/test-data/unit/check-typelevel-examples.test index 37eec0a3239df..7be9f1df450b3 100644 --- a/test-data/unit/check-typelevel-examples.test +++ b/test-data/unit/check-typelevel-examples.test @@ -172,6 +172,138 @@ reveal_type(select(Post, title=True, comments=True, author=True)) # N: Revealed [typing fixtures/typing-full.pyi] +[case testTypeLevel_qblike_nested] +# flags: --python-version 3.14 + +from typing import Literal, Unpack, TypedDict + +from typing import ( + NewProtocol, + BaseTypedDict, + Iter, + Attrs, + IsAssignable, + GetType, + Member, + GetName, + GetMemberType, + GetArg, + InitField, +) + + +# Models + +class Pointer[T]: + pass + +class Property[T](Pointer[T]): + pass + +class Link[T](Pointer[T]): + pass + +class SingleLink[T](Link[T]): + pass + +class MultiLink[T](Link[T]): + pass + + +# Select: captures which fields to fetch from a linked model + +class Select[T: BaseTypedDict](InitField[T]): + pass + +type SelectKwargs[T] = GetArg[T, InitField, Literal[0]] + + +# Type aliases + +type PointerArg[T] = GetArg[T, Pointer, Literal[0]] + +type AdjustLink[Tgt, LinkTy] = list[Tgt] if IsAssignable[LinkTy, MultiLink] else Tgt + +type PropsOnly[T] = NewProtocol[ + *[ + Member[GetName[p], PointerArg[GetType[p]]] + for p in Iter[Attrs[T]] + if IsAssignable[GetType[p], Property] + ] +] + +type LinkTarget[T, Sel] = ( + ProjectFields[T, SelectKwargs[Sel]] + if IsAssignable[Sel, Select] + else PointerArg[GetMemberType[T, Literal["id"]]] + if IsAssignable[Sel, Literal["IDS"]] + else PropsOnly[T] +) + +type ConvertField[T, Sel] = ( + AdjustLink[LinkTarget[PointerArg[T], Sel], T] + if IsAssignable[T, Link] + else PointerArg[T] +) + +type ProjectFields[ModelT, K] = NewProtocol[*[ + Member[GetName[c], ConvertField[GetMemberType[ModelT, GetName[c]], GetType[c]]] + for c in Iter[Attrs[K]] +]] + + +# select function + +def select[ModelT, K: BaseTypedDict]( + typ: type[ModelT], + /, + **kwargs: Unpack[K], +) -> list[ + ProjectFields[ModelT, K] +]: + return [] + + +# Data models + +class Comment: + id: Property[int] + name: Property[str] + poster: Link[User] + +class Post: + id: Property[int] + title: Property[str] + content: Property[str] + comments: MultiLink[Comment] + author: Link[Comment] + +class User: + id: Property[int] + name: Property[str] + email: Property[str] + posts: MultiLink[Post] + + +# Tests + + +# Simple select: same behavior as original +reveal_type(select(User, id=True, name=True)) # N: Revealed type is "builtins.list[NewProtocol[id: builtins.int, name: builtins.str]]" + +nested_sel = Select(title=True, comments=True) +reveal_type(select(User, name=True, posts=nested_sel)) # N: Revealed type is "builtins.list[NewProtocol[name: builtins.str, posts: builtins.list[NewProtocol[title: builtins.str, comments: builtins.list[NewProtocol[id: builtins.int, name: builtins.str]]]]]]" + +reveal_type(select(User, name=True, posts=Select(title=True, comments=True))) # N: Revealed type is "builtins.list[NewProtocol[name: builtins.str, posts: builtins.list[NewProtocol[title: builtins.str, comments: builtins.list[NewProtocol[id: builtins.int, name: builtins.str]]]]]]" + +# "IDS" fetches just the id from linked models +reveal_type(select(User, name=True, posts="IDS")) # N: Revealed type is "builtins.list[NewProtocol[name: builtins.str, posts: builtins.list[builtins.int]]]" + + +[builtins fixtures/typelevel.pyi] +[typing fixtures/typing-full.pyi] + + [case testTypeLevel_nplike] # flags: --python-version 3.14 From 58e86ad46ad70fb4a11e971b6c8341ab42b3f4f4 Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Wed, 11 Mar 2026 13:35:22 -0700 Subject: [PATCH 131/161] Implement GetMember type operator GetMember[T, S] returns the full Member type for the named attribute S from type T, unlike GetMemberType which returns just the attribute's type. Handles Instance types (with MRO walking), TypedDict, and generics. --- mypy/typelevel.py | 63 +++++++++++++++++++ mypy/typeshed/stdlib/_typeshed/typemap.pyi | 10 +++ mypy/typeshed/stdlib/typing.pyi | 2 + test-data/unit/check-typelevel-members.test | 67 +++++++++++++++++++++ test-data/unit/fixtures/typing-full.pyi | 3 + 5 files changed, 145 insertions(+) diff --git a/mypy/typelevel.py b/mypy/typelevel.py index bd41a71d5c99c..a6adcee6d32c4 100644 --- a/mypy/typelevel.py +++ b/mypy/typelevel.py @@ -625,6 +625,69 @@ def _eval_new_union(*args: Type, evaluator: TypeLevelEvaluator) -> Type: return make_simplified_union(list(args)) +@register_operator("GetMember") +@lift_over_unions +def _eval_get_member(target_arg: Type, name_arg: Type, *, evaluator: TypeLevelEvaluator) -> Type: + """Evaluate GetMember[T, Name] - get Member type for named member from T.""" + target = evaluator.eval_proper(target_arg) + name_type = evaluator.eval_proper(name_arg) + + name = extract_literal_string(name_type) + if name is None: + return UninhabitedType() + + member_info = evaluator.get_typemap_type("Member") + + if isinstance(target, Instance): + # Walk MRO to find the member (same as Members/Attrs) + for type_info in target.type.mro: + sym = type_info.names.get(name) + if sym is None or sym.type is None: + continue + + # Map type_info to get correct type args as seen from target + if type_info == target.type: + definer = target + else: + definer = map_instance_to_supertype(target, type_info) + + member_typ = expand_type_by_instance(sym.type, definer) + return create_member_type( + evaluator, + member_info.type, + name=name, + typ=member_typ, + node=sym.node, + definer=definer, + ) + return UninhabitedType() + + if isinstance(target, TypedDictType): + item_type = target.items.get(name) + if item_type is None: + return UninhabitedType() + + quals: list[str] = [] + if name not in target.required_keys: + quals.append("NotRequired") + if name in target.readonly_keys: + quals.append("ReadOnly") + quals_type = UnionType.make_union([evaluator.literal_str(q) for q in quals]) + + return Instance( + member_info.type, + [ + evaluator.literal_str(name), + item_type, + quals_type, + UninhabitedType(), # init + UninhabitedType(), # definer + ], + ) + + return UninhabitedType() + + @register_operator("GetMemberType") @lift_over_unions def _eval_get_member_type( diff --git a/mypy/typeshed/stdlib/_typeshed/typemap.pyi b/mypy/typeshed/stdlib/_typeshed/typemap.pyi index 83ba44e8f5184..bd8f40ac59962 100644 --- a/mypy/typeshed/stdlib/_typeshed/typemap.pyi +++ b/mypy/typeshed/stdlib/_typeshed/typemap.pyi @@ -114,6 +114,16 @@ class GetArgs(Generic[_T, _Base]): ... +@_type_operator +class GetMember(Generic[_T, _Name]): + """ + Get the Member type for attribute _Name from type _T. + _Name must be a Literal[str]. + Returns Never if the member does not exist. + """ + + ... + @_type_operator class GetMemberType(Generic[_T, _Name]): """ diff --git a/mypy/typeshed/stdlib/typing.pyi b/mypy/typeshed/stdlib/typing.pyi index 924cced47c0c0..4f4879713d589 100644 --- a/mypy/typeshed/stdlib/typing.pyi +++ b/mypy/typeshed/stdlib/typing.pyi @@ -1200,6 +1200,7 @@ if sys.version_info >= (3, 15): # Type operators "GetArg", "GetArgs", + "GetMember", "GetMemberType", "Members", "Attrs", @@ -1256,6 +1257,7 @@ if sys.version_info >= (3, 15): GetAnnotations as GetAnnotations, GetArg as GetArg, GetArgs as GetArgs, + GetMember as GetMember, GetMemberType as GetMemberType, GetDefiner as GetDefiner, GetInit as GetInit, diff --git a/test-data/unit/check-typelevel-members.test b/test-data/unit/check-typelevel-members.test index d874a50958a2b..d6b48c27a1092 100644 --- a/test-data/unit/check-typelevel-members.test +++ b/test-data/unit/check-typelevel-members.test @@ -518,3 +518,70 @@ reveal_type(b) # N: Revealed type is "Literal['x']" [builtins fixtures/typelevel.pyi] [typing fixtures/typing-full.pyi] + +[case testGetMemberBasic] +from typing import GetMember, Member, Literal, Never + +class Foo: + x: int + y: str + +a: GetMember[Foo, Literal['x']] +reveal_type(a) # N: Revealed type is "typing.Member[Literal['x'], builtins.int, Never, Never, __main__.Foo]" +b: GetMember[Foo, Literal['y']] +reveal_type(b) # N: Revealed type is "typing.Member[Literal['y'], builtins.str, Never, Never, __main__.Foo]" + +# Non-existent member returns Never +c: GetMember[Foo, Literal['z']] +reveal_type(c) # N: Revealed type is "Never" + +[builtins fixtures/typelevel.pyi] +[typing fixtures/typing-full.pyi] + +[case testGetMemberTypedDict] +from typing import GetMember, Member, Literal, Never, TypedDict + +class TD(TypedDict): + name: str + age: int + +a: GetMember[TD, Literal['name']] +reveal_type(a) # N: Revealed type is "typing.Member[Literal['name'], builtins.str, Never, Never, Never]" +b: GetMember[TD, Literal['age']] +reveal_type(b) # N: Revealed type is "typing.Member[Literal['age'], builtins.int, Never, Never, Never]" +c: GetMember[TD, Literal['missing']] +reveal_type(c) # N: Revealed type is "Never" + +[builtins fixtures/typelevel.pyi] +[typing fixtures/typing-full.pyi] + +[case testGetMemberInherited] +from typing import GetMember, Member, Literal, Never + +class Base: + x: int + +class Child(Base): + y: str + +a: GetMember[Child, Literal['x']] +reveal_type(a) # N: Revealed type is "typing.Member[Literal['x'], builtins.int, Never, Never, __main__.Base]" +b: GetMember[Child, Literal['y']] +reveal_type(b) # N: Revealed type is "typing.Member[Literal['y'], builtins.str, Never, Never, __main__.Child]" + +[builtins fixtures/typelevel.pyi] +[typing fixtures/typing-full.pyi] + +[case testGetMemberGeneric] +from typing import GetMember, Member, Generic, TypeVar, Literal, Never + +T = TypeVar('T') + +class Box(Generic[T]): + value: T + +a: GetMember[Box[int], Literal['value']] +reveal_type(a) # N: Revealed type is "typing.Member[Literal['value'], builtins.int, Never, Never, __main__.Box[builtins.int]]" + +[builtins fixtures/typelevel.pyi] +[typing fixtures/typing-full.pyi] diff --git a/test-data/unit/fixtures/typing-full.pyi b/test-data/unit/fixtures/typing-full.pyi index 67fb19349a1bc..7caf9ab00636c 100644 --- a/test-data/unit/fixtures/typing-full.pyi +++ b/test-data/unit/fixtures/typing-full.pyi @@ -264,6 +264,9 @@ class GetArgs(Generic[T, U]): ... @_type_operator class FromUnion(Generic[T]): ... +@_type_operator +class GetMember(Generic[T, U]): ... + @_type_operator class GetMemberType(Generic[T, U]): ... From 2551227ecb718fc34892907b15a162e6fa46d6b3 Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Wed, 11 Mar 2026 13:37:42 -0700 Subject: [PATCH 132/161] Refactor GetMember/GetMemberType to reuse _get_members_dict Extract the member-building logic from _eval_members_impl into _get_members_dict (and _get_typeddict_members_dict) returning a dict[str, Type]. Members/Attrs/GetMember/GetMemberType all now share this common helper. GetMemberType falls back to direct attribute lookup for stub types like Member itself. --- mypy/typelevel.py | 117 +++++++++++++++------------------------------- 1 file changed, 37 insertions(+), 80 deletions(-) diff --git a/mypy/typelevel.py b/mypy/typelevel.py index a6adcee6d32c4..d438026eef084 100644 --- a/mypy/typelevel.py +++ b/mypy/typelevel.py @@ -629,63 +629,12 @@ def _eval_new_union(*args: Type, evaluator: TypeLevelEvaluator) -> Type: @lift_over_unions def _eval_get_member(target_arg: Type, name_arg: Type, *, evaluator: TypeLevelEvaluator) -> Type: """Evaluate GetMember[T, Name] - get Member type for named member from T.""" - target = evaluator.eval_proper(target_arg) - name_type = evaluator.eval_proper(name_arg) - - name = extract_literal_string(name_type) + name = extract_literal_string(evaluator.eval_proper(name_arg)) if name is None: return UninhabitedType() - member_info = evaluator.get_typemap_type("Member") - - if isinstance(target, Instance): - # Walk MRO to find the member (same as Members/Attrs) - for type_info in target.type.mro: - sym = type_info.names.get(name) - if sym is None or sym.type is None: - continue - - # Map type_info to get correct type args as seen from target - if type_info == target.type: - definer = target - else: - definer = map_instance_to_supertype(target, type_info) - - member_typ = expand_type_by_instance(sym.type, definer) - return create_member_type( - evaluator, - member_info.type, - name=name, - typ=member_typ, - node=sym.node, - definer=definer, - ) - return UninhabitedType() - - if isinstance(target, TypedDictType): - item_type = target.items.get(name) - if item_type is None: - return UninhabitedType() - - quals: list[str] = [] - if name not in target.required_keys: - quals.append("NotRequired") - if name in target.readonly_keys: - quals.append("ReadOnly") - quals_type = UnionType.make_union([evaluator.literal_str(q) for q in quals]) - - return Instance( - member_info.type, - [ - evaluator.literal_str(name), - item_type, - quals_type, - UninhabitedType(), # init - UninhabitedType(), # definer - ], - ) - - return UninhabitedType() + members = _get_members_dict(target_arg, evaluator=evaluator, attrs_only=False) + return members.get(name, UninhabitedType()) @register_operator("GetMemberType") @@ -694,26 +643,26 @@ def _eval_get_member_type( target_arg: Type, name_arg: Type, *, evaluator: TypeLevelEvaluator ) -> Type: """Evaluate GetMemberType[T, Name] - get attribute type from T.""" - target = evaluator.eval_proper(target_arg) - name_type = evaluator.eval_proper(name_arg) - - name = extract_literal_string(name_type) + name = extract_literal_string(evaluator.eval_proper(name_arg)) if name is None: return UninhabitedType() - # TODO: Use the Members logic? + # Try the full members dict first (handles user-defined classes and TypedDicts) + members = _get_members_dict(target_arg, evaluator=evaluator, attrs_only=False) + member = members.get(name) + if member is not None: + # Extract the type argument (index 1) from Member[name, typ, quals, init, definer] + member = get_proper_type(member) + if isinstance(member, Instance) and len(member.args) > 1: + return member.args[1] + return UninhabitedType() + + # Fall back to direct attribute lookup (works for stub types like Member itself) + target = evaluator.eval_proper(target_arg) if isinstance(target, Instance): node = target.type.names.get(name) if node is not None and node.type is not None: - # Expand the attribute type with the instance's type arguments return expand_type_by_instance(node.type, target) - return UninhabitedType() - if isinstance(target, TypedDictType): - itype = target.items.get(name) - if itype is not None: - return itype - return UninhabitedType() - return UninhabitedType() @@ -864,23 +813,32 @@ def _eval_attrs(target: Type, *, evaluator: TypeLevelEvaluator) -> Type: def _eval_members_impl( target_arg: Type, *, evaluator: TypeLevelEvaluator, attrs_only: bool ) -> Type: - """Common implementation for Members and Attrs operators. + """Common implementation for Members and Attrs operators.""" + members = _get_members_dict(target_arg, evaluator=evaluator, attrs_only=attrs_only) + return evaluator.tuple_type(list(members.values())) + + +def _get_members_dict( + target_arg: Type, *, evaluator: TypeLevelEvaluator, attrs_only: bool +) -> dict[str, Type]: + """Build a dict of member name -> Member type for all members of target. Args: attrs_only: If True, filter to attributes only (excludes methods). If False, include all members. + + Returns a dict mapping member names to Member[name, typ, quals, init, definer] + instance types. """ target = evaluator.eval_proper(target_arg) - # Get the Member TypeInfo member_info = evaluator.get_typemap_type("Member") - # Handle TypedDict if isinstance(target, TypedDictType): - return _eval_typeddict_members(target, member_info.type, evaluator=evaluator) + return _get_typeddict_members_dict(target, member_info.type, evaluator=evaluator) if not isinstance(target, Instance): - return UninhabitedType() + return {} members: dict[str, Type] = {} @@ -925,14 +883,14 @@ def _eval_members_impl( ) members[name] = member_type - return evaluator.tuple_type(list(members.values())) + return members -def _eval_typeddict_members( +def _get_typeddict_members_dict( target: TypedDictType, member_type_info: TypeInfo, *, evaluator: TypeLevelEvaluator -) -> Type: - """Evaluate Members/Attrs for a TypedDict type.""" - members: list[Type] = [] +) -> dict[str, Type]: + """Build a dict of member name -> Member type for a TypedDict.""" + members: dict[str, Type] = {} for name, item_type in target.items.items(): # Build qualifiers for TypedDict keys @@ -945,7 +903,7 @@ def _eval_typeddict_members( quals_type = UnionType.make_union([evaluator.literal_str(q) for q in quals]) - member_type = Instance( + members[name] = Instance( member_type_info, [ evaluator.literal_str(name), # name @@ -955,9 +913,8 @@ def _eval_typeddict_members( UninhabitedType(), # definer (not tracked for TypedDict) ], ) - members.append(member_type) - return evaluator.tuple_type(members) + return members def create_member_type( From 1a60d930e68283abee5a545de334da2dd8120f17 Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Wed, 11 Mar 2026 13:47:21 -0700 Subject: [PATCH 133/161] Replace GetMemberType fallback with skip_stubs parameter Instead of falling back to direct attribute lookup for stub types, add a skip_stubs parameter to _get_members_dict. Members/Attrs pass True (to hide inherited object/builtins noise), GetMember/GetMemberType pass False. Includes a note that skip_stubs is a wrong approach. --- mypy/typelevel.py | 33 +++++++++++++++++---------------- 1 file changed, 17 insertions(+), 16 deletions(-) diff --git a/mypy/typelevel.py b/mypy/typelevel.py index d438026eef084..ccc2f6212557b 100644 --- a/mypy/typelevel.py +++ b/mypy/typelevel.py @@ -633,7 +633,9 @@ def _eval_get_member(target_arg: Type, name_arg: Type, *, evaluator: TypeLevelEv if name is None: return UninhabitedType() - members = _get_members_dict(target_arg, evaluator=evaluator, attrs_only=False) + members = _get_members_dict( + target_arg, evaluator=evaluator, attrs_only=False, skip_stubs=False + ) return members.get(name, UninhabitedType()) @@ -647,22 +649,15 @@ def _eval_get_member_type( if name is None: return UninhabitedType() - # Try the full members dict first (handles user-defined classes and TypedDicts) - members = _get_members_dict(target_arg, evaluator=evaluator, attrs_only=False) + members = _get_members_dict( + target_arg, evaluator=evaluator, attrs_only=False, skip_stubs=False + ) member = members.get(name) if member is not None: # Extract the type argument (index 1) from Member[name, typ, quals, init, definer] member = get_proper_type(member) if isinstance(member, Instance) and len(member.args) > 1: return member.args[1] - return UninhabitedType() - - # Fall back to direct attribute lookup (works for stub types like Member itself) - target = evaluator.eval_proper(target_arg) - if isinstance(target, Instance): - node = target.type.names.get(name) - if node is not None and node.type is not None: - return expand_type_by_instance(node.type, target) return UninhabitedType() @@ -819,13 +814,19 @@ def _eval_members_impl( def _get_members_dict( - target_arg: Type, *, evaluator: TypeLevelEvaluator, attrs_only: bool + target_arg: Type, *, evaluator: TypeLevelEvaluator, attrs_only: bool, skip_stubs: bool = True ) -> dict[str, Type]: """Build a dict of member name -> Member type for all members of target. Args: attrs_only: If True, filter to attributes only (excludes methods). If False, include all members. + skip_stubs: If True, skip members defined in stub files. + This is wrong -- we should have a better way to filter + out inherited object/builtins noise in Members/Attrs + without hiding all stub-defined members. But for now, + Members/Attrs pass True and GetMember/GetMemberType + pass False. Returns a dict mapping member names to Member[name, typ, quals, init, definer] instance types. @@ -844,10 +845,10 @@ def _get_members_dict( # Iterate through MRO in reverse (base classes first) to include inherited members for type_info in reversed(target.type.mro): - # Skip types defined in stub files - module = evaluator.api.modules.get(type_info.module_name) - if module is not None and module.is_stub: - continue + if skip_stubs: + module = evaluator.api.modules.get(type_info.module_name) + if module is not None and module.is_stub: + continue for name, sym in type_info.names.items(): if sym.type is None: From 1b6c5cd059a16a36eb602627796d1e08f871d81a Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Mon, 16 Mar 2026 16:29:46 -0700 Subject: [PATCH 134/161] Replace skip_stubs with _should_skip_type_info helper Instead of a skip_stubs bool parameter, use a function that decides per-TypeInfo whether to skip it. For now, skip anything from a stub except typing.Member. The rules need to be more clearly defined. --- mypy/typelevel.py | 38 +++++++++++++++++++++----------------- 1 file changed, 21 insertions(+), 17 deletions(-) diff --git a/mypy/typelevel.py b/mypy/typelevel.py index ccc2f6212557b..5e46c3d21f41a 100644 --- a/mypy/typelevel.py +++ b/mypy/typelevel.py @@ -633,9 +633,7 @@ def _eval_get_member(target_arg: Type, name_arg: Type, *, evaluator: TypeLevelEv if name is None: return UninhabitedType() - members = _get_members_dict( - target_arg, evaluator=evaluator, attrs_only=False, skip_stubs=False - ) + members = _get_members_dict(target_arg, evaluator=evaluator, attrs_only=False) return members.get(name, UninhabitedType()) @@ -649,9 +647,7 @@ def _eval_get_member_type( if name is None: return UninhabitedType() - members = _get_members_dict( - target_arg, evaluator=evaluator, attrs_only=False, skip_stubs=False - ) + members = _get_members_dict(target_arg, evaluator=evaluator, attrs_only=False) member = members.get(name) if member is not None: # Extract the type argument (index 1) from Member[name, typ, quals, init, definer] @@ -813,20 +809,30 @@ def _eval_members_impl( return evaluator.tuple_type(list(members.values())) +def _should_skip_type_info(type_info: TypeInfo, api: SemanticAnalyzerInterface) -> bool: + """Determine whether to skip a type_info when collecting members. + + HACK: The rules here need to be more clearly defined. For now, we skip + anything from a stub file except typing.Member (which needs to be + introspectable for GetMemberType/dot notation on Member instances). + """ + # TODO: figure out the real rules for this + if type_info.fullname == "typing.Member" or type_info.fullname == "_typeshed.typemap.Member": + return False + module = api.modules.get(type_info.module_name) + if module is not None and module.is_stub: + return True + return False + + def _get_members_dict( - target_arg: Type, *, evaluator: TypeLevelEvaluator, attrs_only: bool, skip_stubs: bool = True + target_arg: Type, *, evaluator: TypeLevelEvaluator, attrs_only: bool ) -> dict[str, Type]: """Build a dict of member name -> Member type for all members of target. Args: attrs_only: If True, filter to attributes only (excludes methods). If False, include all members. - skip_stubs: If True, skip members defined in stub files. - This is wrong -- we should have a better way to filter - out inherited object/builtins noise in Members/Attrs - without hiding all stub-defined members. But for now, - Members/Attrs pass True and GetMember/GetMemberType - pass False. Returns a dict mapping member names to Member[name, typ, quals, init, definer] instance types. @@ -845,10 +851,8 @@ def _get_members_dict( # Iterate through MRO in reverse (base classes first) to include inherited members for type_info in reversed(target.type.mro): - if skip_stubs: - module = evaluator.api.modules.get(type_info.module_name) - if module is not None and module.is_stub: - continue + if _should_skip_type_info(type_info, evaluator.api): + continue for name, sym in type_info.names.items(): if sym.type is None: From 4512c8f20b8aa4cc0943f4b6b6454d7a192227af Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Mon, 16 Mar 2026 19:39:42 -0700 Subject: [PATCH 135/161] Implement _NewCallable type operator for extended callable syntax Callable[Params[Param[...], ...], RetType] is desugared to _NewCallable[Param[...], ..., RetType] during type analysis, then evaluated to a CallableType by the _NewCallable operator. --- mypy/typeanal.py | 10 +++ mypy/typelevel.py | 74 ++++++++++++++++++++ mypy/typeshed/stdlib/_typeshed/typemap.pyi | 17 +++++ mypy/typeshed/stdlib/typing.pyi | 3 + test-data/unit/check-typelevel-basic.test | 80 ++++++++++++++++++++++ test-data/unit/fixtures/typing-full.pyi | 13 ++++ 6 files changed, 197 insertions(+) diff --git a/mypy/typeanal.py b/mypy/typeanal.py index 85500be219d39..c2dc9c36f4fdf 100644 --- a/mypy/typeanal.py +++ b/mypy/typeanal.py @@ -1688,6 +1688,16 @@ def analyze_callable_type(self, t: UnboundType) -> Type: ret = callable_with_ellipsis( AnyType(TypeOfAny.explicit), ret_type=ret_type, fallback=fallback ) + elif isinstance(callable_args, UnboundType) and self.refers_to_full_names( + callable_args, ["typing.Params", "_typeshed.typemap.Params"] + ): + # Callable[Params[...], RET] - extended callable syntax. + # Rewrite to _NewCallable[...params..., ret_type] type operator. + items = self.anal_array(list(callable_args.args) + [ret_type], allow_unpack=True) + operator = self.lookup_fully_qualified("typing._NewCallable") + assert operator and isinstance(operator.node, TypeInfo) + obj_fallback = self.named_type("builtins.object") + return TypeOperatorType(operator.node, items, obj_fallback, t.line, t.column) else: # Callable[P, RET] (where P is ParamSpec) with self.tvar_scope_frame(namespace=""): diff --git a/mypy/typelevel.py b/mypy/typelevel.py index 5e46c3d21f41a..91aa2f3e32496 100644 --- a/mypy/typelevel.py +++ b/mypy/typelevel.py @@ -21,7 +21,14 @@ from mypy.maptype import map_instance_to_supertype from mypy.mro import calculate_mro from mypy.nodes import ( + ARG_NAMED, + ARG_NAMED_OPT, + ARG_OPT, + ARG_POS, + ARG_STAR, + ARG_STAR2, MDEF, + ArgKind, Block, ClassDef, Context, @@ -35,6 +42,7 @@ from mypy.typeops import make_simplified_union, tuple_fallback from mypy.types import ( AnyType, + CallableType, ComputedType, Instance, LiteralType, @@ -625,6 +633,72 @@ def _eval_new_union(*args: Type, evaluator: TypeLevelEvaluator) -> Type: return make_simplified_union(list(args)) +@register_operator("_NewCallable") +def _eval_new_callable(*args: Type, evaluator: TypeLevelEvaluator) -> Type: + """Evaluate _NewCallable[Param1, Param2, ..., ReturnType] -> CallableType. + + The last argument is the return type. All preceding arguments should be + Param[name, type, quals] instances describing the callable's parameters. + """ + if len(args) == 0: + return AnyType(TypeOfAny.from_error) + + ret_type = args[-1] + param_args = args[:-1] + + arg_types: list[Type] = [] + arg_kinds: list[ArgKind] = [] + arg_names: list[str | None] = [] + + for param in param_args: + param = evaluator.eval_proper(param) + if not isinstance(param, Instance): + # Not a Param instance, skip + continue + if param.type.fullname not in ("typing.Param", "_typeshed.typemap.Param"): + continue + + # Param[name, type, quals] + p_args = param.args + if len(p_args) < 2: + continue + + name = extract_literal_string(get_proper_type(p_args[0])) + param_type = p_args[1] + quals = extract_qualifier_strings(p_args[2]) if len(p_args) > 2 else [] + + qual_set = set(quals) + if "*" in qual_set: + arg_kinds.append(ARG_STAR) + arg_names.append(None) + elif "**" in qual_set: + arg_kinds.append(ARG_STAR2) + arg_names.append(None) + elif "keyword" in qual_set: + if "default" in qual_set: + arg_kinds.append(ARG_NAMED_OPT) + else: + arg_kinds.append(ARG_NAMED) + arg_names.append(name) + elif "default" in qual_set: + arg_kinds.append(ARG_OPT) + arg_names.append(name) + else: + arg_kinds.append(ARG_POS) + arg_names.append(name) + + arg_types.append(param_type) + + fallback = evaluator.api.named_type("builtins.function") + return CallableType( + arg_types=arg_types, + arg_kinds=arg_kinds, + arg_names=arg_names, + ret_type=ret_type, + fallback=fallback, + ) + + @register_operator("GetMember") @lift_over_unions def _eval_get_member(target_arg: Type, name_arg: Type, *, evaluator: TypeLevelEvaluator) -> Type: diff --git a/mypy/typeshed/stdlib/_typeshed/typemap.pyi b/mypy/typeshed/stdlib/_typeshed/typemap.pyi index bd8f40ac59962..24e87c4334e06 100644 --- a/mypy/typeshed/stdlib/_typeshed/typemap.pyi +++ b/mypy/typeshed/stdlib/_typeshed/typemap.pyi @@ -87,6 +87,14 @@ NamedDefaultParam = Param[_N, _T, Literal["keyword", "default"]] ArgsParam = Param[None, _T, Literal["*"]] KwargsParam = Param[None, _T, Literal["**"]] +class Params(Generic[Unpack[_Ts]]): + """ + Wraps a sequence of Param types as the first argument to Callable + to distinguish extended callable format from the standard format. + """ + + ... + # --- Type Introspection Operators --- _Base = TypeVar("_Base") @@ -206,6 +214,15 @@ class _NewUnion(Generic[Unpack[_Ts]]): ... +@_type_operator +class _NewCallable(Generic[Unpack[_Ts]]): + """ + Construct a callable type from Param types and a return type. + _NewCallable[Param[...], ..., ReturnType] evaluates to a Callable. + """ + + ... + # --- Boolean/Conditional Operators --- @_type_operator diff --git a/mypy/typeshed/stdlib/typing.pyi b/mypy/typeshed/stdlib/typing.pyi index 4f4879713d589..2a2e36e94081f 100644 --- a/mypy/typeshed/stdlib/typing.pyi +++ b/mypy/typeshed/stdlib/typing.pyi @@ -1192,6 +1192,7 @@ if sys.version_info >= (3, 13): # HACK: Always import because its used in mypy internals. # FIXME: Don't put this weird internals stuff here. from _typeshed.typemap import ( + _NewCallable as _NewCallable, _NewUnion as _NewUnion, ) @@ -1231,6 +1232,7 @@ if sys.version_info >= (3, 15): "NamedDefaultParam", "ArgsParam", "KwargsParam", + "Params", # Accessors "GetName", "GetType", @@ -1279,6 +1281,7 @@ if sys.version_info >= (3, 15): NewProtocol as NewProtocol, NewTypedDict as NewTypedDict, Param as Param, + Params as Params, ParamQuals as ParamQuals, PosDefaultParam as PosDefaultParam, PosParam as PosParam, diff --git a/test-data/unit/check-typelevel-basic.test b/test-data/unit/check-typelevel-basic.test index de610a67bf000..fe0348ac438ec 100644 --- a/test-data/unit/check-typelevel-basic.test +++ b/test-data/unit/check-typelevel-basic.test @@ -1192,5 +1192,85 @@ draw(nap) # E: Argument 1 to "draw" has incompatible type "NotAPoint"; expected +[builtins fixtures/typelevel.pyi] +[typing fixtures/typing-full.pyi] + +[case testNewCallableBasic] +from typing import Callable, Param, Params, Literal + +# Basic callable with positional args +f1: Callable[Params[Param[Literal["a"], int], Param[Literal["b"], str]], bool] +reveal_type(f1) # N: Revealed type is "def (a: builtins.int, b: builtins.str) -> builtins.bool" + +[builtins fixtures/typelevel.pyi] +[typing fixtures/typing-full.pyi] + +[case testNewCallableWithKwargs] +from typing import Callable, Param, Params, Literal + +# Callable with keyword-only args +f: Callable[Params[ + Param[Literal["a"], int], + Param[Literal["b"], str, Literal["keyword"]], +], bool] +reveal_type(f) # N: Revealed type is "def (a: builtins.int, *, b: builtins.str) -> builtins.bool" + +[builtins fixtures/typelevel.pyi] +[typing fixtures/typing-full.pyi] + +[case testNewCallableWithDefaults] +from typing import Callable, Param, Params, Literal + +# Callable with default args +f: Callable[Params[ + Param[Literal["a"], int], + Param[Literal["b"], str, Literal["default"]], +], bool] +reveal_type(f) # N: Revealed type is "def (a: builtins.int, b: builtins.str =) -> builtins.bool" + +[builtins fixtures/typelevel.pyi] +[typing fixtures/typing-full.pyi] + +[case testNewCallableWithStarArgs] +from typing import Callable, Param, Params, Literal + +# Callable with *args and **kwargs +f: Callable[Params[ + Param[Literal["a"], int], + Param[None, int, Literal["*"]], + Param[None, str, Literal["**"]], +], bool] +reveal_type(f) # N: Revealed type is "def (a: builtins.int, *builtins.int, **builtins.str) -> builtins.bool" + +[builtins fixtures/typelevel.pyi] +[typing fixtures/typing-full.pyi] + +[case testNewCallableComplex] +from typing import Callable, Param, Params, Literal + +# Complex callable matching PEP example +f: Callable[Params[ + Param[Literal["a"], int, Literal["positional"]], + Param[Literal["b"], int], + Param[Literal["c"], int, Literal["default"]], + Param[None, int, Literal["*"]], + Param[Literal["d"], int, Literal["keyword"]], + Param[Literal["e"], int, Literal["default", "keyword"]], + Param[None, int, Literal["**"]], +], int] +reveal_type(f) # N: Revealed type is "def (a: builtins.int, b: builtins.int, c: builtins.int =, *builtins.int, d: builtins.int, e: builtins.int =, **builtins.int) -> builtins.int" + +[builtins fixtures/typelevel.pyi] +[typing fixtures/typing-full.pyi] + +[case testNewCallableAssignment] +from typing import Callable, Param, Params, Literal + +def real_func(a: int, b: str) -> bool: + return True + +f: Callable[Params[Param[Literal["a"], int], Param[Literal["b"], str]], bool] +f = real_func # OK + [builtins fixtures/typelevel.pyi] [typing fixtures/typing-full.pyi] diff --git a/test-data/unit/fixtures/typing-full.pyi b/test-data/unit/fixtures/typing-full.pyi index 7caf9ab00636c..2f6ee37a34b15 100644 --- a/test-data/unit/fixtures/typing-full.pyi +++ b/test-data/unit/fixtures/typing-full.pyi @@ -309,6 +309,9 @@ class NewProtocol(Generic[Unpack[_Ts]]): ... @_type_operator class _NewUnion(Generic[Unpack[_Ts]]): ... +@_type_operator +class _NewCallable(Generic[Unpack[_Ts]]): ... + # Member data type for type-level computation _Name = TypeVar('_Name') _Type = TypeVar('_Type') @@ -316,6 +319,16 @@ _Quals = TypeVar("_Quals", default=Never) _Init = TypeVar("_Init", default=Never) _Definer = TypeVar("_Definer", default=Never) +_PQuals = TypeVar("_PQuals", default=Never) + +class Param(Generic[_Name, _Type, _PQuals]): + """Represents a function parameter for extended callable syntax.""" + name: _Name + type: _Type + quals: _PQuals + +class Params(Generic[Unpack[_Ts]]): ... + class Member(Generic[_Name, _Type, _Quals, _Init, _Definer]): """ Represents a class member with name, type, qualifiers, initializer, and definer. From fa562e559aa35a194219ac6bd0a5097a39da0eca Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Mon, 16 Mar 2026 20:32:10 -0700 Subject: [PATCH 136/161] Support GetArg on Callable and .name/.type/.quals on Param - _get_args now handles CallableType targets, converting params to Param[name, type, quals] instances with subtype checking - _eval_type_get_attr accepts Param in addition to Member for dot notation - _should_skip_type_info allows Param through (like Member) so its declared attributes are introspectable via GetMemberType --- mypy/typelevel.py | 61 ++++++++++++++++++++-- test-data/unit/check-typelevel-basic.test | 62 +++++++++++++++++++++++ 2 files changed, 118 insertions(+), 5 deletions(-) diff --git a/mypy/typelevel.py b/mypy/typelevel.py index 91aa2f3e32496..bc112af1a6c91 100644 --- a/mypy/typelevel.py +++ b/mypy/typelevel.py @@ -542,13 +542,55 @@ def extract_qualifier_strings(typ: Type) -> list[str]: return qual_strings +def _callable_to_params(evaluator: TypeLevelEvaluator, target: CallableType) -> list[Type]: + """Convert a CallableType's parameters into a list of Param[name, type, quals] instances.""" + param_info = evaluator.get_typemap_type("Param") + never = UninhabitedType() + params: list[Type] = [] + + for arg_type, arg_kind, arg_name in zip(target.arg_types, target.arg_kinds, target.arg_names): + if arg_name is not None: + name_type: Type = evaluator.literal_str(arg_name) + else: + name_type = NoneType() + + quals: list[str] = [] + if arg_kind == ARG_POS: + pass # no quals + elif arg_kind == ARG_OPT: + quals.append("default") + elif arg_kind == ARG_STAR: + quals.append("*") + elif arg_kind == ARG_NAMED: + quals.append("keyword") + elif arg_kind == ARG_NAMED_OPT: + quals.extend(["keyword", "default"]) + elif arg_kind == ARG_STAR2: + quals.append("**") + + if quals: + quals_type: Type = make_simplified_union([evaluator.literal_str(q) for q in quals]) + else: + quals_type = never + + params.append(Instance(param_info.type, [name_type, arg_type, quals_type])) + + return params + + def _get_args(evaluator: TypeLevelEvaluator, target: Type, base: Type) -> Sequence[Type] | None: target = evaluator.eval_proper(target) base = evaluator.eval_proper(base) # TODO: Other cases: - # * Callable (and Parameters) # * Overloaded + # * classmethod/staticmethod (decorated callables) + + if isinstance(target, CallableType) and isinstance(base, CallableType): + if not is_subtype(target, base): + return None + params = _callable_to_params(evaluator, target) + return [evaluator.tuple_type(params), target.ret_type] if isinstance(target, Instance) and isinstance(base, Instance): # TODO: base.is_protocol!! @@ -743,17 +785,21 @@ def _eval_type_get_attr( target = evaluator.eval_proper(target_arg) member_info = evaluator.get_typemap_type("Member") - if not isinstance(target, Instance) or target.type != member_info.type: + param_info = evaluator.get_typemap_type("Param") + if not isinstance(target, Instance) or target.type not in ( + member_info.type, + param_info.type, + ): name_type = evaluator.eval_proper(name_arg) name = extract_literal_string(name_type) evaluator.api.fail( - f"Dot notation .{name} requires a Member type, got {target}", + f"Dot notation .{name} requires a Member or Param type, got {target}", evaluator.error_ctx, serious=True, ) return UninhabitedType() - # Direct call bypasses union lifting, which is correct for Member access + # Direct call bypasses union lifting, which is correct for Member/Param access return _eval_get_member_type(target_arg, name_arg, evaluator=evaluator) @@ -891,7 +937,12 @@ def _should_skip_type_info(type_info: TypeInfo, api: SemanticAnalyzerInterface) introspectable for GetMemberType/dot notation on Member instances). """ # TODO: figure out the real rules for this - if type_info.fullname == "typing.Member" or type_info.fullname == "_typeshed.typemap.Member": + if type_info.fullname in ( + "typing.Member", + "_typeshed.typemap.Member", + "typing.Param", + "_typeshed.typemap.Param", + ): return False module = api.modules.get(type_info.module_name) if module is not None and module.is_stub: diff --git a/test-data/unit/check-typelevel-basic.test b/test-data/unit/check-typelevel-basic.test index fe0348ac438ec..90d1204ab5e64 100644 --- a/test-data/unit/check-typelevel-basic.test +++ b/test-data/unit/check-typelevel-basic.test @@ -1274,3 +1274,65 @@ f = real_func # OK [builtins fixtures/typelevel.pyi] [typing fixtures/typing-full.pyi] + +[case testGetArgCallableBasic] +from typing import Callable, Param, Params, GetArg, Literal + +# GetArg[Callable, Callable, 0] returns tuple of Params +a0: GetArg[Callable[[int, str], bool], Callable, Literal[0]] +reveal_type(a0) # N: Revealed type is "tuple[typing.Param[None, builtins.int, Never], typing.Param[None, builtins.str, Never]]" + +# GetArg[Callable, Callable, 1] returns return type +a1: GetArg[Callable[[int, str], bool], Callable, Literal[1]] +reveal_type(a1) # N: Revealed type is "builtins.bool" + +[builtins fixtures/typelevel.pyi] +[typing fixtures/typing-full.pyi] + +[case testGetArgCallableWithNames] +from typing import Callable, Param, Params, GetArg, Literal + +# Extended callable with named params roundtrips through GetArg +F = Callable[Params[ + Param[Literal["x"], int], + Param[Literal["y"], str, Literal["keyword"]], +], bool] +a0: GetArg[F, Callable, Literal[0]] +reveal_type(a0) # N: Revealed type is "tuple[typing.Param[Literal['x'], builtins.int, Never], typing.Param[Literal['y'], builtins.str, Literal['keyword']]]" + +a1: GetArg[F, Callable, Literal[1]] +reveal_type(a1) # N: Revealed type is "builtins.bool" + +[builtins fixtures/typelevel.pyi] +[typing fixtures/typing-full.pyi] + +[case testParamDotNotation] +from typing import Param, Literal + +# Dot notation on subscripted Param +n: Param[Literal["x"], int, Literal["keyword"]].name +reveal_type(n) # N: Revealed type is "Literal['x']" + +t: Param[Literal["x"], int, Literal["keyword"]].type +reveal_type(t) # N: Revealed type is "builtins.int" + +q: Param[Literal["x"], int, Literal["keyword"]].quals +reveal_type(q) # N: Revealed type is "Literal['keyword']" + +[builtins fixtures/typelevel.pyi] +[typing fixtures/typing-full.pyi] + +[case testParamGetMemberType] +from typing import Param, GetMemberType, Literal + +a: GetMemberType[Param[Literal["x"], int, Literal["keyword"]], Literal["name"]] +reveal_type(a) # N: Revealed type is "Literal['x']" + +b: GetMemberType[Param[Literal["x"], int, Literal["keyword"]], Literal["type"]] +reveal_type(b) # N: Revealed type is "builtins.int" + +c: GetMemberType[Param[Literal["x"], int, Literal["keyword"]], Literal["quals"]] +reveal_type(c) # N: Revealed type is "Literal['keyword']" + +[builtins fixtures/typelevel.pyi] +[typing fixtures/typing-full.pyi] From 7b7e69c3230652048adc70e21900803fb6ae55d5 Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Mon, 16 Mar 2026 21:16:05 -0700 Subject: [PATCH 137/161] Add InitFnType example test for extended callables --- test-data/unit/check-typelevel-examples.test | 83 ++++++++++++++++++++ 1 file changed, 83 insertions(+) diff --git a/test-data/unit/check-typelevel-examples.test b/test-data/unit/check-typelevel-examples.test index 7be9f1df450b3..b5d7f8dd9d2a8 100644 --- a/test-data/unit/check-typelevel-examples.test +++ b/test-data/unit/check-typelevel-examples.test @@ -684,5 +684,88 @@ upd_types: tuple[*[typing.GetType[p] for p in typing.Iter[typing.Members[HeroUpd reveal_type(upd_types) # N: Revealed type is "tuple[builtins.str | None, builtins.int | None | None, builtins.str | None]" +[builtins fixtures/typelevel.pyi] +[typing fixtures/typing-full.pyi] + + +[case testTypeLevel_initfntype] +# flags: --python-version 3.14 + +from typing import ( + Callable, + Literal, + ReadOnly, + TypedDict, + Never, + Self, +) + +import typing + + +class FieldArgs(TypedDict, total=False): + default: ReadOnly[object] + + +class Field[T: FieldArgs](typing.InitField[T]): + pass + + +# Extract the default value from a Field's init arg. +# If the init is a Field, pull out "default"; otherwise use the init itself. +type GetDefault[Init] = ( + typing.GetMemberType[ + typing.GetArg[Init, typing.InitField, Literal[0]], + Literal["default"], + ] + if typing.IsAssignable[Init, Field] + else Init +) + + +# Generate a Member for __init__ from a class's attributes. +# All params are keyword-only; params with defaults get Literal["keyword", "default"]. +type InitFnType[T] = typing.Member[ + Literal["__init__"], + Callable[ + typing.Params[ + typing.Param[Literal["self"], T], + *[ + typing.Param[ + p.name, + p.typ, + Literal["keyword"] + if typing.IsAssignable[ + GetDefault[p.init], + Never, + ] + else Literal["keyword", "default"], + ] + for p in typing.Iter[typing.Attrs[T]] + ], + ], + None, + ], + Literal["ClassVar"], +] + +# Use InitFnType to build a protocol with __init__ + all existing members +type AddInit[T] = typing.NewProtocol[ + InitFnType[T], + *[x for x in typing.Iter[typing.Members[T]]], +] + + +class Hero: + id: int | None = None + name: str + age: int | None = Field(default=None) + secret_name: str + + +h: AddInit[Hero] +reveal_type(h) # N: Revealed type is "NewProtocol[__init__: ClassVar[def (self: __main__.Hero, *, id: builtins.int | None =, name: builtins.str, age: builtins.int | None =, secret_name: builtins.str)], id: builtins.int | None = None, name: builtins.str, age: builtins.int | None = ..., secret_name: builtins.str]" + + [builtins fixtures/typelevel.pyi] [typing fixtures/typing-full.pyi] From bd01da0586392ce9883bd4300e7c29f87974181c Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Tue, 17 Mar 2026 10:05:05 -0700 Subject: [PATCH 138/161] Implement UpdateClass type operator UpdateClass[*Ps: Member] mutates an existing nominal class by adding, overriding, or removing members. Supported as the return type of class decorators and __init_subclass__. - Add UpdateClass stub in typemap.pyi, re-export from typing.pyi - Register UpdateClass operator in typelevel.py; the operator itself just returns NoneType (the real work is done by evaluate_update_class) - Add evaluate_update_class() entrypoint that evaluates the type args and returns a list of MemberDef namedtuples - Factor out _extract_members and _build_synthetic_typeinfo shared between NewProtocol and UpdateClass - Apply UpdateClass effects during post-semanal pass (semanal_main.py) for both decorators and __init_subclass__ - Extend is_valid_constructor/type_object_type to handle Var with callable type (typeops.py), so UpdateClass-added __init__ works - Allow UpdateClass return type on __init_subclass__ (checker.py) Known limitation: init_type (default values) not available during semanal, so UpdateClass-added __init__ params are all required. --- mypy/checker.py | 7 + mypy/semanal_main.py | 109 ++++++++++++- mypy/typelevel.py | 163 ++++++++++++------- mypy/typeops.py | 18 +- mypy/typeshed/stdlib/_typeshed/typemap.pyi | 10 ++ mypy/typeshed/stdlib/typing.pyi | 2 + test-data/unit/check-typelevel-examples.test | 160 ++++++++++++++++++ test-data/unit/fixtures/typing-full.pyi | 3 + 8 files changed, 406 insertions(+), 66 deletions(-) diff --git a/mypy/checker.py b/mypy/checker.py index fa6f7b59c2a8a..73914ec5b5628 100644 --- a/mypy/checker.py +++ b/mypy/checker.py @@ -222,6 +222,7 @@ TypedDictType, TypeGuardedType, TypeOfAny, + TypeOperatorType, TypeTranslator, TypeType, TypeVarId, @@ -1573,6 +1574,12 @@ def check_funcdef_item( and fdef.name in ("__init__", "__init_subclass__") and not isinstance(get_proper_type(typ.ret_type), (NoneType, UninhabitedType)) and not self.dynamic_funcs[-1] + # Allow UpdateClass return type for __init_subclass__ + and not ( + fdef.name == "__init_subclass__" + and isinstance(typ.ret_type, TypeOperatorType) + and typ.ret_type.type.name == "UpdateClass" + ) ): self.fail(message_registry.MUST_HAVE_NONE_RETURN_TYPE.format(fdef.name), item) diff --git a/mypy/semanal_main.py b/mypy/semanal_main.py index 6c2f51b39eb12..e957bc3f64b7d 100644 --- a/mypy/semanal_main.py +++ b/mypy/semanal_main.py @@ -34,7 +34,16 @@ import mypy.state from mypy.checker import FineGrainedDeferredNode from mypy.errors import Errors -from mypy.nodes import Decorator, FuncDef, MypyFile, OverloadedFuncDef, TypeInfo, Var +from mypy.nodes import ( + MDEF, + Decorator, + FuncDef, + MypyFile, + OverloadedFuncDef, + SymbolTableNode, + TypeInfo, + Var, +) from mypy.options import Options from mypy.plugin import ClassDefContext from mypy.plugins import dataclasses as dataclasses_plugin @@ -539,9 +548,107 @@ def apply_hooks_to_class( # an Expression for reason ok = ok and dataclasses_plugin.DataclassTransformer(defn, defn, spec, self).transform() + # Apply UpdateClass effects from decorators and __init_subclass__ + with self.file_context(file_node, options, info): + _apply_update_class_effects(self, info) + return ok +def _apply_update_class_effects(self: SemanticAnalyzer, info: TypeInfo) -> None: + """Apply UpdateClass effects from decorators and __init_subclass__ to a class.""" + from mypy.expandtype import expand_type + from mypy.nodes import RefExpr + from mypy.typelevel import MemberDef, evaluate_update_class + from mypy.types import ( + CallableType, + Instance, + TypeOperatorType, + TypeType, + TypeVarType, + UninhabitedType, + get_proper_type, + ) + from mypy.typevars import fill_typevars + + defn = info.defn + + def _get_class_instance() -> Instance: + inst = fill_typevars(info) + if isinstance(inst, Instance): + return inst + # TupleType fallback + return inst.partial_fallback + + def _resolve_and_apply(func_type: CallableType) -> None: + """Check if func_type returns UpdateClass, resolve it, and apply to info.""" + ret_type = func_type.ret_type + # Don't use get_proper_type here — it would eagerly expand the TypeOperatorType + if not isinstance(ret_type, TypeOperatorType): + return + if ret_type.type.name != "UpdateClass": + return + + # Build substitution map: find type[T] in the first arg and bind T to class instance + env = {} + if func_type.arg_types: + first_arg = get_proper_type(func_type.arg_types[0]) + if isinstance(first_arg, TypeType) and isinstance(first_arg.item, TypeVarType): + env[first_arg.item.id] = _get_class_instance() + + # Substitute type vars in return type + if env: + ret_type = expand_type(ret_type, env) + + assert isinstance(ret_type, TypeOperatorType) + members = evaluate_update_class(ret_type, self, defn) + if members is not None: + _apply_members(members) + + def _apply_members(members: list[MemberDef]) -> None: + for m in members: + if isinstance(get_proper_type(m.type), UninhabitedType): + # Never-typed members mean "remove this member" + info.names.pop(m.name, None) + else: + var = Var(m.name, m.type) + var.info = info + var._fullname = f"{info.fullname}.{m.name}" + var.is_classvar = m.is_classvar + var.is_final = m.is_final + var.is_initialized_in_class = True + var.init_type = m.init_type + var.is_inferred = False + info.names[m.name] = SymbolTableNode(MDEF, var) + + def _get_func_type( + node: FuncDef | Decorator | OverloadedFuncDef | None, + ) -> CallableType | None: + if isinstance(node, Decorator): + node = node.func + if isinstance(node, FuncDef) and isinstance(node.type, CallableType): + return node.type + return None + + # Apply UpdateClass from decorators + for decorator in defn.decorators: + if isinstance(decorator, RefExpr) and decorator.node is not None: + node = decorator.node + if isinstance(node, (FuncDef, Decorator, OverloadedFuncDef)): + func_type = _get_func_type(node) + if func_type is not None: + _resolve_and_apply(func_type) + + # Apply UpdateClass from __init_subclass__ (reverse MRO order, per PEP spec) + for base in reversed(info.mro[1:]): + if "__init_subclass__" not in base.names: + continue + sym = base.names["__init_subclass__"] + func_type = _get_func_type(sym.node) # type: ignore[arg-type] + if func_type is not None: + _resolve_and_apply(func_type) + + def calculate_class_properties(graph: Graph, scc: list[str], errors: Errors) -> None: builtins = graph["builtins"].tree assert builtins diff --git a/mypy/typelevel.py b/mypy/typelevel.py index bc112af1a6c91..c200cb85db6e5 100644 --- a/mypy/typelevel.py +++ b/mypy/typelevel.py @@ -15,7 +15,7 @@ from collections.abc import Callable, Iterator, Sequence from contextlib import contextmanager from dataclasses import dataclass -from typing import TYPE_CHECKING, Final +from typing import TYPE_CHECKING, Final, NamedTuple from mypy.expandtype import expand_type, expand_type_by_instance from mypy.maptype import map_instance_to_supertype @@ -786,10 +786,7 @@ def _eval_type_get_attr( member_info = evaluator.get_typemap_type("Member") param_info = evaluator.get_typemap_type("Param") - if not isinstance(target, Instance) or target.type not in ( - member_info.type, - param_info.type, - ): + if not isinstance(target, Instance) or target.type not in (member_info.type, param_info.type): name_type = evaluator.eval_proper(name_arg) name = extract_literal_string(name_type) evaluator.api.fail( @@ -1146,54 +1143,48 @@ def _eval_new_typeddict(*args: Type, evaluator: TypeLevelEvaluator) -> Type: ) -@register_operator("NewProtocol") -def _eval_new_protocol(*args: Type, evaluator: TypeLevelEvaluator) -> Type: - """Evaluate NewProtocol[*Members] -> create a new structural protocol type. +class MemberDef(NamedTuple): + """Extracted member definition from a Member[name, typ, quals, init, definer] type.""" - This creates a synthetic protocol class with members defined by the Member arguments. - The protocol type uses structural subtyping. - """ + name: str + type: Type + init_type: Type + is_classvar: bool + is_final: bool - # TODO: methods are probably in bad shape - # Get the Member TypeInfo to verify arguments - member_info = evaluator.get_typemap_type("Member") +def _extract_members( + args: tuple[Type, ...], evaluator: TypeLevelEvaluator, *, eval_types: bool = False +) -> list[MemberDef] | None: + """Extract member definitions from Member type arguments. - # Get object type for the base class - # N.B: We don't inherit from Protocol directly because Protocol is not always - # a TypeInfo in test fixtures. Instead we just set is_protocol=True and inherit - # from object, which is how mypy handles protocols internally (the Protocol base - # is removed from bases but is_protocol is set). - object_type = evaluator.api.named_type("builtins.object") + Returns a list of MemberDef, or None if any argument is not a valid Member. - # Build the members dictionary - member_vars: dict[str, tuple[Type, Type, bool, bool]] = ( - {} - ) # name -> (type, init_type, is_classvar, is_final) + If eval_types is True, member types are eagerly evaluated via the + evaluator (needed for UpdateClass where the types are stored on a + real TypeInfo and must be fully resolved). + """ + member_info = evaluator.get_typemap_type("Member") + members: list[MemberDef] = [] for arg in args: arg = get_proper_type(arg) - # Each argument should be a Member[name, typ, quals, init, definer] if not isinstance(arg, Instance) or arg.type != member_info.type: - # Not a Member type - can't construct protocol - return UninhabitedType() - + return None if len(arg.args) < 2: - return UninhabitedType() + return None - # Extract name and type from Member args - name_type = arg.args[0] - name = extract_literal_string(name_type) + name = extract_literal_string(arg.args[0]) if name is None: - return UninhabitedType() + return None item_type = arg.args[1] + if eval_types: + item_type = evaluator.eval_proper(item_type) - # Check qualifiers if present is_classvar = False is_final = False - if len(arg.args) >= 3: for qual in extract_qualifier_strings(arg.args[2]): if qual == "ClassVar": @@ -1205,52 +1196,104 @@ def _eval_new_protocol(*args: Type, evaluator: TypeLevelEvaluator) -> Type: if len(arg.args) >= 4: init_type = arg.args[3] - member_vars[name] = (item_type, init_type, is_classvar, is_final) + members.append(MemberDef(name, item_type, init_type, is_classvar, is_final)) + + return members - protocol_name = "NewProtocol" - # Create the synthetic protocol TypeInfo +def _build_synthetic_typeinfo( + class_name: str, members: list[MemberDef], evaluator: TypeLevelEvaluator +) -> TypeInfo: + """Create a synthetic TypeInfo populated with members. + + Used by NewProtocol to build a TypeInfo carrying member definitions + extracted from Member type arguments. + """ # HACK: We create a ClassDef with an empty Block because TypeInfo requires one. - # This is purely synthetic and never actually executed. - class_def = ClassDef(protocol_name, Block([])) - class_def.fullname = f"__typelevel__.{protocol_name}" + class_def = ClassDef(class_name, Block([])) + class_def.fullname = f"__typelevel__.{class_name}" info = TypeInfo(SymbolTable(), class_def, "__typelevel__") class_def.info = info - # Mark as protocol - info.is_protocol = True - info.is_new_protocol = True - info.runtime_protocol = False # These aren't runtime checkable - - # Set up bases - inherit from object (not Protocol, since Protocol is just a marker) + object_type = evaluator.api.named_type("builtins.object") info.bases = [object_type] - - # HACK: Set up MRO manually since we don't have a full semantic analysis pass. - # For a protocol, the MRO should be: [this_protocol, object] try: calculate_mro(info) except Exception: - # If MRO calculation fails, set up a minimal one # HACK: Minimal MRO setup when calculate_mro fails info.mro = [info, object_type.type] - # Add members to the symbol table - for name, (member_type, init_type, is_classvar, is_final) in member_vars.items(): - var = Var(name, member_type) + for m in members: + var = Var(m.name, m.type) var.info = info - var._fullname = f"{info.fullname}.{name}" - var.is_classvar = is_classvar - var.is_final = is_final + var._fullname = f"{info.fullname}.{m.name}" + var.is_classvar = m.is_classvar + var.is_final = m.is_final var.is_initialized_in_class = True - var.init_type = init_type - # Don't mark as inferred since we have explicit types + var.init_type = m.init_type var.is_inferred = False - info.names[name] = SymbolTableNode(MDEF, var) + info.names[m.name] = SymbolTableNode(MDEF, var) + + return info + + +@register_operator("NewProtocol") +def _eval_new_protocol(*args: Type, evaluator: TypeLevelEvaluator) -> Type: + """Evaluate NewProtocol[*Members] -> create a new structural protocol type. + + This creates a synthetic protocol class with members defined by the Member arguments. + The protocol type uses structural subtyping. + """ + # TODO: methods are probably in bad shape + + members = _extract_members(args, evaluator) + if members is None: + return UninhabitedType() + + info = _build_synthetic_typeinfo("NewProtocol", members, evaluator) + + info.is_protocol = True + info.is_new_protocol = True + info.runtime_protocol = False return Instance(info, []) +@register_operator("UpdateClass") +def _eval_update_class(*args: Type, evaluator: TypeLevelEvaluator) -> Type: + """UpdateClass should not be evaluated as a normal type operator. + + It is only valid as the return type of a class decorator or + __init_subclass__, and is handled specially by semanal_main.py + via evaluate_update_class(). If we get here (e.g. via get_proper_type + expanding the return type annotation), return NoneType since the + decorated function/method semantically returns None at runtime. + Returning Never here would cause the checker to treat subsequent + code as unreachable. + """ + return NoneType() + + +def evaluate_update_class( + typ: TypeOperatorType, api: SemanticAnalyzerInterface, ctx: Context | None = None +) -> list[MemberDef] | None: + """Evaluate an UpdateClass TypeOperatorType and return its member definitions. + + Called from semanal_main.py during the post-semanal pass. Eagerly evaluates + member types so they are fully resolved before being stored on the target + class's TypeInfo. + + Returns None if evaluation fails. + """ + evaluator = TypeLevelEvaluator(api, ctx) + try: + args = evaluator.flatten_args(typ.args) + except (EvaluationStuck, EvaluationOverflow): + return None + return _extract_members(tuple(args), evaluator, eval_types=True) + + @register_operator("Length") @lift_over_unions def _eval_length(arg: Type, *, evaluator: TypeLevelEvaluator) -> Type: diff --git a/mypy/typeops.py b/mypy/typeops.py index 839c6454ca28f..5429ce8a704e8 100644 --- a/mypy/typeops.py +++ b/mypy/typeops.py @@ -173,8 +173,8 @@ def type_object_type(info: TypeInfo, named_type: Callable[[str], Instance]) -> P return AnyType(TypeOfAny.from_error) # The two is_valid_constructor() checks ensure this. - assert isinstance(new_method.node, (SYMBOL_FUNCBASE_TYPES, Decorator)) - assert isinstance(init_method.node, (SYMBOL_FUNCBASE_TYPES, Decorator)) + assert isinstance(new_method.node, (SYMBOL_FUNCBASE_TYPES, Decorator, Var)) + assert isinstance(init_method.node, (SYMBOL_FUNCBASE_TYPES, Decorator, Var)) init_index = info.mro.index(init_method.node.info) new_index = info.mro.index(new_method.node.info) @@ -189,7 +189,7 @@ def type_object_type(info: TypeInfo, named_type: Callable[[str], Instance]) -> P fallback = named_type("builtins.type") if init_index < new_index: - method: FuncBase | Decorator = init_method.node + method: FuncBase | Decorator | Var = init_method.node is_new = False elif init_index > new_index: method = new_method.node @@ -227,6 +227,12 @@ def type_object_type(info: TypeInfo, named_type: Callable[[str], Instance]) -> P # achieved in early return above because is_valid_constructor() is False. allow_cache = False t = function_type(method, fallback) + elif isinstance(method, Var): + # Var with callable type, e.g. from UpdateClass adding __init__ + assert method.type is not None + proper = get_proper_type(method.type) + assert isinstance(proper, FunctionLike) + t = proper else: assert isinstance(method.type, ProperType) assert isinstance(method.type, FunctionLike) # is_valid_constructor() ensures this @@ -242,13 +248,15 @@ def type_object_type(info: TypeInfo, named_type: Callable[[str], Instance]) -> P def is_valid_constructor(n: SymbolNode | None) -> bool: """Does this node represents a valid constructor method? - This includes normal functions, overloaded functions, and decorators - that return a callable type. + This includes normal functions, overloaded functions, decorators + that return a callable type, and Vars with callable types (e.g. from UpdateClass). """ if isinstance(n, SYMBOL_FUNCBASE_TYPES): return True if isinstance(n, Decorator): return isinstance(get_proper_type(n.type), FunctionLike) + if isinstance(n, Var) and n.type is not None: + return isinstance(get_proper_type(n.type), FunctionLike) return False diff --git a/mypy/typeshed/stdlib/_typeshed/typemap.pyi b/mypy/typeshed/stdlib/_typeshed/typemap.pyi index 24e87c4334e06..f4d0526d6dcf6 100644 --- a/mypy/typeshed/stdlib/_typeshed/typemap.pyi +++ b/mypy/typeshed/stdlib/_typeshed/typemap.pyi @@ -205,6 +205,16 @@ class NewTypedDict(Generic[Unpack[_Ts]]): ... +@_type_operator +class UpdateClass(Generic[Unpack[_Ts]]): + """ + Update an existing class with new members. + Can only be used as the return type of a class decorator or __init_subclass__. + Members with type Never are removed from the class. + """ + + ... + @_type_operator class _NewUnion(Generic[Unpack[_Ts]]): """ diff --git a/mypy/typeshed/stdlib/typing.pyi b/mypy/typeshed/stdlib/typing.pyi index 2a2e36e94081f..c4fb7e99037fa 100644 --- a/mypy/typeshed/stdlib/typing.pyi +++ b/mypy/typeshed/stdlib/typing.pyi @@ -1208,6 +1208,7 @@ if sys.version_info >= (3, 15): "FromUnion", "NewProtocol", "NewTypedDict", + "UpdateClass", "IsAssignable", "IsEquivalent", "Bool", @@ -1280,6 +1281,7 @@ if sys.version_info >= (3, 15): NamedParam as NamedParam, NewProtocol as NewProtocol, NewTypedDict as NewTypedDict, + UpdateClass as UpdateClass, Param as Param, Params as Params, ParamQuals as ParamQuals, diff --git a/test-data/unit/check-typelevel-examples.test b/test-data/unit/check-typelevel-examples.test index b5d7f8dd9d2a8..cc8e8b4de90a1 100644 --- a/test-data/unit/check-typelevel-examples.test +++ b/test-data/unit/check-typelevel-examples.test @@ -769,3 +769,163 @@ reveal_type(h) # N: Revealed type is "NewProtocol[__init__: ClassVar[def (self: [builtins fixtures/typelevel.pyi] [typing fixtures/typing-full.pyi] + + +[case testUpdateClassBasicDecorator] +# flags: --python-version 3.14 +from typing import Literal, Never, Member, UpdateClass + +def add_x[T](cls: type[T]) -> UpdateClass[ + Member[Literal["x"], int], +]: + ... + +@add_x +class Foo: + y: str + +reveal_type(Foo.x) # N: Revealed type is "builtins.int" +reveal_type(Foo().y) # N: Revealed type is "builtins.str" + +[builtins fixtures/typelevel.pyi] +[typing fixtures/typing-full.pyi] + + +[case testUpdateClassRemoveMember] +# flags: --python-version 3.14 +from typing import Literal, Never, Member, UpdateClass + +def remove_y[T](cls: type[T]) -> UpdateClass[ + Member[Literal["y"], Never], +]: + ... + +@remove_y +class Foo: + x: int + y: str + +reveal_type(Foo().x) # N: Revealed type is "builtins.int" +Foo().y # E: "Foo" has no attribute "y" + +[builtins fixtures/typelevel.pyi] +[typing fixtures/typing-full.pyi] + + +[case testUpdateClassInitSubclass] +# flags: --python-version 3.14 +from typing import Literal, Never, Member, UpdateClass + +class Base: + def __init_subclass__[T](cls: type[T]) -> UpdateClass[ + Member[Literal["tag"], str], + ]: + ... + +class Child(Base): + x: int + +reveal_type(Child.tag) # N: Revealed type is "builtins.str" +reveal_type(Child().x) # N: Revealed type is "builtins.int" + +[builtins fixtures/typelevel.pyi] +[typing fixtures/typing-full.pyi] + + +[case testUpdateClassDataclassLike] +# flags: --python-version 3.14 + +from typing import ( + Callable, + Literal, + ReadOnly, + TypedDict, + Never, + UpdateClass, +) + +import typing + + +class FieldArgs(TypedDict, total=False): + default: ReadOnly[object] + + +class Field[T: FieldArgs](typing.InitField[T]): + pass + + +# Extract the default value from a Field's init arg. +# If the init is a Field, pull out "default"; otherwise use the init itself. +type GetDefault[Init] = ( + typing.GetMemberType[ + typing.GetArg[Init, typing.InitField, Literal[0]], + Literal["default"], + ] + if typing.IsAssignable[Init, Field] + else Init +) + + +# Generate a Member for __init__ from a class's attributes. +# All params are keyword-only; params with defaults get Literal["keyword", "default"]. +type InitFnType[T] = typing.Member[ + Literal["__init__"], + Callable[ + typing.Params[ + typing.Param[Literal["self"], T], + *[ + typing.Param[ + p.name, + p.typ, + Literal["keyword"] + if typing.IsAssignable[ + GetDefault[p.init], + Never, + ] + else Literal["keyword", "default"], + ] + for p in typing.Iter[typing.Attrs[T]] + ], + ], + None, + ], + Literal["ClassVar"], +] + + +def dataclass_ish[T](cls: type[T]) -> UpdateClass[InitFnType[T]]: + ... + + +@dataclass_ish +class Hero: + id: int | None = None + name: str + age: int | None = Field(default=None) + secret_name: str + + +# Note: init_type is not available during semanal (when UpdateClass runs), +# so all params are required. In the NewProtocol version (evaluated lazily +# during type checking), defaults ARE detected. +reveal_type(Hero.__init__) # N: Revealed type is "def (self: __main__.Hero, *, id: builtins.int | None, name: builtins.str, age: builtins.int | None, secret_name: builtins.str)" +Hero(id=None, name="Spider-Boy", age=None, secret_name="Pedro Parqueador") +h = Hero(id=1, name="Spider-Boy", age=16, secret_name="Pedro Parqueador") +reveal_type(h) # N: Revealed type is "__main__.Hero" + +# Also test a simpler UpdateClass with direct Member +def add_greet[T](cls: type[T]) -> UpdateClass[ + typing.Member[Literal["greet"], Callable[[T], str]], +]: + ... + +@add_greet +class Simple: + x: int + +reveal_type(Simple.greet) # N: Revealed type is "def (__main__.Simple) -> builtins.str" +reveal_type(Simple().greet) # N: Revealed type is "def (__main__.Simple) -> builtins.str" + +[builtins fixtures/typelevel.pyi] +[typing fixtures/typing-full.pyi] diff --git a/test-data/unit/fixtures/typing-full.pyi b/test-data/unit/fixtures/typing-full.pyi index 2f6ee37a34b15..14c3e52eca3c8 100644 --- a/test-data/unit/fixtures/typing-full.pyi +++ b/test-data/unit/fixtures/typing-full.pyi @@ -306,6 +306,9 @@ class NewTypedDict(Generic[Unpack[_Ts]]): ... @_type_operator class NewProtocol(Generic[Unpack[_Ts]]): ... +@_type_operator +class UpdateClass(Generic[Unpack[_Ts]]): ... + @_type_operator class _NewUnion(Generic[Unpack[_Ts]]): ... From 6e845c4d3622cc4c935d3c03ed311d07c88f0b84 Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Tue, 17 Mar 2026 10:45:07 -0700 Subject: [PATCH 139/161] Populate init_type during post-semanal for UpdateClass MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Normally init_type on class Vars is set during type checking, but UpdateClass needs it earlier (during the post-semanal pass) so that Attrs[T]/Members[T] can detect which fields have defaults. Add _populate_init_types() that runs just before UpdateClass evaluation: - None literals → NoneType - Simple constants → LiteralType via constant_fold_expr - Other expressions → var's declared type as non-Never placeholder Only runs when an UpdateClass decorator or __init_subclass__ is found, to avoid affecting classes that don't use UpdateClass. --- mypy/semanal_main.py | 68 ++++++++++++++++++++ test-data/unit/check-typelevel-examples.test | 10 +-- 2 files changed, 73 insertions(+), 5 deletions(-) diff --git a/mypy/semanal_main.py b/mypy/semanal_main.py index e957bc3f64b7d..dfc39bd82d607 100644 --- a/mypy/semanal_main.py +++ b/mypy/semanal_main.py @@ -555,6 +555,64 @@ def apply_hooks_to_class( return ok +def _populate_init_types(info: TypeInfo, api: SemanticAnalyzer) -> None: + """Populate init_type on class Vars that have explicit values. + + Normally init_type is set during type checking (checker.py), but + UpdateClass needs it during the post-semanal pass. We populate it + here from the AST expressions. + """ + from mypy.constant_fold import constant_fold_expr + from mypy.nodes import AssignmentStmt, NameExpr + from mypy.types import LiteralType, NoneType + + for stmt in info.defn.defs.body: + if not isinstance(stmt, AssignmentStmt): + continue + for lvalue in stmt.lvalues: + if not isinstance(lvalue, NameExpr) or not isinstance(lvalue.node, Var): + continue + var = lvalue.node + if not var.has_explicit_value or var.init_type is not None: + continue + + rvalue = stmt.rvalue + + # None literal + if isinstance(rvalue, NameExpr) and rvalue.fullname == "builtins.None": + var.init_type = NoneType() + continue + + # Simple constant literals (int, str, bool, float) + value = constant_fold_expr(rvalue, info.module_name) + if value is not None and not isinstance(value, complex): + if isinstance(value, bool): + type_name = "builtins.bool" + elif isinstance(value, int): + type_name = "builtins.int" + elif isinstance(value, str): + type_name = "builtins.str" + else: + assert isinstance(value, float) + type_name = "builtins.float" + fallback = api.named_type_or_none(type_name) + if fallback: + var.init_type = fallback.copy_modified( + last_known_value=LiteralType(value=value, fallback=fallback) + ) + continue + + # Fallback: use the var's declared type as a non-Never placeholder. + # This ensures fields with defaults are detected as optional, + # even if we can't determine the precise init_type. + # Full init_type inference (e.g. for Field(default=...)) requires + # type checking, which runs later; the NewProtocol/type alias path + # gets the precise init_type because it evaluates lazily during + # type checking. + if var.type is not None: + var.init_type = var.type + + def _apply_update_class_effects(self: SemanticAnalyzer, info: TypeInfo) -> None: """Apply UpdateClass effects from decorators and __init_subclass__ to a class.""" from mypy.expandtype import expand_type @@ -572,6 +630,7 @@ def _apply_update_class_effects(self: SemanticAnalyzer, info: TypeInfo) -> None: from mypy.typevars import fill_typevars defn = info.defn + init_types_populated = False def _get_class_instance() -> Instance: inst = fill_typevars(info) @@ -582,6 +641,8 @@ def _get_class_instance() -> Instance: def _resolve_and_apply(func_type: CallableType) -> None: """Check if func_type returns UpdateClass, resolve it, and apply to info.""" + nonlocal init_types_populated + ret_type = func_type.ret_type # Don't use get_proper_type here — it would eagerly expand the TypeOperatorType if not isinstance(ret_type, TypeOperatorType): @@ -589,6 +650,13 @@ def _resolve_and_apply(func_type: CallableType) -> None: if ret_type.type.name != "UpdateClass": return + # Populate init_type on class vars before evaluating, so that + # Attrs[T]/Members[T] can see defaults. Only do this once, and + # only when we actually have an UpdateClass to apply. + if not init_types_populated: + _populate_init_types(info, self) + init_types_populated = True + # Build substitution map: find type[T] in the first arg and bind T to class instance env = {} if func_type.arg_types: diff --git a/test-data/unit/check-typelevel-examples.test b/test-data/unit/check-typelevel-examples.test index cc8e8b4de90a1..9a54f1f858513 100644 --- a/test-data/unit/check-typelevel-examples.test +++ b/test-data/unit/check-typelevel-examples.test @@ -906,11 +906,11 @@ class Hero: secret_name: str -# Note: init_type is not available during semanal (when UpdateClass runs), -# so all params are required. In the NewProtocol version (evaluated lazily -# during type checking), defaults ARE detected. -reveal_type(Hero.__init__) # N: Revealed type is "def (self: __main__.Hero, *, id: builtins.int | None, name: builtins.str, age: builtins.int | None, secret_name: builtins.str)" -Hero(id=None, name="Spider-Boy", age=None, secret_name="Pedro Parqueador") +reveal_type(Hero.__init__) # N: Revealed type is "def (self: __main__.Hero, *, id: builtins.int | None =, name: builtins.str, age: builtins.int | None =, secret_name: builtins.str)" +Hero(name="Spider-Boy", secret_name="Pedro Parqueador") +Hero(name="Spider-Boy", secret_name="Pedro Parqueador", id=3) +Hero(name="Spider-Boy", secret_name="Pedro Parqueador", age=16) +Hero(name="Spider-Boy") # E: Missing named argument "secret_name" for "Hero" h = Hero(id=1, name="Spider-Boy", age=16, secret_name="Pedro Parqueador") reveal_type(h) # N: Revealed type is "__main__.Hero" From a263758e8c49e50b2530d84725a6274e3211b1ac Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Tue, 17 Mar 2026 10:55:49 -0700 Subject: [PATCH 140/161] Populate init_type during post-semanal for UpdateClass Normally init_type on class Vars is set during type checking, but UpdateClass needs it during the post-semanal pass so that Attrs[T]/Members[T] can detect which fields have defaults. Use the TypeChecker's expression checker (created lazily via state.type_checker()) to infer rvalue types, giving accurate results for all expressions including Field() calls. Only runs when an UpdateClass decorator or __init_subclass__ is actually found. --- mypy/semanal_main.py | 63 ++++++++++++++++---------------------------- 1 file changed, 22 insertions(+), 41 deletions(-) diff --git a/mypy/semanal_main.py b/mypy/semanal_main.py index dfc39bd82d607..782dc40544c77 100644 --- a/mypy/semanal_main.py +++ b/mypy/semanal_main.py @@ -508,6 +508,7 @@ def apply_class_plugin_hooks(graph: Graph, scc: list[str], errors: Errors) -> No state.options, tree, errors, + state, ): incomplete = True @@ -519,6 +520,7 @@ def apply_hooks_to_class( options: Options, file_node: MypyFile, errors: Errors, + state: State | None = None, ) -> bool: # TODO: Move more class-related hooks here? defn = info.defn @@ -550,21 +552,27 @@ def apply_hooks_to_class( # Apply UpdateClass effects from decorators and __init_subclass__ with self.file_context(file_node, options, info): - _apply_update_class_effects(self, info) + _apply_update_class_effects(self, info, state) return ok -def _populate_init_types(info: TypeInfo, api: SemanticAnalyzer) -> None: +def _populate_init_types(info: TypeInfo, state: State | None) -> None: """Populate init_type on class Vars that have explicit values. Normally init_type is set during type checking (checker.py), but - UpdateClass needs it during the post-semanal pass. We populate it - here from the AST expressions. + UpdateClass needs it during the post-semanal pass. We use the + TypeChecker's expression checker to infer the rvalue types, which + gives accurate results for all expressions (literals, None, Field() + calls, etc.). """ - from mypy.constant_fold import constant_fold_expr from mypy.nodes import AssignmentStmt, NameExpr - from mypy.types import LiteralType, NoneType + from mypy.types_utils import try_getting_literal + + if state is None: + return + + checker = state.type_checker() for stmt in info.defn.defs.body: if not isinstance(stmt, AssignmentStmt): @@ -576,44 +584,17 @@ def _populate_init_types(info: TypeInfo, api: SemanticAnalyzer) -> None: if not var.has_explicit_value or var.init_type is not None: continue - rvalue = stmt.rvalue - - # None literal - if isinstance(rvalue, NameExpr) and rvalue.fullname == "builtins.None": - var.init_type = NoneType() + try: + rvalue_type = checker.expr_checker.accept(stmt.rvalue) + except Exception: continue - # Simple constant literals (int, str, bool, float) - value = constant_fold_expr(rvalue, info.module_name) - if value is not None and not isinstance(value, complex): - if isinstance(value, bool): - type_name = "builtins.bool" - elif isinstance(value, int): - type_name = "builtins.int" - elif isinstance(value, str): - type_name = "builtins.str" - else: - assert isinstance(value, float) - type_name = "builtins.float" - fallback = api.named_type_or_none(type_name) - if fallback: - var.init_type = fallback.copy_modified( - last_known_value=LiteralType(value=value, fallback=fallback) - ) - continue - - # Fallback: use the var's declared type as a non-Never placeholder. - # This ensures fields with defaults are detected as optional, - # even if we can't determine the precise init_type. - # Full init_type inference (e.g. for Field(default=...)) requires - # type checking, which runs later; the NewProtocol/type alias path - # gets the precise init_type because it evaluates lazily during - # type checking. - if var.type is not None: - var.init_type = var.type + var.init_type = try_getting_literal(rvalue_type) -def _apply_update_class_effects(self: SemanticAnalyzer, info: TypeInfo) -> None: +def _apply_update_class_effects( + self: SemanticAnalyzer, info: TypeInfo, state: State | None = None +) -> None: """Apply UpdateClass effects from decorators and __init_subclass__ to a class.""" from mypy.expandtype import expand_type from mypy.nodes import RefExpr @@ -654,7 +635,7 @@ def _resolve_and_apply(func_type: CallableType) -> None: # Attrs[T]/Members[T] can see defaults. Only do this once, and # only when we actually have an UpdateClass to apply. if not init_types_populated: - _populate_init_types(info, self) + _populate_init_types(info, state) init_types_populated = True # Build substitution map: find type[T] in the first arg and bind T to class instance From 281d29bf4539261c078a631bfbf574856673bc16 Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Tue, 17 Mar 2026 10:58:55 -0700 Subject: [PATCH 141/161] Add Field() without default to UpdateClass test Verify that InitField inference distinguishes Field(default=None) (optional) from Field() (required), confirming the TypeChecker-based init_type population works for complex rvalue expressions. --- test-data/unit/check-typelevel-examples.test | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/test-data/unit/check-typelevel-examples.test b/test-data/unit/check-typelevel-examples.test index 9a54f1f858513..a77e6aa47671f 100644 --- a/test-data/unit/check-typelevel-examples.test +++ b/test-data/unit/check-typelevel-examples.test @@ -903,15 +903,16 @@ class Hero: id: int | None = None name: str age: int | None = Field(default=None) + score: float = Field() secret_name: str -reveal_type(Hero.__init__) # N: Revealed type is "def (self: __main__.Hero, *, id: builtins.int | None =, name: builtins.str, age: builtins.int | None =, secret_name: builtins.str)" -Hero(name="Spider-Boy", secret_name="Pedro Parqueador") -Hero(name="Spider-Boy", secret_name="Pedro Parqueador", id=3) -Hero(name="Spider-Boy", secret_name="Pedro Parqueador", age=16) -Hero(name="Spider-Boy") # E: Missing named argument "secret_name" for "Hero" -h = Hero(id=1, name="Spider-Boy", age=16, secret_name="Pedro Parqueador") +reveal_type(Hero.__init__) # N: Revealed type is "def (self: __main__.Hero, *, id: builtins.int | None =, name: builtins.str, age: builtins.int | None =, score: builtins.float, secret_name: builtins.str)" +Hero(name="Spider-Boy", secret_name="Pedro Parqueador", score=1.0) +Hero(name="Spider-Boy", secret_name="Pedro Parqueador", score=1.0, id=3) +Hero(name="Spider-Boy", secret_name="Pedro Parqueador", score=1.0, age=16) +Hero(name="Spider-Boy", score=1.0) # E: Missing named argument "secret_name" for "Hero" +h = Hero(id=1, name="Spider-Boy", age=16, score=1.0, secret_name="Pedro Parqueador") reveal_type(h) # N: Revealed type is "__main__.Hero" # Also test a simpler UpdateClass with direct Member From 728c132385cffd05d71a0b3c934e206d23971f3e Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Wed, 18 Mar 2026 12:19:21 -0700 Subject: [PATCH 142/161] Move around some of the tests for UpdateClass --- test-data/unit/check-typelevel-examples.test | 175 +++--------------- .../unit/check-typelevel-update-class.test | 66 +++++++ 2 files changed, 90 insertions(+), 151 deletions(-) create mode 100644 test-data/unit/check-typelevel-update-class.test diff --git a/test-data/unit/check-typelevel-examples.test b/test-data/unit/check-typelevel-examples.test index a77e6aa47671f..3d3064427b2fb 100644 --- a/test-data/unit/check-typelevel-examples.test +++ b/test-data/unit/check-typelevel-examples.test @@ -688,7 +688,7 @@ reveal_type(upd_types) # N: Revealed type is "tuple[builtins.str | None, builti [typing fixtures/typing-full.pyi] -[case testTypeLevel_initfntype] +[case testUpdateClassDataclassLike] # flags: --python-version 3.14 from typing import ( @@ -697,7 +697,7 @@ from typing import ( ReadOnly, TypedDict, Never, - Self, + UpdateClass, ) import typing @@ -749,157 +749,31 @@ type InitFnType[T] = typing.Member[ Literal["ClassVar"], ] -# Use InitFnType to build a protocol with __init__ + all existing members -type AddInit[T] = typing.NewProtocol[ - InitFnType[T], - *[x for x in typing.Iter[typing.Members[T]]], -] +def dataclass_ish[T](cls: type[T]) -> UpdateClass[InitFnType[T]]: + ... + +@dataclass_ish class Hero: id: int | None = None name: str age: int | None = Field(default=None) + score: float = Field() secret_name: str -h: AddInit[Hero] -reveal_type(h) # N: Revealed type is "NewProtocol[__init__: ClassVar[def (self: __main__.Hero, *, id: builtins.int | None =, name: builtins.str, age: builtins.int | None =, secret_name: builtins.str)], id: builtins.int | None = None, name: builtins.str, age: builtins.int | None = ..., secret_name: builtins.str]" - - -[builtins fixtures/typelevel.pyi] -[typing fixtures/typing-full.pyi] - - -[case testUpdateClassBasicDecorator] -# flags: --python-version 3.14 -from typing import Literal, Never, Member, UpdateClass - -def add_x[T](cls: type[T]) -> UpdateClass[ - Member[Literal["x"], int], -]: - ... - -@add_x -class Foo: - y: str - -reveal_type(Foo.x) # N: Revealed type is "builtins.int" -reveal_type(Foo().y) # N: Revealed type is "builtins.str" - -[builtins fixtures/typelevel.pyi] -[typing fixtures/typing-full.pyi] - - -[case testUpdateClassRemoveMember] -# flags: --python-version 3.14 -from typing import Literal, Never, Member, UpdateClass - -def remove_y[T](cls: type[T]) -> UpdateClass[ - Member[Literal["y"], Never], -]: - ... - -@remove_y -class Foo: - x: int - y: str - -reveal_type(Foo().x) # N: Revealed type is "builtins.int" -Foo().y # E: "Foo" has no attribute "y" - -[builtins fixtures/typelevel.pyi] -[typing fixtures/typing-full.pyi] - - -[case testUpdateClassInitSubclass] -# flags: --python-version 3.14 -from typing import Literal, Never, Member, UpdateClass - -class Base: - def __init_subclass__[T](cls: type[T]) -> UpdateClass[ - Member[Literal["tag"], str], +class Model: + def __init_subclass__[T]( + cls: type[T], + ) -> typing.UpdateClass[ + # Add the computed __init__ function + InitFnType[T], ]: - ... - -class Child(Base): - x: int - -reveal_type(Child.tag) # N: Revealed type is "builtins.str" -reveal_type(Child().x) # N: Revealed type is "builtins.int" - -[builtins fixtures/typelevel.pyi] -[typing fixtures/typing-full.pyi] - - -[case testUpdateClassDataclassLike] -# flags: --python-version 3.14 - -from typing import ( - Callable, - Literal, - ReadOnly, - TypedDict, - Never, - UpdateClass, -) - -import typing - - -class FieldArgs(TypedDict, total=False): - default: ReadOnly[object] - - -class Field[T: FieldArgs](typing.InitField[T]): - pass - - -# Extract the default value from a Field's init arg. -# If the init is a Field, pull out "default"; otherwise use the init itself. -type GetDefault[Init] = ( - typing.GetMemberType[ - typing.GetArg[Init, typing.InitField, Literal[0]], - Literal["default"], - ] - if typing.IsAssignable[Init, Field] - else Init -) - - -# Generate a Member for __init__ from a class's attributes. -# All params are keyword-only; params with defaults get Literal["keyword", "default"]. -type InitFnType[T] = typing.Member[ - Literal["__init__"], - Callable[ - typing.Params[ - typing.Param[Literal["self"], T], - *[ - typing.Param[ - p.name, - p.typ, - Literal["keyword"] - if typing.IsAssignable[ - GetDefault[p.init], - Never, - ] - else Literal["keyword", "default"], - ] - for p in typing.Iter[typing.Attrs[T]] - ], - ], - None, - ], - Literal["ClassVar"], -] - - -def dataclass_ish[T](cls: type[T]) -> UpdateClass[InitFnType[T]]: - ... + pass -@dataclass_ish -class Hero: +class Hero2(Model): id: int | None = None name: str age: int | None = Field(default=None) @@ -907,6 +781,7 @@ class Hero: secret_name: str + reveal_type(Hero.__init__) # N: Revealed type is "def (self: __main__.Hero, *, id: builtins.int | None =, name: builtins.str, age: builtins.int | None =, score: builtins.float, secret_name: builtins.str)" Hero(name="Spider-Boy", secret_name="Pedro Parqueador", score=1.0) Hero(name="Spider-Boy", secret_name="Pedro Parqueador", score=1.0, id=3) @@ -915,18 +790,16 @@ Hero(name="Spider-Boy", score=1.0) # E: Missing named argument "secret_name" fo h = Hero(id=1, name="Spider-Boy", age=16, score=1.0, secret_name="Pedro Parqueador") reveal_type(h) # N: Revealed type is "__main__.Hero" -# Also test a simpler UpdateClass with direct Member -def add_greet[T](cls: type[T]) -> UpdateClass[ - typing.Member[Literal["greet"], Callable[[T], str]], -]: - ... -@add_greet -class Simple: - x: int -reveal_type(Simple.greet) # N: Revealed type is "def (__main__.Simple) -> builtins.str" -reveal_type(Simple().greet) # N: Revealed type is "def (__main__.Simple) -> builtins.str" +reveal_type(Hero2.__init__) # N: Revealed type is "def (self: __main__.Hero2, *, id: builtins.int | None =, name: builtins.str, age: builtins.int | None =, score: builtins.float, secret_name: builtins.str)" +Hero2(name="Spider-Boy", secret_name="Pedro Parqueador", score=1.0) +Hero2(name="Spider-Boy", secret_name="Pedro Parqueador", score=1.0, id=3) +Hero2(name="Spider-Boy", secret_name="Pedro Parqueador", score=1.0, age=16) +Hero2(name="Spider-Boy", score=1.0) # E: Missing named argument "secret_name" for "Hero2" +h2 = Hero2(id=1, name="Spider-Boy", age=16, score=1.0, secret_name="Pedro Parqueador") +reveal_type(h2) # N: Revealed type is "__main__.Hero2" + [builtins fixtures/typelevel.pyi] [typing fixtures/typing-full.pyi] diff --git a/test-data/unit/check-typelevel-update-class.test b/test-data/unit/check-typelevel-update-class.test new file mode 100644 index 0000000000000..3627ba80e5cc7 --- /dev/null +++ b/test-data/unit/check-typelevel-update-class.test @@ -0,0 +1,66 @@ +-- TODO: Test inheritance more carefully: +-- - Multiple bases with __init_subclass__ returning UpdateClass (reverse MRO order) +-- - UpdateClass on a class that is itself subclassed (do children see the added members?) +-- - UpdateClass adding a member that conflicts with an inherited member +-- - Diamond inheritance with UpdateClass on multiple paths +-- - UpdateClass + explicit __init__ on the same class + +[case testUpdateClassBasicDecorator] +# flags: --python-version 3.14 +from typing import Literal, Never, Member, UpdateClass + +def add_x[T](cls: type[T]) -> UpdateClass[ + Member[Literal["x"], int], +]: + ... + +@add_x +class Foo: + y: str + +reveal_type(Foo.x) # N: Revealed type is "builtins.int" +reveal_type(Foo().y) # N: Revealed type is "builtins.str" + +[builtins fixtures/typelevel.pyi] +[typing fixtures/typing-full.pyi] + + +[case testUpdateClassRemoveMember] +# flags: --python-version 3.14 +from typing import Literal, Never, Member, UpdateClass + +def remove_y[T](cls: type[T]) -> UpdateClass[ + Member[Literal["y"], Never], +]: + ... + +@remove_y +class Foo: + x: int + y: str + +reveal_type(Foo().x) # N: Revealed type is "builtins.int" +Foo().y # E: "Foo" has no attribute "y" + +[builtins fixtures/typelevel.pyi] +[typing fixtures/typing-full.pyi] + + +[case testUpdateClassInitSubclass] +# flags: --python-version 3.14 +from typing import Literal, Never, Member, UpdateClass + +class Base: + def __init_subclass__[T](cls: type[T]) -> UpdateClass[ + Member[Literal["tag"], str], + ]: + ... + +class Child(Base): + x: int + +reveal_type(Child.tag) # N: Revealed type is "builtins.str" +reveal_type(Child().x) # N: Revealed type is "builtins.int" + +[builtins fixtures/typelevel.pyi] +[typing fixtures/typing-full.pyi] From c810265dc9b095c0dc04560e330c3c2f69017a34 Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Wed, 18 Mar 2026 13:59:50 -0700 Subject: [PATCH 143/161] Temporarily switch the README to be about the prototype --- README.md | 208 +++++++------------------------------------------ REAL_README.md | 190 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 219 insertions(+), 179 deletions(-) create mode 100644 REAL_README.md diff --git a/README.md b/README.md index 8040566b18eff..e4a0fcf08ab71 100644 --- a/README.md +++ b/README.md @@ -1,190 +1,40 @@ -mypy logo +# PEP 827: Type-Level Computation — Prototype Implementation -Mypy: Static Typing for Python -======================================= +This is a prototype implementation of [PEP 827](https://peps.python.org/pep-0827/) (Type Manipulation) in mypy. -[![Stable Version](https://img.shields.io/pypi/v/mypy?color=blue)](https://pypi.org/project/mypy/) -[![Downloads](https://img.shields.io/pypi/dm/mypy)](https://pypistats.org/packages/mypy) -[![Build Status](https://github.com/python/mypy/actions/workflows/test.yml/badge.svg)](https://github.com/python/mypy/actions) -[![Documentation Status](https://readthedocs.org/projects/mypy/badge/?version=latest)](https://mypy.readthedocs.io/en/latest/?badge=latest) -[![Chat at https://gitter.im/python/typing](https://badges.gitter.im/python/typing.svg)](https://gitter.im/python/typing?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) -[![Checked with mypy](https://www.mypy-lang.org/static/mypy_badge.svg)](https://mypy-lang.org/) -[![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) -[![Linting: Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/charliermarsh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff) +The PEP introduces type-level computation operators for introspecting and constructing types. -Got a question? ---------------- +Most of the main features are prototyped, and this should be suitable for experimentation, but it it not yet production quality or particularly ready to be a PR. -We are always happy to answer questions! Here are some good places to ask them: +For the original mypy README, see [REAL_README.md](REAL_README.md). -- for general questions about Python typing, try [typing discussions](https://github.com/python/typing/discussions) -- for anything you're curious about, try [gitter chat](https://gitter.im/python/typing) +## What's implemented -If you're just getting started, -[the documentation](https://mypy.readthedocs.io/en/stable/index.html) -and [type hints cheat sheet](https://mypy.readthedocs.io/en/stable/cheat_sheet_py3.html) -can also help answer questions. +- **Type operators**: `IsAssignable`, `IsEquivalent`, `Bool`, `GetArg`, `GetArgs`, `GetMember`, `GetMemberType`, `Members`, `Attrs`, `FromUnion`, `Length`, `Slice`, `Concat`, `Uppercase`, `Lowercase`, `Capitalize`, `Uncapitalize`, `RaiseError` +- **Conditional types**: `true_type if BoolType else false_type` +- **Type-level comprehensions**: `*[T for x in Iter[...]]` with filtering +- **Dot notation**: `member.name`, `member.type`, `param.type`, etc. +- **Boolean operators**: `and`, `or`, `not` on type booleans +- **Data types**: `Member[name, type, quals, init, definer]`, `Param[name, type, quals]` +- **Extended callables**: `Callable[Params[Param[...], ...], RetType]` +- **Object construction**: `NewProtocol[*Members]`, `NewTypedDict[*Members]` +- **Class modification**: `UpdateClass[*Members]` as return type of decorators / `__init_subclass__` +- **InitField**: Keyword argument capture with literal type inference +- **Callable introspection**: `GetArg[SomeCallable, Callable, Literal[0]]` returns `Param` types -If you think you've found a bug: +## What's not yet implemented -- check our [common issues page](https://mypy.readthedocs.io/en/stable/common_issues.html) -- search our [issue tracker](https://github.com/python/mypy/issues) to see if - it's already been reported +- `GetSpecialAttr[T, Attr]` — extract `__name__`, `__module__`, `__qualname__` +- `GenericCallable[Vs, lambda : Ty]` — generic callable types with lambda binding +- `Overloaded[*Callables]` — overloaded function type construction +- `any(comprehension)` / `all(comprehension)` — quantification over type booleans +- `classmethod`/`staticmethod` representation in type-level computation -To report a bug or request an enhancement: +- any attempt to make it perform well -- report at [our issue tracker](https://github.com/python/mypy/issues) -- if the issue is with a specific library or function, consider reporting it at - [typeshed tracker](https://github.com/python/typeshed/issues) or the issue - tracker for that library +## Key files -To discuss a new type system feature: - -- discuss at [discuss.python.org](https://discuss.python.org/c/typing/32) -- there is also some historical discussion at the [typing-sig mailing list](https://mail.python.org/archives/list/typing-sig@python.org/) and the [python/typing repo](https://github.com/python/typing/issues) - -What is mypy? -------------- - -Mypy is a static type checker for Python. - -Type checkers help ensure that you're using variables and functions in your code -correctly. With mypy, add type hints ([PEP 484](https://www.python.org/dev/peps/pep-0484/)) -to your Python programs, and mypy will warn you when you use those types -incorrectly. - -Python is a dynamic language, so usually you'll only see errors in your code -when you attempt to run it. Mypy is a *static* checker, so it finds bugs -in your programs without even running them! - -Here is a small example to whet your appetite: - -```python -number = input("What is your favourite number?") -print("It is", number + 1) # error: Unsupported operand types for + ("str" and "int") -``` - -Adding type hints for mypy does not interfere with the way your program would -otherwise run. Think of type hints as similar to comments! You can always use -the Python interpreter to run your code, even if mypy reports errors. - -Mypy is designed with gradual typing in mind. This means you can add type -hints to your code base slowly and that you can always fall back to dynamic -typing when static typing is not convenient. - -Mypy has a powerful and easy-to-use type system, supporting features such as -type inference, generics, callable types, tuple types, union types, -structural subtyping and more. Using mypy will make your programs easier to -understand, debug, and maintain. - -See [the documentation](https://mypy.readthedocs.io/en/stable/index.html) for -more examples and information. - -In particular, see: - -- [type hints cheat sheet](https://mypy.readthedocs.io/en/stable/cheat_sheet_py3.html) -- [getting started](https://mypy.readthedocs.io/en/stable/getting_started.html) -- [list of error codes](https://mypy.readthedocs.io/en/stable/error_code_list.html) - -Quick start ------------ - -Mypy can be installed using pip: - -```bash -python3 -m pip install -U mypy -``` - -If you want to run the latest version of the code, you can install from the -repo directly: - -```bash -python3 -m pip install -U git+https://github.com/python/mypy.git -``` - -Now you can type-check the [statically typed parts] of a program like this: - -```bash -mypy PROGRAM -``` - -You can always use the Python interpreter to run your statically typed -programs, even if mypy reports type errors: - -```bash -python3 PROGRAM -``` - -If you are working with large code bases, you can run mypy in -[daemon mode], that will give much faster (often sub-second) incremental updates: - -```bash -dmypy run -- PROGRAM -``` - -You can also try mypy in an [online playground](https://mypy-play.net/) (developed by -Yusuke Miyazaki). - -[statically typed parts]: https://mypy.readthedocs.io/en/latest/getting_started.html#function-signatures-and-dynamic-vs-static-typing -[daemon mode]: https://mypy.readthedocs.io/en/stable/mypy_daemon.html - -Integrations ------------- - -Mypy can be integrated into popular IDEs: - -- VS Code: provides [basic integration](https://code.visualstudio.com/docs/python/linting#_mypy) with mypy. -- Vim: - - Using [Syntastic](https://github.com/vim-syntastic/syntastic): in `~/.vimrc` add - `let g:syntastic_python_checkers=['mypy']` - - Using [ALE](https://github.com/dense-analysis/ale): should be enabled by default when `mypy` is installed, - or can be explicitly enabled by adding `let b:ale_linters = ['mypy']` in `~/vim/ftplugin/python.vim` -- Emacs: using [Flycheck](https://github.com/flycheck/) -- Sublime Text: [SublimeLinter-contrib-mypy](https://github.com/fredcallaway/SublimeLinter-contrib-mypy) -- PyCharm: [mypy plugin](https://github.com/dropbox/mypy-PyCharm-plugin) -- IDLE: [idlemypyextension](https://github.com/CoolCat467/idlemypyextension) -- pre-commit: use [pre-commit mirrors-mypy](https://github.com/pre-commit/mirrors-mypy), although - note by default this will limit mypy's ability to analyse your third party dependencies. - -Web site and documentation --------------------------- - -Additional information is available at the web site: - - - -Jump straight to the documentation: - - - -Follow along our changelog at: - - - -Contributing ------------- - -Help in testing, development, documentation and other tasks is -highly appreciated and useful to the project. There are tasks for -contributors of all experience levels. - -To get started with developing mypy, see [CONTRIBUTING.md](CONTRIBUTING.md). - -Mypyc and compiled version of mypy ----------------------------------- - -[Mypyc](https://github.com/mypyc/mypyc) uses Python type hints to compile Python -modules to faster C extensions. Mypy is itself compiled using mypyc: this makes -mypy approximately 4 times faster than if interpreted! - -To install an interpreted mypy instead, use: - -```bash -python3 -m pip install --no-binary mypy -U mypy -``` - -To use a compiled version of a development -version of mypy, directly install a binary from -. - -To contribute to the mypyc project, check out the issue tracker at +- `mypy/typelevel.py` — All type operator evaluation logic +- `mypy/typeanal.py` — Desugaring of conditional types, comprehensions, dot notation, extended callables +- `mypy/typeshed/stdlib/_typeshed/typemap.pyi` — Stub declarations for all operators and data types +- `test-data/unit/check-typelevel-*.test` — Test suite diff --git a/REAL_README.md b/REAL_README.md new file mode 100644 index 0000000000000..8040566b18eff --- /dev/null +++ b/REAL_README.md @@ -0,0 +1,190 @@ +mypy logo + +Mypy: Static Typing for Python +======================================= + +[![Stable Version](https://img.shields.io/pypi/v/mypy?color=blue)](https://pypi.org/project/mypy/) +[![Downloads](https://img.shields.io/pypi/dm/mypy)](https://pypistats.org/packages/mypy) +[![Build Status](https://github.com/python/mypy/actions/workflows/test.yml/badge.svg)](https://github.com/python/mypy/actions) +[![Documentation Status](https://readthedocs.org/projects/mypy/badge/?version=latest)](https://mypy.readthedocs.io/en/latest/?badge=latest) +[![Chat at https://gitter.im/python/typing](https://badges.gitter.im/python/typing.svg)](https://gitter.im/python/typing?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) +[![Checked with mypy](https://www.mypy-lang.org/static/mypy_badge.svg)](https://mypy-lang.org/) +[![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) +[![Linting: Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/charliermarsh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff) + +Got a question? +--------------- + +We are always happy to answer questions! Here are some good places to ask them: + +- for general questions about Python typing, try [typing discussions](https://github.com/python/typing/discussions) +- for anything you're curious about, try [gitter chat](https://gitter.im/python/typing) + +If you're just getting started, +[the documentation](https://mypy.readthedocs.io/en/stable/index.html) +and [type hints cheat sheet](https://mypy.readthedocs.io/en/stable/cheat_sheet_py3.html) +can also help answer questions. + +If you think you've found a bug: + +- check our [common issues page](https://mypy.readthedocs.io/en/stable/common_issues.html) +- search our [issue tracker](https://github.com/python/mypy/issues) to see if + it's already been reported + +To report a bug or request an enhancement: + +- report at [our issue tracker](https://github.com/python/mypy/issues) +- if the issue is with a specific library or function, consider reporting it at + [typeshed tracker](https://github.com/python/typeshed/issues) or the issue + tracker for that library + +To discuss a new type system feature: + +- discuss at [discuss.python.org](https://discuss.python.org/c/typing/32) +- there is also some historical discussion at the [typing-sig mailing list](https://mail.python.org/archives/list/typing-sig@python.org/) and the [python/typing repo](https://github.com/python/typing/issues) + +What is mypy? +------------- + +Mypy is a static type checker for Python. + +Type checkers help ensure that you're using variables and functions in your code +correctly. With mypy, add type hints ([PEP 484](https://www.python.org/dev/peps/pep-0484/)) +to your Python programs, and mypy will warn you when you use those types +incorrectly. + +Python is a dynamic language, so usually you'll only see errors in your code +when you attempt to run it. Mypy is a *static* checker, so it finds bugs +in your programs without even running them! + +Here is a small example to whet your appetite: + +```python +number = input("What is your favourite number?") +print("It is", number + 1) # error: Unsupported operand types for + ("str" and "int") +``` + +Adding type hints for mypy does not interfere with the way your program would +otherwise run. Think of type hints as similar to comments! You can always use +the Python interpreter to run your code, even if mypy reports errors. + +Mypy is designed with gradual typing in mind. This means you can add type +hints to your code base slowly and that you can always fall back to dynamic +typing when static typing is not convenient. + +Mypy has a powerful and easy-to-use type system, supporting features such as +type inference, generics, callable types, tuple types, union types, +structural subtyping and more. Using mypy will make your programs easier to +understand, debug, and maintain. + +See [the documentation](https://mypy.readthedocs.io/en/stable/index.html) for +more examples and information. + +In particular, see: + +- [type hints cheat sheet](https://mypy.readthedocs.io/en/stable/cheat_sheet_py3.html) +- [getting started](https://mypy.readthedocs.io/en/stable/getting_started.html) +- [list of error codes](https://mypy.readthedocs.io/en/stable/error_code_list.html) + +Quick start +----------- + +Mypy can be installed using pip: + +```bash +python3 -m pip install -U mypy +``` + +If you want to run the latest version of the code, you can install from the +repo directly: + +```bash +python3 -m pip install -U git+https://github.com/python/mypy.git +``` + +Now you can type-check the [statically typed parts] of a program like this: + +```bash +mypy PROGRAM +``` + +You can always use the Python interpreter to run your statically typed +programs, even if mypy reports type errors: + +```bash +python3 PROGRAM +``` + +If you are working with large code bases, you can run mypy in +[daemon mode], that will give much faster (often sub-second) incremental updates: + +```bash +dmypy run -- PROGRAM +``` + +You can also try mypy in an [online playground](https://mypy-play.net/) (developed by +Yusuke Miyazaki). + +[statically typed parts]: https://mypy.readthedocs.io/en/latest/getting_started.html#function-signatures-and-dynamic-vs-static-typing +[daemon mode]: https://mypy.readthedocs.io/en/stable/mypy_daemon.html + +Integrations +------------ + +Mypy can be integrated into popular IDEs: + +- VS Code: provides [basic integration](https://code.visualstudio.com/docs/python/linting#_mypy) with mypy. +- Vim: + - Using [Syntastic](https://github.com/vim-syntastic/syntastic): in `~/.vimrc` add + `let g:syntastic_python_checkers=['mypy']` + - Using [ALE](https://github.com/dense-analysis/ale): should be enabled by default when `mypy` is installed, + or can be explicitly enabled by adding `let b:ale_linters = ['mypy']` in `~/vim/ftplugin/python.vim` +- Emacs: using [Flycheck](https://github.com/flycheck/) +- Sublime Text: [SublimeLinter-contrib-mypy](https://github.com/fredcallaway/SublimeLinter-contrib-mypy) +- PyCharm: [mypy plugin](https://github.com/dropbox/mypy-PyCharm-plugin) +- IDLE: [idlemypyextension](https://github.com/CoolCat467/idlemypyextension) +- pre-commit: use [pre-commit mirrors-mypy](https://github.com/pre-commit/mirrors-mypy), although + note by default this will limit mypy's ability to analyse your third party dependencies. + +Web site and documentation +-------------------------- + +Additional information is available at the web site: + + + +Jump straight to the documentation: + + + +Follow along our changelog at: + + + +Contributing +------------ + +Help in testing, development, documentation and other tasks is +highly appreciated and useful to the project. There are tasks for +contributors of all experience levels. + +To get started with developing mypy, see [CONTRIBUTING.md](CONTRIBUTING.md). + +Mypyc and compiled version of mypy +---------------------------------- + +[Mypyc](https://github.com/mypyc/mypyc) uses Python type hints to compile Python +modules to faster C extensions. Mypy is itself compiled using mypyc: this makes +mypy approximately 4 times faster than if interpreted! + +To install an interpreted mypy instead, use: + +```bash +python3 -m pip install --no-binary mypy -U mypy +``` + +To use a compiled version of a development +version of mypy, directly install a binary from +. + +To contribute to the mypyc project, check out the issue tracker at From f49083e5cd7124df93ea1a0a844d60adf901c250 Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Wed, 18 Mar 2026 15:11:09 -0700 Subject: [PATCH 144/161] Add is_type_operator and is_new_protocol to binary cache serialization These flags were in TypeInfo.FLAGS (used by JSON serialize/deserialize) but missing from the binary write/read methods, causing them to be lost when restoring from cache. --- mypy/nodes.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/mypy/nodes.py b/mypy/nodes.py index c37d7618eb71b..8734c143e8b1c 100644 --- a/mypy/nodes.py +++ b/mypy/nodes.py @@ -4261,6 +4261,8 @@ def write(self, data: WriteBuffer) -> None: self.is_final, self.is_disjoint_base, self.is_intersection, + self.is_type_operator, + self.is_new_protocol, ], ) write_json(data, self.metadata) @@ -4334,7 +4336,9 @@ def read(cls, data: ReadBuffer) -> TypeInfo: ti.is_final, ti.is_disjoint_base, ti.is_intersection, - ) = read_flags(data, num_flags=11) + ti.is_type_operator, + ti.is_new_protocol, + ) = read_flags(data, num_flags=13) ti.metadata = read_json(data) tag = read_tag(data) if tag != LITERAL_NONE: From 6dec4534699c75a804041d66756912a404576a4e Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Wed, 18 Mar 2026 16:19:09 -0700 Subject: [PATCH 145/161] README tweaks --- README.md | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index e4a0fcf08ab71..05e675614c7d8 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,15 @@ # PEP 827: Type-Level Computation — Prototype Implementation -This is a prototype implementation of [PEP 827](https://peps.python.org/pep-0827/) (Type Manipulation) in mypy. +This is a prototype implementation of [PEP +827](https://peps.python.org/pep-0827/) (Type Manipulation) in mypy. The PEP introduces type-level computation operators for introspecting and constructing types. -Most of the main features are prototyped, and this should be suitable for experimentation, but it it not yet production quality or particularly ready to be a PR. +Most of the main features are prototyped, and this should be suitable +for experimentation, but it it not yet production quality or ready to +be a PR yet. (This prototype has been AI assisted, and at least some +slop has made it in that will need to be fixed; it might benefit from +a full history rewrite, also.) For the original mypy README, see [REAL_README.md](REAL_README.md). From 56a6b1ea0988d8f35c66abdb593ea34d5cdc714b Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Wed, 18 Mar 2026 18:18:40 -0700 Subject: [PATCH 146/161] Fix a test output mismatch in pythoneval --- test-data/unit/pythoneval.test | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/test-data/unit/pythoneval.test b/test-data/unit/pythoneval.test index dc47cfbe0b11f..b5523fcd1d601 100644 --- a/test-data/unit/pythoneval.test +++ b/test-data/unit/pythoneval.test @@ -2392,10 +2392,10 @@ type HeroDict[T] = typing.NewTypedDict[{ hd: HeroDict[Hero] reveal_type(hd) [out] -_program.py:133: note: Revealed type is "NewProtocol[id: builtins.int, name: builtins.str, age: builtins.int | None]" -_program.py:136: note: Revealed type is "NewProtocol[name: builtins.str, age: builtins.int | None = None, secret_name: builtins.str]" -_program.py:139: note: Revealed type is "NewProtocol[name: builtins.str | None = None, age: builtins.int | None | None = None, secret_name: builtins.str | None = None]" +_program.py:133: note: Revealed type is "NewProtocol[id: int, name: str, age: int | None]" +_program.py:136: note: Revealed type is "NewProtocol[name: str, age: int | None = None, secret_name: str]" +_program.py:139: note: Revealed type is "NewProtocol[name: str | None = None, age: int | None | None = None, secret_name: str | None = None]" _program.py:142: note: Revealed type is "tuple[Never, None, Never]" -_program.py:145: note: Revealed type is "tuple[builtins.str | None, builtins.int | None | None, builtins.str | None]" -_program.py:148: note: Revealed type is "tuple[builtins.str | None, builtins.int | None | None, builtins.str | None]" -_program.py:160: note: Revealed type is "TypedDict({'id': builtins.int | None, 'name': builtins.str, 'age': builtins.int | None})" +_program.py:145: note: Revealed type is "tuple[str | None, int | None | None, str | None]" +_program.py:148: note: Revealed type is "tuple[str | None, int | None | None, str | None]" +_program.py:160: note: Revealed type is "TypedDict({'id': int | None, 'name': str, 'age': int | None})" From 7c35d99f044c15f53f8b1f970758883537e6537c Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Wed, 18 Mar 2026 18:20:36 -0700 Subject: [PATCH 147/161] Give synthetic NewProtocol types unique fullnames Distinct NewProtocol types previously all shared the fullname __typelevel__.NewProtocol, causing has_base() to treat them as nominally related. This meant assigning between unrelated NewProtocol types was silently accepted. Use a global counter to generate unique fullnames. --- mypy/typelevel.py | 10 +++++++++- test-data/unit/check-typelevel-basic.test | 17 +++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/mypy/typelevel.py b/mypy/typelevel.py index c200cb85db6e5..a360a0ee91ffd 100644 --- a/mypy/typelevel.py +++ b/mypy/typelevel.py @@ -1201,6 +1201,9 @@ def _extract_members( return members +_synthetic_type_counter = 0 + + def _build_synthetic_typeinfo( class_name: str, members: list[MemberDef], evaluator: TypeLevelEvaluator ) -> TypeInfo: @@ -1209,9 +1212,14 @@ def _build_synthetic_typeinfo( Used by NewProtocol to build a TypeInfo carrying member definitions extracted from Member type arguments. """ + global _synthetic_type_counter + _synthetic_type_counter += 1 + # HACK: We create a ClassDef with an empty Block because TypeInfo requires one. + # Each synthetic type needs a unique fullname so that nominal subtype checks + # (has_base) don't incorrectly treat distinct synthetic types as related. class_def = ClassDef(class_name, Block([])) - class_def.fullname = f"__typelevel__.{class_name}" + class_def.fullname = f"__typelevel__.{class_name}.{_synthetic_type_counter}" info = TypeInfo(SymbolTable(), class_def, "__typelevel__") class_def.info = info diff --git a/test-data/unit/check-typelevel-basic.test b/test-data/unit/check-typelevel-basic.test index 90d1204ab5e64..dd0e265b28560 100644 --- a/test-data/unit/check-typelevel-basic.test +++ b/test-data/unit/check-typelevel-basic.test @@ -1067,6 +1067,23 @@ takes_proto(b) # E: Argument 1 to "takes_proto" has incompatible type "Bad"; ex [typing fixtures/typing-full.pyi] +[case testTypeOperatorNewProtocolIncompatible] +# flags: --python-version 3.14 + +from typing import NewProtocol, Member, Literal + +a0: NewProtocol[ + Member[Literal["foo"], str], +] +b0: NewProtocol[ + Member[Literal["bar"], int], +] +a0 = b0 # E: Incompatible types in assignment (expression has type "NewProtocol[bar: int]", variable has type "NewProtocol[foo: str]") + +[builtins fixtures/typelevel.pyi] +[typing fixtures/typing-full.pyi] + + [case testTypeOperatorNewProtocolRecursive1] # flags: --python-version 3.14 From a3f1b42e624d50621c510855cd1a72ddab96b401 Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Wed, 18 Mar 2026 18:28:21 -0700 Subject: [PATCH 148/161] Expand incremental NewProtocol test to check cross-module usage b.py now imports and uses the protocols from a.py, including verifying that incompatible NewProtocol assignments are caught on both initial and incremental runs. --- test-data/unit/check-incremental.test | 49 +++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/test-data/unit/check-incremental.test b/test-data/unit/check-incremental.test index d21a0a704d978..0dd38a3c4ad3f 100644 --- a/test-data/unit/check-incremental.test +++ b/test-data/unit/check-incremental.test @@ -314,11 +314,60 @@ asdf: NewProtocol[ ] [file b.py] +from a import MyProto, LinkedList +from typing import NewProtocol, Member, Literal + +x: MyProto + +class Good: + x: int + y: str + +def takes_proto(p: MyProto) -> None: + pass + +takes_proto(Good()) + +z: LinkedList[str] + +# A different NewProtocol that should be incompatible with MyProto +type OtherProto = NewProtocol[ + Member[Literal["bar"], int], +] +other: OtherProto +x = other [file b.py.2] +from a import MyProto, LinkedList +from typing import NewProtocol, Member, Literal + +x: MyProto + +class Good: + x: int + y: str + +def takes_proto(p: MyProto) -> None: + pass + +takes_proto(Good()) + +z: LinkedList[str] + +# A different NewProtocol that should be incompatible with MyProto +type OtherProto = NewProtocol[ + Member[Literal["bar"], int], +] +other: OtherProto +x = other # dummy change +[out] +tmp/b.py:22: error: Incompatible types in assignment (expression has type "NewProtocol[bar: int]", variable has type "NewProtocol[x: int, y: str]") +[out2] +tmp/b.py:22: error: Incompatible types in assignment (expression has type "NewProtocol[bar: int]", variable has type "NewProtocol[x: int, y: str]") + [builtins fixtures/typelevel.pyi] [typing fixtures/typing-full.pyi] From 2775e9c8b7aec9ee7b8d59495aa3de970f39ade8 Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Wed, 18 Mar 2026 18:45:21 -0700 Subject: [PATCH 149/161] Serialize NewProtocol as unevaluated TypeOperatorType in cache MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Synthetic NewProtocol TypeInfos have no module home, so their Instances can't be looked up by fullname on cache load. Replace is_new_protocol bool with new_protocol_constructor field that stores the original TypeOperatorType. When serializing an Instance of a NewProtocol type, write the constructor instead — it gets re-evaluated fresh on load. --- README.md | 9 ++++ mypy/nodes.py | 19 ++++---- mypy/typelevel.py | 3 +- mypy/types.py | 10 +++++ test-data/unit/check-incremental.test | 63 +++++++++++++++++++++++++++ 5 files changed, 95 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 05e675614c7d8..327f275639ff9 100644 --- a/README.md +++ b/README.md @@ -43,3 +43,12 @@ For the original mypy README, see [REAL_README.md](REAL_README.md). - `mypy/typeanal.py` — Desugaring of conditional types, comprehensions, dot notation, extended callables - `mypy/typeshed/stdlib/_typeshed/typemap.pyi` — Stub declarations for all operators and data types - `test-data/unit/check-typelevel-*.test` — Test suite + +## Some implementation notes + +- Evaluating `NewProtocol` creates a new anonymous `TypeInfo` that + doesn't go into any symbol tables. That `TypeInfo` hangs on to the + `NewProtocol` invocation that created it, and when we serialize + `Instance`s that refer to it, we serialize the `NewProtocol` + invocation instead. This allows us to avoid needing to serialize the + anonymous `TypeInfo`s. diff --git a/mypy/nodes.py b/mypy/nodes.py index 8734c143e8b1c..880c46e625e72 100644 --- a/mypy/nodes.py +++ b/mypy/nodes.py @@ -3592,7 +3592,7 @@ class is generic then it will be a type constructor of higher kind. "dataclass_transform_spec", "is_type_check_only", "is_type_operator", - "is_new_protocol", + "new_protocol_constructor", "deprecated", "type_object_type", ) @@ -3753,8 +3753,14 @@ class is generic then it will be a type constructor of higher kind. # Type operators are used for type-level computation (e.g., GetArg, Members, etc.) is_type_operator: bool - # Is set to `True` for synthetic protocol types created by NewProtocol[...] - is_new_protocol: bool + # For synthetic protocol types created by NewProtocol[...], stores the + # unevaluated TypeOperatorType so it can be re-evaluated on cache load + # instead of trying to serialize the synthetic TypeInfo. + new_protocol_constructor: mypy.types.TypeOperatorType | None + + @property + def is_new_protocol(self) -> bool: + return self.new_protocol_constructor is not None # The type's deprecation message (in case it is deprecated) deprecated: str | None @@ -3776,7 +3782,6 @@ class is generic then it will be a type constructor of higher kind. "is_disjoint_base", "is_intersection", "is_type_operator", - "is_new_protocol", ] def __init__(self, names: SymbolTable, defn: ClassDef, module_name: str) -> None: @@ -3825,7 +3830,7 @@ def __init__(self, names: SymbolTable, defn: ClassDef, module_name: str) -> None self.dataclass_transform_spec = None self.is_type_check_only = False self.is_type_operator = False - self.is_new_protocol = False + self.new_protocol_constructor = None self.deprecated = None self.type_object_type = None @@ -4262,7 +4267,6 @@ def write(self, data: WriteBuffer) -> None: self.is_disjoint_base, self.is_intersection, self.is_type_operator, - self.is_new_protocol, ], ) write_json(data, self.metadata) @@ -4337,8 +4341,7 @@ def read(cls, data: ReadBuffer) -> TypeInfo: ti.is_disjoint_base, ti.is_intersection, ti.is_type_operator, - ti.is_new_protocol, - ) = read_flags(data, num_flags=13) + ) = read_flags(data, num_flags=12) ti.metadata = read_json(data) tag = read_tag(data) if tag != LITERAL_NONE: diff --git a/mypy/typelevel.py b/mypy/typelevel.py index a360a0ee91ffd..0c893893d4ecb 100644 --- a/mypy/typelevel.py +++ b/mypy/typelevel.py @@ -1262,7 +1262,8 @@ def _eval_new_protocol(*args: Type, evaluator: TypeLevelEvaluator) -> Type: info = _build_synthetic_typeinfo("NewProtocol", members, evaluator) info.is_protocol = True - info.is_new_protocol = True + assert evaluator._current_op is not None + info.new_protocol_constructor = evaluator._current_op info.runtime_protocol = False return Instance(info, []) diff --git a/mypy/types.py b/mypy/types.py index ddb11c949e54a..639246e9d1159 100644 --- a/mypy/types.py +++ b/mypy/types.py @@ -1931,6 +1931,11 @@ def __eq__(self, other: object) -> bool: def serialize(self) -> JsonDict | str: assert self.type is not None + # Synthetic NewProtocol types can't be looked up by fullname on + # deserialization, so serialize the unevaluated constructor instead. + # It will be re-evaluated on load. + if self.type.new_protocol_constructor is not None: + return self.type.new_protocol_constructor.serialize() type_ref = self.type.fullname if not self.args and not self.last_known_value and not self.extra_attrs: return type_ref @@ -1965,6 +1970,11 @@ def deserialize(cls, data: JsonDict | str) -> Instance: return inst def write(self, data: WriteBuffer) -> None: + # Synthetic NewProtocol types can't be looked up by fullname on + # deserialization, so serialize the unevaluated constructor instead. + if self.type.new_protocol_constructor is not None: + self.type.new_protocol_constructor.write(data) + return write_tag(data, INSTANCE) if not self.args and not self.last_known_value and not self.extra_attrs: type_ref = self.type.fullname diff --git a/test-data/unit/check-incremental.test b/test-data/unit/check-incremental.test index 0dd38a3c4ad3f..ce5ff0ecf3d84 100644 --- a/test-data/unit/check-incremental.test +++ b/test-data/unit/check-incremental.test @@ -371,6 +371,69 @@ tmp/b.py:22: error: Incompatible types in assignment (expression has type "NewPr [builtins fixtures/typelevel.pyi] [typing fixtures/typing-full.pyi] + +[case testIncrementalUpdateClassWithNewProtocol] +# flags: --python-version 3.14 +import a +import b + +[file a.py] +from typing import Literal, Member, NewProtocol, UpdateClass + +type MyProto = NewProtocol[ + Member[Literal["x"], int], + Member[Literal["y"], str], +] + +def add_proto[T](cls: type[T]) -> UpdateClass[ + Member[Literal["proto"], MyProto], +]: + ... + +@add_proto +class Foo: + z: float + +[file b.py] +from a import Foo, MyProto + +reveal_type(Foo().proto) +reveal_type(Foo().z) + +class Good: + x: int + y: str + +g: Good +p: MyProto +p = g + +[file b.py.2] +from a import Foo, MyProto + +reveal_type(Foo().proto) +reveal_type(Foo().z) + +class Good: + x: int + y: str + +g: Good +p: MyProto +p = g + +# dummy change + +[out] +tmp/b.py:3: note: Revealed type is "NewProtocol[x: builtins.int, y: builtins.str]" +tmp/b.py:4: note: Revealed type is "builtins.float" +[out2] +tmp/b.py:3: note: Revealed type is "NewProtocol[x: builtins.int, y: builtins.str]" +tmp/b.py:4: note: Revealed type is "builtins.float" + +[builtins fixtures/typelevel.pyi] +[typing fixtures/typing-full.pyi] + [case testIncrementalMethodInterfaceChange] import mod1 From db45a7d2518c9c736a3f1e292e15f541b97864bc Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Wed, 18 Mar 2026 19:03:44 -0700 Subject: [PATCH 150/161] Add some tests for the currently-broken incremental mode --- README.md | 4 +- test-data/unit/fine-grained-python314.test | 148 +++++++++++++++++++++ 2 files changed, 151 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 327f275639ff9..e7120d2aa8197 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,7 @@ For the original mypy README, see [REAL_README.md](REAL_README.md). - **Class modification**: `UpdateClass[*Members]` as return type of decorators / `__init_subclass__` - **InitField**: Keyword argument capture with literal type inference - **Callable introspection**: `GetArg[SomeCallable, Callable, Literal[0]]` returns `Param` types +- Incremental mode ## What's not yet implemented @@ -35,7 +36,8 @@ For the original mypy README, see [REAL_README.md](REAL_README.md). - `any(comprehension)` / `all(comprehension)` — quantification over type booleans - `classmethod`/`staticmethod` representation in type-level computation -- any attempt to make it perform well +- Any attempt to make it perform well +- Fine-grained mode is currently broken ## Key files diff --git a/test-data/unit/fine-grained-python314.test b/test-data/unit/fine-grained-python314.test index 40af43567bd15..601ed34cda8e2 100644 --- a/test-data/unit/fine-grained-python314.test +++ b/test-data/unit/fine-grained-python314.test @@ -15,3 +15,151 @@ LiteralString = str [out] == main:2: error: Incompatible types in assignment (expression has type "Template", variable has type "str") + +[case testFineGrainedNewProtocol-xfail] +# flags: --python-version 3.14 +import a +import b + +[file a.py] + +from typing import NewProtocol, Member, Literal, Iter + +# Basic NewProtocol creation +type MyProto = NewProtocol[ + Member[Literal["x"], int], + Member[Literal["y"], str], +] +x: MyProto + +type LinkedList[T] = NewProtocol[ + Member[Literal["data"], T], + Member[Literal["next"], LinkedList[T]], +] + +z: LinkedList[str] + +lol: tuple[*[t for t in Iter[tuple[MyProto]]]] + +asdf: NewProtocol[ + Member[Literal["x"], int], + Member[Literal["y"], str], +] + +[file b.py] +from a import MyProto, LinkedList +from typing import NewProtocol, Member, Literal + +x: MyProto + +class Good: + x: int + y: str + +def takes_proto(p: MyProto) -> None: + pass + +takes_proto(Good()) + +z: LinkedList[str] + +# A different NewProtocol that should be incompatible with MyProto +type OtherProto = NewProtocol[ + Member[Literal["bar"], int], +] +other: OtherProto +x = other + +[file b.py.2] +from a import MyProto, LinkedList +from typing import NewProtocol, Member, Literal + +x: MyProto + +class Good: + x: int + y: str + +def takes_proto(p: MyProto) -> None: + pass + +takes_proto(Good()) + +z: LinkedList[str] + +# A different NewProtocol that should be incompatible with MyProto +type OtherProto = NewProtocol[ + Member[Literal["bar"], int], +] +other: OtherProto +x = other + +# dummy change + +[builtins fixtures/typelevel.pyi] +[typing fixtures/typing-full.pyi] +[out] +b.py:22: error: Incompatible types in assignment (expression has type "NewProtocol[bar: int]", variable has type "NewProtocol[x: int, y: str]") +== +b.py:22: error: Incompatible types in assignment (expression has type "NewProtocol[bar: int]", variable has type "NewProtocol[x: int, y: str]") + +[case testFineGrainedUpdateClassWithNewProtocol-xfail] +# flags: --python-version 3.14 +import a +import b + +[file a.py] +from typing import Literal, Member, NewProtocol, UpdateClass + +type MyProto = NewProtocol[ + Member[Literal["x"], int], + Member[Literal["y"], str], +] + +def add_proto[T](cls: type[T]) -> UpdateClass[ + Member[Literal["proto"], MyProto], +]: + ... + +@add_proto +class Foo: + z: float + +[file b.py] +from a import Foo, MyProto + +reveal_type(Foo().proto) +reveal_type(Foo().z) + +class Good: + x: int + y: str + +g: Good +p: MyProto +p = g + +[file b.py.2] +from a import Foo, MyProto + +reveal_type(Foo().proto) +reveal_type(Foo().z) + +class Good: + x: int + y: str + +g: Good +p: MyProto +p = g + +# dummy change + +[builtins fixtures/typelevel.pyi] +[typing fixtures/typing-full.pyi] +[out] +b.py:3: note: Revealed type is "NewProtocol[x: builtins.int, y: builtins.str]" +b.py:4: note: Revealed type is "builtins.float" +== +b.py:3: note: Revealed type is "NewProtocol[x: builtins.int, y: builtins.str]" +b.py:4: note: Revealed type is "builtins.float" From 81986ceec09ecf84bededb90f7070405b9658cf3 Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Wed, 18 Mar 2026 18:14:03 -0700 Subject: [PATCH 151/161] Improve error reporting for type alias dot notation, fix a test --- mypy/exprtotype.py | 6 +--- mypy/fastparse.py | 6 +--- mypy/typeanal.py | 43 ++++++++++++++++++++++++++++- test-data/unit/check-fastparse.test | 32 ++++++++++++++------- 4 files changed, 66 insertions(+), 21 deletions(-) diff --git a/mypy/exprtotype.py b/mypy/exprtotype.py index 0a45f74d52f7e..1fe77c53eddd7 100644 --- a/mypy/exprtotype.py +++ b/mypy/exprtotype.py @@ -107,11 +107,7 @@ def expr_to_unanalyzed_type( before_dot = expr_to_unanalyzed_type( expr.expr, options, allow_new_syntax, expr, lookup_qualified=lookup_qualified ) - attr_literal = UnboundType( - "Literal", - [RawExpressionType(expr.name, "builtins.str", line=expr.line)], - line=expr.line, - ) + attr_literal = RawExpressionType(expr.name, "builtins.str", line=expr.line) return UnboundType( "__builtins__._TypeGetAttr", [before_dot, attr_literal], diff --git a/mypy/fastparse.py b/mypy/fastparse.py index 2dc8c8dc56a53..1894b994ee7c6 100644 --- a/mypy/fastparse.py +++ b/mypy/fastparse.py @@ -2203,11 +2203,7 @@ def visit_Attribute(self, n: Attribute) -> Type: elif isinstance(before_dot, UnboundType): # Subscripted type with attribute access: GetMember[T, K].type # Desugar to _TypeGetAttr[GetMember[T, K], Literal["attr"]] - attr_literal = UnboundType( - "Literal", - [RawExpressionType(n.attr, "builtins.str", line=self.line)], - line=self.line, - ) + attr_literal = RawExpressionType(n.attr, "builtins.str", line=self.line) return UnboundType( "__builtins__._TypeGetAttr", [before_dot, attr_literal], diff --git a/mypy/typeanal.py b/mypy/typeanal.py index c2dc9c36f4fdf..7f6e09803ceff 100644 --- a/mypy/typeanal.py +++ b/mypy/typeanal.py @@ -1163,14 +1163,55 @@ def analyze_type_operator(self, t: UnboundType, type_info: TypeInfo) -> Type: Returns a TypeOperatorType that will be expanded later. """ + # Convert any RawExpressionType args (e.g. from dot notation desugaring) + # to LiteralType before analysis, since bare RawExpressionType would be + # rejected by visit_raw_expression_type. + converted_args: list[Type] = [] + for arg in t.args: + if isinstance(arg, RawExpressionType) and arg.literal_value is not None: + fallback = self.named_type(arg.base_type_name) + converted_args.append( + LiteralType(arg.literal_value, fallback, line=arg.line, column=arg.column) + ) + else: + converted_args.append(arg) + # Analyze all type arguments an_args = self.anal_array( - t.args, + converted_args, allow_param_spec=True, allow_param_spec_literals=type_info.has_param_spec_type, allow_unpack=True, ) + # For _TypeGetAttr, eagerly check that the first arg is a type that + # supports dot notation. If it's already a concrete type like + # tuple[int, str] or a Callable, we can report the error now rather + # than deferring to lazy evaluation (which may never happen for + # unused function parameters). + # TODO: This eager check is a workaround for the fact that type + # operator evaluation is lazy — if a function parameter's type is + # never used, the operator is never evaluated and the error is + # never reported. We may need a more principled approach to + # ensuring type-level errors are always surfaced. + if type_info.name == "_TypeGetAttr" and len(an_args) >= 2: + target = get_proper_type(an_args[0]) + if isinstance(target, (Instance, TupleType, CallableType)): + is_valid = False + if isinstance(target, Instance) and ( + target.type.is_type_operator or target.type.name in ("Member", "Param") + ): + is_valid = True + if not is_valid: + name = None + name_type = get_proper_type(an_args[1]) + if isinstance(name_type, LiteralType) and isinstance(name_type.value, str): + name = name_type.value + self.fail( + f"Dot notation .{name} requires a Member or Param type, got {target}", t + ) + return UninhabitedType() + # TODO: different fallbacks for different types fallback = self.named_type("builtins.object") return TypeOperatorType(type_info, an_args, fallback, t.line, t.column) diff --git a/test-data/unit/check-fastparse.test b/test-data/unit/check-fastparse.test index 7ee5a9c432169..a357aa8dddadd 100644 --- a/test-data/unit/check-fastparse.test +++ b/test-data/unit/check-fastparse.test @@ -34,26 +34,38 @@ def f(x): # E: Invalid type comment or annotation # All of these should not crash from typing import Callable, Tuple, Iterable -x: Tuple[int, str].x # E: Invalid type comment or annotation -a: Iterable[x].x # E: Invalid type comment or annotation +x: Tuple[int, str].x # E: Dot notation .x requires a Member or Param type, got tuple[int, str] +a: Iterable[x].x # E: Dot notation .x requires a Member or Param type, got typing.Iterable[x?] \ + # E: Variable "__main__.x" is not valid as a type \ + # N: See https://mypy.readthedocs.io/en/stable/common_issues.html#variables-vs-type-aliases b: Tuple[x][x] # E: Invalid type comment or annotation c: Iterable[x][x] # E: Invalid type comment or annotation d: Callable[..., int][x] # E: Invalid type comment or annotation -e: Callable[..., int].x # E: Invalid type comment or annotation - -f = None # type: Tuple[int, str].x # E: Invalid type comment or annotation -g = None # type: Iterable[x].x # E: Invalid type comment or annotation +e: Callable[..., int].x # E: Dot notation .x requires a Member or Param type, got def (*Any, **Any) -> int + +f = None # type: Tuple[int, str].x # E: Dot notation .x requires a Member or Param type, got tuple[int, str] \ + # E: Incompatible types in assignment (expression has type "None", variable has type "Never") +g = None # type: Iterable[x].x # E: Variable "__main__.x" is not valid as a type \ + # N: See https://mypy.readthedocs.io/en/stable/common_issues.html#variables-vs-type-aliases \ + # E: Dot notation .x requires a Member or Param type, got typing.Iterable[x?] \ + # E: Incompatible types in assignment (expression has type "None", variable has type "Never") h = None # type: Tuple[x][x] # E: Invalid type comment or annotation i = None # type: Iterable[x][x] # E: Invalid type comment or annotation j = None # type: Callable[..., int][x] # E: Invalid type comment or annotation -k = None # type: Callable[..., int].x # E: Invalid type comment or annotation +k = None # type: Callable[..., int].x # E: Dot notation .x requires a Member or Param type, got def (*Any, **Any) -> int \ + # E: Incompatible types in assignment (expression has type "None", variable has type "Never") -def f1(x: Tuple[int, str].x) -> None: pass # E: Invalid type comment or annotation -def f2(x: Iterable[x].x) -> None: pass # E: Invalid type comment or annotation +def f1(x: Tuple[int, str].x) -> None: pass # E: Dot notation .x requires a Member or Param type, got tuple[int, str] +def f2(x: Iterable[x].x) -> None: pass # E: Dot notation .x requires a Member or Param type, got typing.Iterable[x?] \ + # E: Variable "__main__.x" is not valid as a type \ + # N: See https://mypy.readthedocs.io/en/stable/common_issues.html#variables-vs-type-aliases def f3(x: Tuple[x][x]) -> None: pass # E: Invalid type comment or annotation def f4(x: Iterable[x][x]) -> None: pass # E: Invalid type comment or annotation def f5(x: Callable[..., int][x]) -> None: pass # E: Invalid type comment or annotation -def f6(x: Callable[..., int].x) -> None: pass # E: Invalid type comment or annotation +def f6(x: Callable[..., int].x) -> None: pass # E: Dot notation .x requires a Member or Param type, got def (*Any, **Any) -> int + +[builtins fixtures/typelevel.pyi] +[typing fixtures/typing-full.pyi] [case testFastParseTypeWithIgnore] def f(x, # type: x # type: ignore From 9744d25fbd2a42ddb8b0cc6e5a74388e08c9ea7b Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Thu, 19 Mar 2026 11:29:11 -0700 Subject: [PATCH 152/161] Try to fix build on older pythons --- mypy/test/testcheck.py | 2 ++ test-data/unit/check-incremental.test | 26 ++++++++++++++++---------- test-data/unit/pythoneval.test | 2 +- 3 files changed, 19 insertions(+), 11 deletions(-) diff --git a/mypy/test/testcheck.py b/mypy/test/testcheck.py index cbb9c0235feda..de16a2f33d842 100644 --- a/mypy/test/testcheck.py +++ b/mypy/test/testcheck.py @@ -48,6 +48,8 @@ typecheck_files.remove("check-python311.test") if sys.version_info < (3, 12): typecheck_files.remove("check-python312.test") + typecheck_files = [f for f in typecheck_files if "typelevel" not in f] + typecheck_files.remove("check-kwargs-unpack-typevar.test") if sys.version_info < (3, 13): typecheck_files.remove("check-python313.test") if sys.version_info < (3, 14): diff --git a/test-data/unit/check-incremental.test b/test-data/unit/check-incremental.test index ce5ff0ecf3d84..3cfa6b90a74d6 100644 --- a/test-data/unit/check-incremental.test +++ b/test-data/unit/check-incremental.test @@ -244,6 +244,7 @@ def baz() -> int: import a [file a.py] from typing import ( + TypeVar, NewTypedDict, Iter, Attrs, @@ -252,7 +253,8 @@ from typing import ( Member, ) -type PropsOnly[T] = list[ +T = TypeVar("T") +PropsOnly = list[ NewTypedDict[ *[ Member[GetName[p], GetType[p]] @@ -262,6 +264,7 @@ type PropsOnly[T] = list[ ] [file a.py.2] from typing import ( + TypeVar, NewTypedDict, Iter, Attrs, @@ -270,7 +273,8 @@ from typing import ( Member, ) -type PropsOnly[T] = list[ +T = TypeVar("T") +PropsOnly = list[ NewTypedDict[ *[ Member[GetName[p], GetType[p]] @@ -290,16 +294,17 @@ import b [file a.py] -from typing import NewProtocol, Member, Literal, Iter +from typing import TypeVar, NewProtocol, Member, Literal, Iter # Basic NewProtocol creation -type MyProto = NewProtocol[ +MyProto = NewProtocol[ Member[Literal["x"], int], Member[Literal["y"], str], ] x: MyProto -type LinkedList[T] = NewProtocol[ +T = TypeVar("T") +LinkedList = NewProtocol[ Member[Literal["data"], T], Member[Literal["next"], LinkedList[T]], ] @@ -331,7 +336,7 @@ takes_proto(Good()) z: LinkedList[str] # A different NewProtocol that should be incompatible with MyProto -type OtherProto = NewProtocol[ +OtherProto = NewProtocol[ Member[Literal["bar"], int], ] other: OtherProto @@ -355,7 +360,7 @@ takes_proto(Good()) z: LinkedList[str] # A different NewProtocol that should be incompatible with MyProto -type OtherProto = NewProtocol[ +OtherProto = NewProtocol[ Member[Literal["bar"], int], ] other: OtherProto @@ -378,14 +383,15 @@ import a import b [file a.py] -from typing import Literal, Member, NewProtocol, UpdateClass +from typing import TypeVar, Literal, Member, NewProtocol, UpdateClass -type MyProto = NewProtocol[ +MyProto = NewProtocol[ Member[Literal["x"], int], Member[Literal["y"], str], ] -def add_proto[T](cls: type[T]) -> UpdateClass[ +T = TypeVar("T") +def add_proto(cls: type[T]) -> UpdateClass[ Member[Literal["proto"], MyProto], ]: ... diff --git a/test-data/unit/pythoneval.test b/test-data/unit/pythoneval.test index b5523fcd1d601..db88a1784d215 100644 --- a/test-data/unit/pythoneval.test +++ b/test-data/unit/pythoneval.test @@ -2231,7 +2231,7 @@ def f(x: int, y: list[str]): _testStrictEqualityWithList.py:3: error: Non-overlapping container check (element type: "int", container item type: "str") [case testTypeLevel] -# flags: --python-version 3.15 +# flags: --python-version=3.15 from __future__ import annotations from typing import ( From be2ce6e569a2191ce6a822fda092c4bcfefd0cc6 Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Wed, 15 Apr 2026 18:29:27 -0700 Subject: [PATCH 153/161] DEBUG: remove pep.rst --- pep.rst | 1 - 1 file changed, 1 deletion(-) delete mode 120000 pep.rst diff --git a/pep.rst b/pep.rst deleted file mode 120000 index c52d575bcefd3..0000000000000 --- a/pep.rst +++ /dev/null @@ -1 +0,0 @@ -../typemap/pep.rst \ No newline at end of file From 493c4067d16d74c01dd36a9e322a98704bb1d8b8 Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Wed, 15 Apr 2026 18:46:37 -0700 Subject: [PATCH 154/161] Prevent pathological infinite expansion in some cases --- mypy/types.py | 8 +++++- test-data/unit/check-typelevel-basic.test | 31 +++++++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/mypy/types.py b/mypy/types.py index 639246e9d1159..1a974aa016d87 100644 --- a/mypy/types.py +++ b/mypy/types.py @@ -3958,7 +3958,13 @@ def _expand_type_fors_in_args(typ: ProperType) -> ProperType: typ = expand_type(typ2, {}) elif isinstance(typ, UnpackType) and _could_be_computed_unpack(typ): # No need to expand here - typ = typ.copy_modified(type=get_proper_type(typ.type)) + expanded = get_proper_type(typ.type) + # If expansion is stuck (still a ComputedType), keep the original. + # This prevents infinite expansion of recursive type aliases like + # Zip[DropLast[T], ...] where each expansion produces another + # stuck recursive reference. + if not isinstance(expanded, ComputedType): + typ = typ.copy_modified(type=expanded) return typ diff --git a/test-data/unit/check-typelevel-basic.test b/test-data/unit/check-typelevel-basic.test index dd0e265b28560..40f04336acdbd 100644 --- a/test-data/unit/check-typelevel-basic.test +++ b/test-data/unit/check-typelevel-basic.test @@ -1353,3 +1353,34 @@ reveal_type(c) # N: Revealed type is "Literal['keyword']" [builtins fixtures/typelevel.pyi] [typing fixtures/typing-full.pyi] + +[case testRecursiveConditionalAliasWithTypeVars] +# flags: --python-version 3.14 +# Regression test: recursive conditional type alias with TypeVars +# in a function return type must not hang when formatting error messages. +# The bug was that _expand_type_fors_in_args would infinitely expand +# recursive aliases inside UnpackType when the expansion was stuck +# on TypeVars. +from typing import Literal, Bool, IsAssignable, Slice, GetArg, Length, RaiseError, assert_type + +type DropLast[T] = Slice[T, Literal[0], Literal[-1]] +type Last[T] = GetArg[T, tuple, Literal[-1]] +type Empty[T] = IsAssignable[Length[T], Literal[0]] + +type Zip[T, S] = ( + tuple[()] + if Bool[Empty[T]] and Bool[Empty[S]] + else RaiseError[Literal["Zip length mismatch"], T, S] + if Bool[Empty[T]] or Bool[Empty[S]] + else tuple[*Zip[DropLast[T], DropLast[S]], tuple[Last[T], Last[S]]] +) + +def pair_zip[T, U](a: T, b: U) -> Zip[T, U]: + return tuple(zip(a, b)) # type: ignore + +# Concrete usage should work +result = pair_zip((1, "hello"), (3.14, True)) +assert_type(result, tuple[tuple[int, float], tuple[str, bool]]) + +[builtins fixtures/typelevel.pyi] +[typing fixtures/typing-full.pyi] From fbc5d6c16834379307857318e6c32326b5d8a201 Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Thu, 16 Apr 2026 11:31:19 -0700 Subject: [PATCH 155/161] [HACK?] Suppress type expansion errors when formatting types We may want to think about this more carefully later, and see what needs to be generalized. --- mypy/messages.py | 9 ++++++- mypy/typelevel.py | 18 +++++++++++++- test-data/unit/check-typelevel-basic.test | 30 +++++++++++++++++++++++ 3 files changed, 55 insertions(+), 2 deletions(-) diff --git a/mypy/messages.py b/mypy/messages.py index ff7b11ee5c3f4..e2ab3ba638423 100644 --- a/mypy/messages.py +++ b/mypy/messages.py @@ -71,6 +71,7 @@ is_same_type, is_subtype, ) +from mypy.typelevel import typelevel_ctx from mypy.typeops import separate_union_literals from mypy.types import ( AnyType, @@ -2899,7 +2900,8 @@ def format_type_bare( instead. (The caller may want to use quote_type_string after processing has happened, to maintain consistent quoting in messages.) """ - return format_type_inner(typ, verbosity, options, find_type_overlaps(typ), module_names) + with typelevel_ctx.suppress_errors(): + return format_type_inner(typ, verbosity, options, find_type_overlaps(typ), module_names) def format_type_distinctly(*types: Type, options: Options, bare: bool = False) -> tuple[str, ...]: @@ -2914,6 +2916,11 @@ def format_type_distinctly(*types: Type, options: Options, bare: bool = False) - be quoted; callers who need to do post-processing of the strings before quoting them (such as prepending * or **) should use this. """ + with typelevel_ctx.suppress_errors(): + return _format_type_distinctly(*types, options=options, bare=bare) + + +def _format_type_distinctly(*types: Type, options: Options, bare: bool = False) -> tuple[str, ...]: overlapping = find_type_overlaps(*types) def format_single(arg: Type) -> str: diff --git a/mypy/typelevel.py b/mypy/typelevel.py index 0c893893d4ecb..50dc002f9777d 100644 --- a/mypy/typelevel.py +++ b/mypy/typelevel.py @@ -89,6 +89,7 @@ def __init__(self) -> None: # XXX: but maybe we should always thread the evaluator back # ourselves or something instead? self._evaluator: TypeLevelEvaluator | None = None + self._suppress_errors: bool = False @property def api(self) -> SemanticAnalyzerInterface | None: @@ -111,6 +112,20 @@ def set_api(self, api: SemanticAnalyzerInterface) -> Iterator[None]: finally: self._api = saved + @contextmanager + def suppress_errors(self) -> Iterator[None]: + """Suppress side-effectful errors (e.g. from RaiseError) during evaluation. + + Used during type formatting to prevent spurious errors when + get_proper_type evaluates TypeOperatorTypes for display purposes. + """ + saved = self._suppress_errors + self._suppress_errors = True + try: + yield + finally: + self._suppress_errors = saved + # Global context instance for type-level computation typelevel_ctx: Final = TypeLevelContext() @@ -1344,7 +1359,8 @@ def _eval_raise_error(*args: Type, evaluator: TypeLevelEvaluator) -> Type: # TODO: We could also print a stack trace? # Use serious=True to bypass in_checked_function() check which requires # self.options to be set on the SemanticAnalyzer - evaluator.api.fail(msg, evaluator.error_ctx, serious=True) + if not typelevel_ctx._suppress_errors: + evaluator.api.fail(msg, evaluator.error_ctx, serious=True) return UninhabitedType() diff --git a/test-data/unit/check-typelevel-basic.test b/test-data/unit/check-typelevel-basic.test index 40f04336acdbd..7c14fbadcca18 100644 --- a/test-data/unit/check-typelevel-basic.test +++ b/test-data/unit/check-typelevel-basic.test @@ -1384,3 +1384,33 @@ assert_type(result, tuple[tuple[int, float], tuple[str, bool]]) [builtins fixtures/typelevel.pyi] [typing fixtures/typing-full.pyi] + +[case testRecursiveConditionalAliasWithTypeVarTuples] +# flags: --python-version 3.14 +# Regression test: same as above but with TypeVarTuples (*Ts, *Us). +# RaiseError inside the Zip body must not fire during error formatting +# when get_proper_type evaluates it standalone outside its _Cond context. +from typing import Literal, Bool, IsAssignable, Slice, GetArg, Length, RaiseError, assert_type + +type DropLast[T] = Slice[T, Literal[0], Literal[-1]] +type Last[T] = GetArg[T, tuple, Literal[-1]] +type Empty[T] = IsAssignable[Length[T], Literal[0]] + +type Zip[T, S] = ( + tuple[()] + if Bool[Empty[T]] and Bool[Empty[S]] + else RaiseError[Literal["Zip length mismatch"], T, S] + if Bool[Empty[T]] or Bool[Empty[S]] + else tuple[*Zip[DropLast[T], DropLast[S]], tuple[Last[T], Last[S]]] +) + +def pair_zip[*Ts, *Us]( + a: tuple[*Ts], b: tuple[*Us] +) -> Zip[tuple[*Ts], tuple[*Us]]: + return tuple(zip(a, b)) # type: ignore + +result = pair_zip((1, "hello"), (3.14, True)) +assert_type(result, tuple[tuple[int, float], tuple[str, bool]]) + +[builtins fixtures/typelevel.pyi] +[typing fixtures/typing-full.pyi] From 8a121bf2895e5a1460af417a8add370fd24ce44f Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Thu, 16 Apr 2026 13:20:12 -0700 Subject: [PATCH 156/161] [TODO] Add an AI generated bug report to README --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index e7120d2aa8197..cb0404a60f082 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,10 @@ For the original mypy README, see [REAL_README.md](REAL_README.md). - Any attempt to make it perform well - Fine-grained mode is currently broken +## Known bugs + +- **`IsAssignable`/`IsEquivalent` over-accept on parameterized tuples.** Both operators ignore type arguments on `tuple` (and presumably other generics), so e.g. `IsAssignable[tuple[Literal[2], Literal[1]], tuple[Literal[2], ...]]` and `IsEquivalent[tuple[Literal[2], Literal[1]], tuple[Literal[2], Literal[2]]]` both return `Literal[True]`. On bare `Literal` values the operators work correctly. (Note: even if implemented correctly, these checks should always return `False` for a fixed-length tuple vs. a homogeneous tuple — neither current behavior is what's needed, so this is a doubly-bad primitive for "all elements equal" style checks. Use `Union[*xs]` collapse against a representative instead.) + ## Key files - `mypy/typelevel.py` — All type operator evaluation logic From 8673fabc02de7bd9d7ddc024ca39dc7359ddf7be Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Thu, 16 Apr 2026 13:24:28 -0700 Subject: [PATCH 157/161] Emit errors in error cases instead of producing Never --- mypy/subtypes.py | 14 ++++-- mypy/typelevel.py | 47 +++++++++++++++++---- test-data/unit/check-typelevel-basic.test | 26 ++++++------ test-data/unit/check-typelevel-members.test | 10 ++--- 4 files changed, 66 insertions(+), 31 deletions(-) diff --git a/mypy/subtypes.py b/mypy/subtypes.py index a24bffa71295a..142223503f7b2 100644 --- a/mypy/subtypes.py +++ b/mypy/subtypes.py @@ -2308,10 +2308,16 @@ def infer_class_variances(info: TypeInfo) -> bool: return True tvs = info.defn.type_vars success = True - for i, tv in enumerate(tvs): - if isinstance(tv, TypeVarType) and tv.variance == VARIANCE_NOT_READY: - if not infer_variance(info, i): - success = False + # Variance inference substitutes type variables with synthetic bounds and + # runs subtype checks, which can trigger type-level operator evaluation on + # types the user never wrote. Suppress any errors from those evaluations. + from mypy.typelevel import typelevel_ctx + + with typelevel_ctx.suppress_errors(): + for i, tv in enumerate(tvs): + if isinstance(tv, TypeVarType) and tv.variance == VARIANCE_NOT_READY: + if not infer_variance(info, i): + success = False return success diff --git a/mypy/typelevel.py b/mypy/typelevel.py index 50dc002f9777d..29ff2f8e11555 100644 --- a/mypy/typelevel.py +++ b/mypy/typelevel.py @@ -195,11 +195,15 @@ def wrapper(*args: Type, evaluator: TypeLevelEvaluator) -> Type: proper = get_proper_type(arg) if isinstance(proper, UnionType): expanded.append(list(proper.items)) + elif isinstance(proper, UninhabitedType): + # Never decomposes to an empty union: the operator never runs. + expanded.append([]) else: expanded.append([arg]) combinations = list(itertools.product(*expanded)) + # Fast path: no unions to fan out over, just call the operator directly. if len(combinations) == 1: return func(*args, evaluator=evaluator) @@ -222,6 +226,18 @@ class EvaluationOverflow(Exception): pass +class TypeLevelError(Exception): + """Raised by an operator evaluator when its arguments are invalid. + + Caught in eval_operator, which emits the message via api.fail and + returns UninhabitedType. + """ + + def __init__(self, message: str) -> None: + super().__init__(message) + self.message = message + + class TypeLevelEvaluator: """Evaluates type-level computations to concrete types.""" @@ -297,7 +313,12 @@ def eval_operator(self, typ: TypeOperatorType) -> Type: args = self.flatten_args(args) elif len(args) != info.expected_argc: return UninhabitedType() - return info.func(*args, evaluator=self) + try: + return info.func(*args, evaluator=self) + except TypeLevelError as err: + if not typelevel_ctx._suppress_errors: + self.api.fail(err.message, self.error_ctx, serious=True) + return AnyType(TypeOfAny.from_error) finally: self._current_op = old_op @@ -643,10 +664,12 @@ def _eval_get_arg( target: Type, base: Type, index_arg: Type, *, evaluator: TypeLevelEvaluator ) -> Type: """Evaluate GetArg[T, Base, Idx] - get type argument at index from T as Base.""" - args = _get_args(evaluator, target, base) + eval_target = evaluator.eval_proper(target) + eval_base = evaluator.eval_proper(base) + args = _get_args(evaluator, eval_target, eval_base) if args is None: - return UninhabitedType() + raise TypeLevelError(f"GetArg: {eval_target} is not a subclass of {eval_base}") # Extract index as int index = extract_literal_int(evaluator.eval_proper(index_arg)) @@ -658,17 +681,19 @@ def _eval_get_arg( if 0 <= index < len(args): return args[index] - return UninhabitedType() + raise TypeLevelError(f"GetArg: index out of range for {eval_target} as {eval_base}") @register_operator("GetArgs") @lift_over_unions def _eval_get_args(target: Type, base: Type, *, evaluator: TypeLevelEvaluator) -> Type: """Evaluate GetArgs[T, Base] -> tuple of all type args from T as Base.""" - args = _get_args(evaluator, target, base) + eval_target = evaluator.eval_proper(target) + eval_base = evaluator.eval_proper(base) + args = _get_args(evaluator, eval_target, eval_base) if args is None: - return UninhabitedType() + raise TypeLevelError(f"GetArgs: {eval_target} is not a subclass of {eval_base}") return evaluator.tuple_type(list(args)) @@ -764,8 +789,12 @@ def _eval_get_member(target_arg: Type, name_arg: Type, *, evaluator: TypeLevelEv if name is None: return UninhabitedType() - members = _get_members_dict(target_arg, evaluator=evaluator, attrs_only=False) - return members.get(name, UninhabitedType()) + eval_target = evaluator.eval_proper(target_arg) + members = _get_members_dict(eval_target, evaluator=evaluator, attrs_only=False) + member = members.get(name) + if member is None: + raise TypeLevelError(f"GetMember: {name!r} not found in {eval_target}") + return member @register_operator("GetMemberType") @@ -852,7 +881,7 @@ def _eval_slice( sliced_items = target.items[start:end] return evaluator.tuple_type(sliced_items) - return UninhabitedType() + raise TypeLevelError(f"Slice: {target} is not a tuple or string Literal") @register_operator("Concat") diff --git a/test-data/unit/check-typelevel-basic.test b/test-data/unit/check-typelevel-basic.test index 7c14fbadcca18..7300f069cd29e 100644 --- a/test-data/unit/check-typelevel-basic.test +++ b/test-data/unit/check-typelevel-basic.test @@ -652,7 +652,7 @@ reveal_type(z) # N: Revealed type is "builtins.str" [typing fixtures/typing-full.pyi] [case testTypeOperatorGetArgOutOfBounds] -# Test GetArg with out-of-bounds indexes returns Never +# Test GetArg with out-of-bounds indexes produces an error from typing import Generic, TypeVar, GetArg, Literal T = TypeVar('T') @@ -662,25 +662,25 @@ class MyGeneric(Generic[T, U]): pass # Index too large -x: GetArg[MyGeneric[int, str], MyGeneric, Literal[2]] -reveal_type(x) # N: Revealed type is "Never" +x: GetArg[MyGeneric[int, str], MyGeneric, Literal[2]] # E: GetArg: index out of range for __main__.MyGeneric[int, str] as __main__.MyGeneric[Any, Any] +reveal_type(x) # N: Revealed type is "Any" -x2: GetArg[MyGeneric[int, str], MyGeneric, Literal[100]] -reveal_type(x2) # N: Revealed type is "Never" +x2: GetArg[MyGeneric[int, str], MyGeneric, Literal[100]] # E: GetArg: index out of range for __main__.MyGeneric[int, str] as __main__.MyGeneric[Any, Any] +reveal_type(x2) # N: Revealed type is "Any" # Negative index too small -y: GetArg[MyGeneric[int, str], MyGeneric, Literal[-3]] -reveal_type(y) # N: Revealed type is "Never" +y: GetArg[MyGeneric[int, str], MyGeneric, Literal[-3]] # E: GetArg: index out of range for __main__.MyGeneric[int, str] as __main__.MyGeneric[Any, Any] +reveal_type(y) # N: Revealed type is "Any" -y2: GetArg[MyGeneric[int, str], MyGeneric, Literal[-100]] -reveal_type(y2) # N: Revealed type is "Never" +y2: GetArg[MyGeneric[int, str], MyGeneric, Literal[-100]] # E: GetArg: index out of range for __main__.MyGeneric[int, str] as __main__.MyGeneric[Any, Any] +reveal_type(y2) # N: Revealed type is "Any" # Out of bounds on tuples -z: GetArg[tuple[int, str], tuple, Literal[2]] -reveal_type(z) # N: Revealed type is "Never" +z: GetArg[tuple[int, str], tuple, Literal[2]] # E: GetArg: index out of range for tuple[int, str] as tuple[Any, ...] +reveal_type(z) # N: Revealed type is "Any" -z2: GetArg[tuple[int, str], tuple, Literal[-3]] -reveal_type(z2) # N: Revealed type is "Never" +z2: GetArg[tuple[int, str], tuple, Literal[-3]] # E: GetArg: index out of range for tuple[int, str] as tuple[Any, ...] +reveal_type(z2) # N: Revealed type is "Any" [builtins fixtures/typelevel.pyi] [typing fixtures/typing-full.pyi] diff --git a/test-data/unit/check-typelevel-members.test b/test-data/unit/check-typelevel-members.test index d6b48c27a1092..77796b8744ec3 100644 --- a/test-data/unit/check-typelevel-members.test +++ b/test-data/unit/check-typelevel-members.test @@ -531,9 +531,9 @@ reveal_type(a) # N: Revealed type is "typing.Member[Literal['x'], builtins.int, b: GetMember[Foo, Literal['y']] reveal_type(b) # N: Revealed type is "typing.Member[Literal['y'], builtins.str, Never, Never, __main__.Foo]" -# Non-existent member returns Never -c: GetMember[Foo, Literal['z']] -reveal_type(c) # N: Revealed type is "Never" +# Non-existent member produces an error +c: GetMember[Foo, Literal['z']] # E: GetMember: 'z' not found in __main__.Foo +reveal_type(c) # N: Revealed type is "Any" [builtins fixtures/typelevel.pyi] [typing fixtures/typing-full.pyi] @@ -549,8 +549,8 @@ a: GetMember[TD, Literal['name']] reveal_type(a) # N: Revealed type is "typing.Member[Literal['name'], builtins.str, Never, Never, Never]" b: GetMember[TD, Literal['age']] reveal_type(b) # N: Revealed type is "typing.Member[Literal['age'], builtins.int, Never, Never, Never]" -c: GetMember[TD, Literal['missing']] -reveal_type(c) # N: Revealed type is "Never" +c: GetMember[TD, Literal['missing']] # E: GetMember: 'missing' not found in TypedDict(__main__.TD, {'name': str, 'age': int}) +reveal_type(c) # N: Revealed type is "Any" [builtins fixtures/typelevel.pyi] [typing fixtures/typing-full.pyi] From b33f459c5a58531f34fbf23716f3062850e81b48 Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Thu, 16 Apr 2026 17:12:17 -0700 Subject: [PATCH 158/161] Fix some cases to return Any --- mypy/typelevel.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/mypy/typelevel.py b/mypy/typelevel.py index 29ff2f8e11555..f4775d2a3f9c9 100644 --- a/mypy/typelevel.py +++ b/mypy/typelevel.py @@ -666,6 +666,8 @@ def _eval_get_arg( """Evaluate GetArg[T, Base, Idx] - get type argument at index from T as Base.""" eval_target = evaluator.eval_proper(target) eval_base = evaluator.eval_proper(base) + if isinstance(eval_target, AnyType): + return eval_target args = _get_args(evaluator, eval_target, eval_base) if args is None: @@ -690,6 +692,8 @@ def _eval_get_args(target: Type, base: Type, *, evaluator: TypeLevelEvaluator) - """Evaluate GetArgs[T, Base] -> tuple of all type args from T as Base.""" eval_target = evaluator.eval_proper(target) eval_base = evaluator.eval_proper(base) + if isinstance(eval_target, AnyType): + return eval_target args = _get_args(evaluator, eval_target, eval_base) if args is None: @@ -790,6 +794,8 @@ def _eval_get_member(target_arg: Type, name_arg: Type, *, evaluator: TypeLevelEv return UninhabitedType() eval_target = evaluator.eval_proper(target_arg) + if isinstance(eval_target, AnyType): + return eval_target members = _get_members_dict(eval_target, evaluator=evaluator, attrs_only=False) member = members.get(name) if member is None: @@ -807,7 +813,10 @@ def _eval_get_member_type( if name is None: return UninhabitedType() - members = _get_members_dict(target_arg, evaluator=evaluator, attrs_only=False) + eval_target = evaluator.eval_proper(target_arg) + if isinstance(eval_target, AnyType): + return eval_target + members = _get_members_dict(eval_target, evaluator=evaluator, attrs_only=False) member = members.get(name) if member is not None: # Extract the type argument (index 1) from Member[name, typ, quals, init, definer] @@ -851,6 +860,8 @@ def _eval_slice( ) -> Type: """Evaluate Slice[S, Start, End] - slice a literal string or tuple type.""" target = evaluator.eval_proper(target_arg) + if isinstance(target, AnyType): + return target # Handle start - can be int or None start_type = evaluator.eval_proper(start_arg) From c76b5a65f9bce858206e0657d33e9c476674f786 Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Thu, 16 Apr 2026 18:56:42 -0700 Subject: [PATCH 159/161] Add *Map(...) as a synonym for the *[...] type-comprehension syntax `*Map(Expr for v in Iter[T] if Cond)` now parses and behaves identically to `*[Expr for v in Iter[T] if Cond]`. The parser recognizes Map[] and emits UnboundType('Map', [TypeForComprehension]); typeanal only desugars to the TFC when the name actually resolves to typing.Map / _typeshed.typemap.Map, so a user-defined class named Map is unaffected. --- mypy/exprtotype.py | 125 ++++++++++++++---- mypy/fastparse.py | 53 ++++++++ mypy/semanal.py | 5 + mypy/typeanal.py | 16 +++ mypy/typeshed/stdlib/_typeshed/typemap.pyi | 11 ++ mypy/typeshed/stdlib/typing.pyi | 2 + .../unit/check-typelevel-comprehension.test | 107 +++++++++++++++ test-data/unit/fixtures/typing-full.pyi | 3 + 8 files changed, 296 insertions(+), 26 deletions(-) diff --git a/mypy/exprtotype.py b/mypy/exprtotype.py index 1fe77c53eddd7..c8ea41a449e9f 100644 --- a/mypy/exprtotype.py +++ b/mypy/exprtotype.py @@ -17,6 +17,7 @@ EllipsisExpr, Expression, FloatExpr, + GeneratorExpr, IndexExpr, IntExpr, ListComprehension, @@ -56,6 +57,64 @@ class TypeTranslationError(Exception): """Exception raised when an expression is not valid as a type.""" +def _is_map_name(name: str) -> bool: + """Return True if name syntactically refers to the Map type operator.""" + return name == "Map" or name.endswith(".Map") + + +def _is_map_call(expr: CallExpr) -> bool: + """Return True if expr is Map() — call syntax.""" + if len(expr.args) != 1 or expr.arg_names != [None]: + return False + if not isinstance(expr.args[0], (GeneratorExpr, ListComprehension)): + return False + callee = expr.callee + if isinstance(callee, NameExpr): + return _is_map_name(callee.name) + if isinstance(callee, MemberExpr): + return callee.name == "Map" + return False + + +def _generator_to_type_for_comprehension( + gen: GeneratorExpr, + options: Options, + allow_new_syntax: bool, + lookup_qualified: Callable[[str, Context], SymbolTableNode | None] | None, + line: int, + column: int, +) -> TypeForComprehension: + """Build a TypeForComprehension from a GeneratorExpr (single for-clause). + + Raises TypeTranslationError if the generator expression isn't a supported + form (multiple generators or non-name target). + """ + if len(gen.sequences) != 1: + raise TypeTranslationError() + index = gen.indices[0] + if not isinstance(index, NameExpr): + raise TypeTranslationError() + iter_name = index.name + element_expr = expr_to_unanalyzed_type( + gen.left_expr, options, allow_new_syntax, lookup_qualified=lookup_qualified + ) + iter_type = expr_to_unanalyzed_type( + gen.sequences[0], options, allow_new_syntax, lookup_qualified=lookup_qualified + ) + conditions: list[Type] = [ + expr_to_unanalyzed_type(cond, options, allow_new_syntax, lookup_qualified=lookup_qualified) + for cond in gen.condlists[0] + ] + return TypeForComprehension( + element_expr=element_expr, + iter_name=iter_name, + iter_type=iter_type, + conditions=conditions, + line=line, + column=column, + ) + + def _extract_argument_name(expr: Expression) -> str | None: if isinstance(expr, NameExpr) and expr.name == "None": return None @@ -192,6 +251,20 @@ def expr_to_unanalyzed_type( line=expr.line, column=expr.column, ) + elif isinstance(expr, CallExpr) and _is_map_call(expr): + # Map(genexp) — variadic comprehension operator (call syntax). + base = expr_to_unanalyzed_type( + expr.callee, options, allow_new_syntax, expr, lookup_qualified=lookup_qualified + ) + assert isinstance(base, UnboundType) and not base.args + arg = expr.args[0] + assert isinstance(arg, (GeneratorExpr, ListComprehension)) + gen = arg if isinstance(arg, GeneratorExpr) else arg.generator + tfc = _generator_to_type_for_comprehension( + gen, options, allow_new_syntax, lookup_qualified, expr.line, expr.column + ) + base.args = (tfc,) + return base elif isinstance(expr, CallExpr) and isinstance(_parent, ListExpr): c = expr.callee names = [] @@ -297,35 +370,35 @@ def expr_to_unanalyzed_type( elif allow_unpack and isinstance(expr, StarExpr): # Check if this is a type comprehension: *[Expr for var in Iter if Cond] if isinstance(expr.expr, ListComprehension): - gen = expr.expr.generator - # Only support single generator - if len(gen.sequences) != 1: - raise TypeTranslationError() - # The index should be a simple name - index = gen.indices[0] - if not isinstance(index, NameExpr): - raise TypeTranslationError() - iter_name = index.name - element_expr = expr_to_unanalyzed_type( - gen.left_expr, options, allow_new_syntax, lookup_qualified=lookup_qualified + return _generator_to_type_for_comprehension( + expr.expr.generator, + options, + allow_new_syntax, + lookup_qualified, + expr.line, + expr.column, ) - iter_type = expr_to_unanalyzed_type( - gen.sequences[0], options, allow_new_syntax, lookup_qualified=lookup_qualified + # *Map(genexp) — keep the Map wrapper around the TFC (not an + # UnpackType). typeanal will verify the name resolves to Map and + # desugar to the analyzed TFC; the TFC then participates in variadic + # flattening just like the *[...] form. + if isinstance(expr.expr, CallExpr) and _is_map_call(expr.expr): + inner_base = expr_to_unanalyzed_type( + expr.expr.callee, + options, + allow_new_syntax, + expr.expr, + lookup_qualified=lookup_qualified, ) - conditions: list[Type] = [ - expr_to_unanalyzed_type( - cond, options, allow_new_syntax, lookup_qualified=lookup_qualified - ) - for cond in gen.condlists[0] - ] - return TypeForComprehension( - element_expr=element_expr, - iter_name=iter_name, - iter_type=iter_type, - conditions=conditions, - line=expr.line, - column=expr.column, + assert isinstance(inner_base, UnboundType) and not inner_base.args + arg = expr.expr.args[0] + assert isinstance(arg, (GeneratorExpr, ListComprehension)) + gen = arg if isinstance(arg, GeneratorExpr) else arg.generator + tfc = _generator_to_type_for_comprehension( + gen, options, allow_new_syntax, lookup_qualified, expr.expr.line, expr.expr.column ) + inner_base.args = (tfc,) + return inner_base return UnpackType( expr_to_unanalyzed_type( expr.expr, options, allow_new_syntax, lookup_qualified=lookup_qualified diff --git a/mypy/fastparse.py b/mypy/fastparse.py index 1894b994ee7c6..6134edba0e728 100644 --- a/mypy/fastparse.py +++ b/mypy/fastparse.py @@ -1955,6 +1955,16 @@ def translate_expr_list(self, l: Sequence[ast3.expr]) -> list[Type]: return [self.visit(e) for e in l] def visit_Call(self, e: Call) -> Type: + # Map(genexp) — variadic comprehension operator (call syntax allows + # a bare generator expression without extra parentheses). + if ( + len(e.args) == 1 + and not e.keywords + and isinstance(e.args[0], (ast3.GeneratorExp, ast3.ListComp)) + and self._is_map_name_ast(e.func) + ): + return self._build_map_from_call(e) + # Parse the arg constructor f = e.func constructor = stringify_name(f) @@ -2219,10 +2229,24 @@ def visit_Starred(self, n: ast3.Starred) -> Type: # Check if this is a list comprehension (type comprehension syntax) if isinstance(n.value, ast3.ListComp): return self.visit_ListComp_as_type(n.value) + # *Map(genexp) — pure synonym for *[...]. Produce the TFC directly + # (matching the *[...] path) rather than wrapping in UnpackType. + if ( + isinstance(n.value, ast3.Call) + and len(n.value.args) == 1 + and not n.value.keywords + and isinstance(n.value.args[0], (ast3.GeneratorExp, ast3.ListComp)) + and self._is_map_name_ast(n.value.func) + ): + return self._build_map_from_call(n.value) return UnpackType(self.visit(n.value), from_star_syntax=True) def visit_ListComp_as_type(self, n: ast3.ListComp) -> Type: """Convert *[Expr for var in Iter if Cond] to TypeForComprehension.""" + return self._comprehension_to_type(n) + + def _comprehension_to_type(self, n: ast3.ListComp | ast3.GeneratorExp) -> Type: + """Build a TypeForComprehension from a list or generator comprehension AST.""" # Currently only support single generator if len(n.generators) != 1: return self.invalid_type( @@ -2249,6 +2273,35 @@ def visit_ListComp_as_type(self, n: ast3.ListComp) -> Type: column=self.convert_column(n.col_offset), ) + def _is_map_name_ast(self, node: ast3.AST) -> bool: + """Return True if node syntactically names the Map type operator.""" + if isinstance(node, ast3.Name): + return node.id == "Map" + if isinstance(node, ast3.Attribute): + return node.attr == "Map" + return False + + def _build_map_from_call(self, n: ast3.Call) -> Type: + """Convert Map() to UnboundType(, [TypeForComprehension]). + + The Map wrapper is retained so typeanal can verify that the name + actually resolves to the Map type operator before desugaring to the + TFC. A user class coincidentally named Map will fail resolution there. + """ + assert len(n.args) == 1 and isinstance(n.args[0], (ast3.GeneratorExp, ast3.ListComp)) + tfc = self._comprehension_to_type(n.args[0]) + if not isinstance(tfc, TypeForComprehension): + return tfc + value = self.visit(n.func) + if not isinstance(value, UnboundType) or value.args: + return self.invalid_type(n) + result = UnboundType( + value.name, [tfc], line=self.line, column=self.convert_column(n.col_offset) + ) + result.end_column = n.end_col_offset + result.end_line = n.end_lineno + return result + # List(expr* elts, expr_context ctx) def visit_List(self, n: ast3.List) -> Type: assert isinstance(n.ctx, ast3.Load) diff --git a/mypy/semanal.py b/mypy/semanal.py index b602a1ce0c26b..07e7e4e11fcba 100644 --- a/mypy/semanal.py +++ b/mypy/semanal.py @@ -6273,6 +6273,11 @@ def visit_index_expr(self, expr: IndexExpr) -> None: and not base.node.is_generic() ): expr.index.accept(self) + elif isinstance(expr.index, (GeneratorExpr, ListComprehension)): + # Foo[] is a type-level comprehension; leave + # conversion to expr_to_unanalyzed_type. Just do normal semantic + # analysis for name resolution here. + expr.index.accept(self) elif ( isinstance(base, RefExpr) and isinstance(base.node, TypeAlias) ) or refers_to_class_or_function(base): diff --git a/mypy/typeanal.py b/mypy/typeanal.py index 7f6e09803ceff..2651ccd8e77ed 100644 --- a/mypy/typeanal.py +++ b/mypy/typeanal.py @@ -1184,6 +1184,22 @@ def analyze_type_operator(self, t: UnboundType, type_info: TypeInfo) -> Type: allow_unpack=True, ) + # Map(comprehension) is a pure synonym for *[comprehension]: once + # we've confirmed the name really resolves to the Map type operator, + # unwrap it and return the TypeForComprehension directly. Using the + # TFC (not a TypeOperatorType wrapper) matches the *[...] path and + # avoids eager evaluation that would pre-substitute TypeVarTuples. + if type_info.fullname in ("_typeshed.typemap.Map", "typing.Map"): + if len(an_args) == 1 and isinstance(an_args[0], TypeForComprehension): + return an_args[0] + self.fail( + "Map(...) requires a single comprehension argument, " + "e.g. Map(T for T in Iter[...])", + t, + code=codes.VALID_TYPE, + ) + return AnyType(TypeOfAny.from_error) + # For _TypeGetAttr, eagerly check that the first arg is a type that # supports dot notation. If it's already a concrete type like # tuple[int, str] or a Callable, we can report the error now rather diff --git a/mypy/typeshed/stdlib/_typeshed/typemap.pyi b/mypy/typeshed/stdlib/_typeshed/typemap.pyi index f4d0526d6dcf6..112a22b765fd2 100644 --- a/mypy/typeshed/stdlib/_typeshed/typemap.pyi +++ b/mypy/typeshed/stdlib/_typeshed/typemap.pyi @@ -273,6 +273,17 @@ class Iter(Generic[_T]): ... +@_type_operator +class Map(Generic[_T]): + """ + Variadic type comprehension. + `*Map[(Expr for v in Iter[T] if Cond)]` expands in a variadic context + to the tuple of element types produced by the comprehension, equivalent + to `*[Expr for v in Iter[T] if Cond]`. + """ + + ... + # --- String Operations --- @_type_operator diff --git a/mypy/typeshed/stdlib/typing.pyi b/mypy/typeshed/stdlib/typing.pyi index c4fb7e99037fa..5a51775962acc 100644 --- a/mypy/typeshed/stdlib/typing.pyi +++ b/mypy/typeshed/stdlib/typing.pyi @@ -1213,6 +1213,7 @@ if sys.version_info >= (3, 15): "IsEquivalent", "Bool", "Iter", + "Map", "Slice", "Concat", "Uppercase", @@ -1273,6 +1274,7 @@ if sys.version_info >= (3, 15): KwargsParam as KwargsParam, Length as Length, Lowercase as Lowercase, + Map as Map, IsEquivalent as IsEquivalent, Member as Member, MemberQuals as MemberQuals, diff --git a/test-data/unit/check-typelevel-comprehension.test b/test-data/unit/check-typelevel-comprehension.test index a8d53ab761843..d28f84d3978ef 100644 --- a/test-data/unit/check-typelevel-comprehension.test +++ b/test-data/unit/check-typelevel-comprehension.test @@ -125,3 +125,110 @@ x = {'x': 1, 'y': 2} [builtins fixtures/typelevel.pyi] [typing fixtures/typing-full.pyi] + +[case testMapBasic] +# *Map(...) is a pure synonym for *[...] — basic tuple expansion +from typing import Iter, Map + +x: tuple[*Map(list[T] for T in Iter[tuple[int, str, bool]])] +reveal_type(x) # N: Revealed type is "tuple[builtins.list[builtins.int], builtins.list[builtins.str], builtins.list[builtins.bool]]" + +[builtins fixtures/typelevel.pyi] +[typing fixtures/typing-full.pyi] + +[case testMapWithCondition] +# *Map(...) with an IsAssignable filter +from typing import Iter, IsAssignable, Map + +x: tuple[*Map(list[T] for T in Iter[tuple[int, str, float]] if IsAssignable[T, int])] +reveal_type(x) # N: Revealed type is "tuple[builtins.list[builtins.int]]" + +[builtins fixtures/typelevel.pyi] +[typing fixtures/typing-full.pyi] + +[case testMapTransform] +# *Map(...) transforming each element into a pair +from typing import Iter, Map + +x: tuple[*Map(tuple[T, T] for T in Iter[tuple[int, str]])] +reveal_type(x) # N: Revealed type is "tuple[tuple[builtins.int, builtins.int], tuple[builtins.str, builtins.str]]" + +[builtins fixtures/typelevel.pyi] +[typing fixtures/typing-full.pyi] + +[case testMapInUnion] +# *Map(...) inside Union[...] +from typing import Iter, Map, Union + +x: Union[*Map(list[T] for T in Iter[tuple[int, str]])] +reveal_type(x) # N: Revealed type is "builtins.list[builtins.int] | builtins.list[builtins.str]" + +[builtins fixtures/typelevel.pyi] +[typing fixtures/typing-full.pyi] + +[case testMapListComp] +# Map accepts a list comprehension as well as a generator expression +from typing import Iter, Map + +x: tuple[*Map([list[T] for T in Iter[tuple[int, str]]])] +reveal_type(x) # N: Revealed type is "tuple[builtins.list[builtins.int], builtins.list[builtins.str]]" + +[builtins fixtures/typelevel.pyi] +[typing fixtures/typing-full.pyi] + +[case testMapInTypeAlias] +# flags: --python-version 3.12 +# *Map(...) inside a PEP 695 type alias exercises the exprtotype.py code path +from typing import Iter, Map + +type Listify[*Ts] = tuple[*Map(list[T] for T in Iter[tuple[*Ts]])] + +x: Listify[int, str, bool] +reveal_type(x) # N: Revealed type is "tuple[builtins.list[builtins.int], builtins.list[builtins.str], builtins.list[builtins.bool]]" + +[builtins fixtures/typelevel.pyi] +[typing fixtures/typing-full.pyi] + +[case testMapEquivalentToStarList] +# Side-by-side: *Map(...) and *[...] produce identical types +from typing import Iter, Map + +x: tuple[*Map(list[T] for T in Iter[tuple[int, str, bool]])] +y: tuple[*[list[T] for T in Iter[tuple[int, str, bool]]]] +reveal_type(x) # N: Revealed type is "tuple[builtins.list[builtins.int], builtins.list[builtins.str], builtins.list[builtins.bool]]" +reveal_type(y) # N: Revealed type is "tuple[builtins.list[builtins.int], builtins.list[builtins.str], builtins.list[builtins.bool]]" +x = y +y = x + +[builtins fixtures/typelevel.pyi] +[typing fixtures/typing-full.pyi] + +[case testMapShadowedByUserClass] +# A local class named Map does NOT get the type-comprehension desugaring: +# typeanal only unwraps Map() when the name resolves to the real +# _typeshed.typemap.Map (re-exported as typing.Map). +from typing import Iter + +class Map: ... + +x: tuple[*Map(list[T] for T in Iter[tuple[int, str]])] # E: "Map" expects no type arguments, but 1 given + +[builtins fixtures/typelevel.pyi] +[typing fixtures/typing-full.pyi] + +[case testMapInNewProtocol] +# *Map(...) inside NewProtocol[...] — verifies the variadic flow is identical +# to the *[...] form for non-tuple variadic containers. +from typing import Iter, Map, NewProtocol, Member, Literal + +P = NewProtocol[*Map(Member[Literal['x'], T] for T in Iter[tuple[int]])] + +class Impl: + x: int + +def f(p: P) -> None: ... + +f(Impl()) + +[builtins fixtures/typelevel.pyi] +[typing fixtures/typing-full.pyi] diff --git a/test-data/unit/fixtures/typing-full.pyi b/test-data/unit/fixtures/typing-full.pyi index 14c3e52eca3c8..0a0eaf0a2e612 100644 --- a/test-data/unit/fixtures/typing-full.pyi +++ b/test-data/unit/fixtures/typing-full.pyi @@ -246,6 +246,9 @@ _Ts = TypeVarTuple("_Ts") @_type_operator class Iter(Generic[T]): ... +@_type_operator +class Map(Generic[T]): ... + @_type_operator class IsAssignable(Generic[T, U]): ... From 414b6c50710a2ff42ea728f21ee9b6cc46b22a5c Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Thu, 16 Apr 2026 19:16:23 -0700 Subject: [PATCH 160/161] Collapse Map[] over Iter[Any] to Any in variadic containers Part 2 of the Map[...] plan: *Map[Expr for v in Iter[T] if Cond] now diverges from *[...] only at the Any boundary. When Iter[T] evaluates to Any and the comprehension is Map-flavored, the expansion produces an UnpackType(AnyType) sentinel. Enclosing tuple, Union (via _NewUnion), and NewProtocol (and any other variadic type operator) detect that sentinel and collapse themselves to AnyType. The *[...] form is unchanged: Iter[Any] there still yields tuple[Never] / Never as before. - Add TypeForComprehension.is_map, set from the parser paths that desugar Map[]. - evaluate_comprehension short-circuits on AnyType iter sources only when is_map. - _expand_type_fors_in_args and TypeLevelEvaluator.eval_operator use a shared find_map_any helper to collapse the sentinel in tuple / Instance / variadic-operator contexts. --- mypy/exprtotype.py | 2 + mypy/fastparse.py | 1 + mypy/typelevel.py | 30 +++++++++ mypy/types.py | 53 +++++++++++++++- .../unit/check-typelevel-comprehension.test | 63 +++++++++++++++++++ 5 files changed, 146 insertions(+), 3 deletions(-) diff --git a/mypy/exprtotype.py b/mypy/exprtotype.py index c8ea41a449e9f..a6f3f3f528a7c 100644 --- a/mypy/exprtotype.py +++ b/mypy/exprtotype.py @@ -263,6 +263,7 @@ def expr_to_unanalyzed_type( tfc = _generator_to_type_for_comprehension( gen, options, allow_new_syntax, lookup_qualified, expr.line, expr.column ) + tfc.is_map = True base.args = (tfc,) return base elif isinstance(expr, CallExpr) and isinstance(_parent, ListExpr): @@ -397,6 +398,7 @@ def expr_to_unanalyzed_type( tfc = _generator_to_type_for_comprehension( gen, options, allow_new_syntax, lookup_qualified, expr.expr.line, expr.expr.column ) + tfc.is_map = True inner_base.args = (tfc,) return inner_base return UnpackType( diff --git a/mypy/fastparse.py b/mypy/fastparse.py index 6134edba0e728..fad7e90a87759 100644 --- a/mypy/fastparse.py +++ b/mypy/fastparse.py @@ -2292,6 +2292,7 @@ def _build_map_from_call(self, n: ast3.Call) -> Type: tfc = self._comprehension_to_type(n.args[0]) if not isinstance(tfc, TypeForComprehension): return tfc + tfc.is_map = True value = self.visit(n.func) if not isinstance(value, UnboundType) or value.args: return self.invalid_type(n) diff --git a/mypy/typelevel.py b/mypy/typelevel.py index f4775d2a3f9c9..0fb39fed63bc3 100644 --- a/mypy/typelevel.py +++ b/mypy/typelevel.py @@ -151,6 +151,21 @@ class OperatorInfo: EXPANSION_OVERFLOW = AnyType(TypeOfAny.from_error) +def find_map_any_in_args(args: list[Type]) -> AnyType | None: + """Return the inner AnyType if any arg is an UnpackType(AnyType) sentinel. + + This sentinel is produced by evaluating a *Map[...] comprehension whose + Iter[...] source is Any; enclosing variadic operators / containers use + its presence to collapse themselves to Any. + """ + for arg in args: + if isinstance(arg, UnpackType): + inner = get_proper_type(arg.type) + if isinstance(inner, AnyType): + return inner + return None + + def register_operator(name: str) -> Callable[[OperatorFunc], OperatorFunc]: """Decorator to register an operator evaluator. @@ -311,6 +326,10 @@ def eval_operator(self, typ: TypeOperatorType) -> Type: args = typ.args if info.expected_argc is None: args = self.flatten_args(args) + # If a *Map[...]-over-Any sentinel landed in the flattened args, + # collapse the whole variadic operator call to Any. + if (map_any := find_map_any_in_args(args)) is not None: + return map_any elif len(args) != info.expected_argc: return UninhabitedType() try: @@ -336,6 +355,8 @@ def flatten_args(self, args: list[Type]) -> list[Type]: """Flatten type arguments, evaluating and unpacking as needed. Handles UnpackType from comprehensions by expanding the inner TupleType. + An UnpackType(AnyType) sentinel (produced by *Map[...] over Iter[Any]) + is passed through for the caller to detect via find_map_any_in_args. """ flat_args: list[Type] = [] for arg in args: @@ -471,6 +492,10 @@ def _eval_iter(arg: Type, *, evaluator: TypeLevelEvaluator) -> Type: target = evaluator.eval_proper(arg) if isinstance(target, TupleType): return target + elif isinstance(target, AnyType): + # Propagate Any so enclosing operators (notably Map) can detect it + # and short-circuit at the Any boundary. + return target else: return UninhabitedType() @@ -1414,6 +1439,11 @@ def evaluate_comprehension(evaluator: TypeLevelEvaluator, typ: TypeForComprehens # Get the iterable type and expand it to a TupleType iter_proper = evaluator.eval_proper(typ.iter_type) + if isinstance(iter_proper, AnyType) and typ.is_map: + # Map-over-Any: propagate Any as an UnpackType(AnyType) sentinel + # that the enclosing variadic container collapses to Any. + return UnpackType(AnyType(TypeOfAny.from_another_any, source_any=iter_proper)) + if not isinstance(iter_proper, TupleType): # Can only iterate over tuple types return UninhabitedType() diff --git a/mypy/types.py b/mypy/types.py index 1a974aa016d87..c4227c803c7bc 100644 --- a/mypy/types.py +++ b/mypy/types.py @@ -601,7 +601,7 @@ class TypeForComprehension(ComputedType): Expands to a tuple of types. """ - __slots__ = ("element_expr", "iter_name", "iter_type", "conditions", "iter_var") + __slots__ = ("element_expr", "iter_name", "iter_type", "conditions", "iter_var", "is_map") def __init__( self, @@ -612,6 +612,8 @@ def __init__( iter_var: TypeVarType | None = None, # Typically populated by typeanal line: int = -1, column: int = -1, + *, + is_map: bool = False, ) -> None: super().__init__(line, column) self.element_expr = element_expr @@ -619,6 +621,10 @@ def __init__( self.iter_type = iter_type self.conditions = conditions self.iter_var: TypeVarType | None = iter_var + # True when this comprehension was desugared from `Map[...]` syntax. + # Changes behavior at the Map boundary: Iter[Any] propagates as Any + # through the enclosing variadic context instead of erroring. + self.is_map = is_map def type_param(self) -> mypy.nodes.TypeParam: return mypy.nodes.TypeParam(self.iter_name, mypy.nodes.TYPE_VAR_KIND, None, [], None) @@ -627,7 +633,15 @@ def accept(self, visitor: TypeVisitor[T]) -> T: return visitor.visit_type_for_comprehension(self) def __hash__(self) -> int: - return hash((self.element_expr, self.iter_name, self.iter_type, tuple(self.conditions))) + return hash( + ( + self.element_expr, + self.iter_name, + self.iter_type, + tuple(self.conditions), + self.is_map, + ) + ) def __eq__(self, other: object) -> bool: if not isinstance(other, TypeForComprehension): @@ -637,6 +651,7 @@ def __eq__(self, other: object) -> bool: and self.iter_name == other.iter_name and self.iter_type == other.iter_type and self.conditions == other.conditions + and self.is_map == other.is_map ) def __repr__(self) -> str: @@ -651,6 +666,7 @@ def serialize(self) -> JsonDict: "iter_type": self.iter_type.serialize(), "conditions": [c.serialize() for c in self.conditions], "iter_var": self.iter_var.serialize() if self.iter_var else None, + "is_map": self.is_map, } @classmethod @@ -663,6 +679,7 @@ def deserialize(cls, data: JsonDict) -> TypeForComprehension: deserialize_type(data["iter_type"]), [deserialize_type(c) for c in data["conditions"]], iter_var=cast(TypeVarType, deserialize_type(var)) if var else None, + is_map=data.get("is_map", False), ) def copy_modified( @@ -673,6 +690,7 @@ def copy_modified( iter_type: Type | None = None, conditions: list[Type] | None = None, iter_var: TypeVarType | None = None, # Typically populated by typeanal + is_map: bool | None = None, ) -> TypeForComprehension: return TypeForComprehension( element_expr if element_expr is not None else self.element_expr, @@ -682,6 +700,7 @@ def copy_modified( iter_var if iter_var is not None else self.iter_var, self.line, self.column, + is_map=is_map if is_map is not None else self.is_map, ) def write(self, data: WriteBuffer) -> None: @@ -693,6 +712,7 @@ def write(self, data: WriteBuffer) -> None: for cond in self.conditions: cond.write(data) write_type_opt(data, self.iter_var) + write_bool(data, self.is_map) write_tag(data, END_TAG) @classmethod @@ -703,9 +723,12 @@ def read(cls, data: ReadBuffer) -> TypeForComprehension: num_conditions = read_int(data) conditions = [read_type(data) for _ in range(num_conditions)] iter_var = cast(TypeVarType | None, read_type_opt(data)) + is_map = read_bool(data) assert read_tag(data) == END_TAG - return TypeForComprehension(element_expr, iter_name, iter_type, conditions, iter_var) + return TypeForComprehension( + element_expr, iter_name, iter_type, conditions, iter_var, is_map=is_map + ) class TypeGuardedType(Type): @@ -3930,6 +3953,23 @@ def _could_be_computed_unpack(t: Type) -> bool: ) +def _find_map_any(items: Iterable[Type]) -> AnyType | None: + """Return the AnyType payload from the first *Map[...]-over-Any sentinel in items. + + The sentinel is an UnpackType wrapping an AnyType, produced by + evaluate_comprehension on a Map-flavored TypeForComprehension whose + Iter[...] source is Any. The enclosing variadic container should + collapse to AnyType when it sees one. + """ + for item in items: + if not isinstance(item, UnpackType): + continue + inner = get_proper_type(item.type) + if isinstance(inner, AnyType): + return inner + return None + + def _expand_type_fors_in_args(typ: ProperType) -> ProperType: """ Expand any TypeForComprehensions in type arguments. @@ -3948,6 +3988,10 @@ def _expand_type_fors_in_args(typ: ProperType) -> ProperType: # expanding the types might produce Unpacks, which we use # expand_type to substitute in. typ = expand_type(typ2, {}) + if isinstance(typ, TupleType): + if (map_any := _find_map_any(typ.items)) is not None: + # A *Map[...] over Iter[Any] landed here; propagate Any through the tuple. + return map_any elif ( isinstance(typ, Instance) and typ.type # Make sure it's not a FakeInfo @@ -3956,6 +4000,9 @@ def _expand_type_fors_in_args(typ: ProperType) -> ProperType: ): typ2 = typ.copy_modified(args=[get_proper_type(st) for st in typ.args]) typ = expand_type(typ2, {}) + if isinstance(typ, Instance): + if (map_any := _find_map_any(typ.args)) is not None: + return map_any elif isinstance(typ, UnpackType) and _could_be_computed_unpack(typ): # No need to expand here expanded = get_proper_type(typ.type) diff --git a/test-data/unit/check-typelevel-comprehension.test b/test-data/unit/check-typelevel-comprehension.test index d28f84d3978ef..93fa3677a9d95 100644 --- a/test-data/unit/check-typelevel-comprehension.test +++ b/test-data/unit/check-typelevel-comprehension.test @@ -232,3 +232,66 @@ f(Impl()) [builtins fixtures/typelevel.pyi] [typing fixtures/typing-full.pyi] + +[case testMapOverIterAnyInTuple] +# *Map(...) over Iter[Any] collapses the enclosing tuple to Any. +# The *[...] form is unchanged (Iter[Any] still errors / produces tuple[Never]). +from typing import Iter, Map, Any + +x: tuple[*Map(list[T] for T in Iter[Any])] +reveal_type(x) # N: Revealed type is "Any" + +y: tuple[*[list[T] for T in Iter[Any]]] +reveal_type(y) # N: Revealed type is "tuple[Never]" + +[builtins fixtures/typelevel.pyi] +[typing fixtures/typing-full.pyi] + +[case testMapOverIterAnyInUnion] +# *Map(...) over Iter[Any] collapses the enclosing Union to Any. +from typing import Iter, Map, Union, Any + +x: Union[*Map(list[T] for T in Iter[Any])] +reveal_type(x) # N: Revealed type is "Any" + +y: Union[*[list[T] for T in Iter[Any]]] +reveal_type(y) # N: Revealed type is "Never" + +[builtins fixtures/typelevel.pyi] +[typing fixtures/typing-full.pyi] + +[case testMapOverIterAnyInNewProtocol] +# *Map(...) over Iter[Any] collapses the enclosing NewProtocol to Any: +# any value satisfies the resulting protocol without error. +from typing import Iter, Map, NewProtocol, Member, Literal, Any + +P = NewProtocol[*Map(Member[Literal['x'], T] for T in Iter[Any])] + +def f(p: P) -> None: ... + +class Impl: ... + +f(Impl()) # P collapsed to Any, so this is fine +f(42) + +[builtins fixtures/typelevel.pyi] +[typing fixtures/typing-full.pyi] + +[case testMapOverIterAnyInTypeAlias] +# flags: --python-version 3.12 +# The Map-over-Any collapse flows through a PEP 695 alias. +from typing import Iter, Map, Any + +type MapList[X] = tuple[*Map(list[T] for T in Iter[X])] + +# When X = Any the comprehension source is Iter[Any]; Map collapses to Any. +x: MapList[Any] +reveal_type(x) # N: Revealed type is "Any" + +# Non-Map form keeps prior behavior: Iter[Any] does not collapse. +type StarList[X] = tuple[*[list[T] for T in Iter[X]]] +y: StarList[Any] +reveal_type(y) # N: Revealed type is "tuple[Never]" + +[builtins fixtures/typelevel.pyi] +[typing fixtures/typing-full.pyi] From 5250279d38109fedafff709488939c38901783bd Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Thu, 16 Apr 2026 20:22:27 -0700 Subject: [PATCH 161/161] Emit error when *[...] comprehension iterates over explicit Any *[Expr for v in Iter[Any]] now emits an error suggesting Map(...) instead of silently producing tuple[Never]. The error only fires for explicitly-written Any (TypeOfAny.explicit), not for Any values arising from TypeVar substitution during subtype checks. --- mypy/typelevel.py | 11 ++++++++++- test-data/unit/check-typelevel-comprehension.test | 14 +++++++------- 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/mypy/typelevel.py b/mypy/typelevel.py index 0fb39fed63bc3..e7660fb928813 100644 --- a/mypy/typelevel.py +++ b/mypy/typelevel.py @@ -1444,8 +1444,17 @@ def evaluate_comprehension(evaluator: TypeLevelEvaluator, typ: TypeForComprehens # that the enclosing variadic container collapses to Any. return UnpackType(AnyType(TypeOfAny.from_another_any, source_any=iter_proper)) + if isinstance(iter_proper, AnyType) and not typ.is_map: + if iter_proper.type_of_any == TypeOfAny.explicit and not typelevel_ctx._suppress_errors: + evaluator.api.fail( + "Type comprehension requires Iter over a tuple type, got Any;" + " use Map(...) to propagate Any", + evaluator.error_ctx, + serious=True, + ) + return AnyType(TypeOfAny.from_error) + if not isinstance(iter_proper, TupleType): - # Can only iterate over tuple types return UninhabitedType() # Process each item in the tuple diff --git a/test-data/unit/check-typelevel-comprehension.test b/test-data/unit/check-typelevel-comprehension.test index 93fa3677a9d95..9d06db6b50135 100644 --- a/test-data/unit/check-typelevel-comprehension.test +++ b/test-data/unit/check-typelevel-comprehension.test @@ -235,14 +235,14 @@ f(Impl()) [case testMapOverIterAnyInTuple] # *Map(...) over Iter[Any] collapses the enclosing tuple to Any. -# The *[...] form is unchanged (Iter[Any] still errors / produces tuple[Never]). +# The *[...] form errors because Iter[Any] is not a tuple type. from typing import Iter, Map, Any x: tuple[*Map(list[T] for T in Iter[Any])] reveal_type(x) # N: Revealed type is "Any" -y: tuple[*[list[T] for T in Iter[Any]]] -reveal_type(y) # N: Revealed type is "tuple[Never]" +y: tuple[*[list[T] for T in Iter[Any]]] # E: Type comprehension requires Iter over a tuple type, got Any; use Map(...) to propagate Any +reveal_type(y) # N: Revealed type is "tuple[Any]" [builtins fixtures/typelevel.pyi] [typing fixtures/typing-full.pyi] @@ -254,8 +254,8 @@ from typing import Iter, Map, Union, Any x: Union[*Map(list[T] for T in Iter[Any])] reveal_type(x) # N: Revealed type is "Any" -y: Union[*[list[T] for T in Iter[Any]]] -reveal_type(y) # N: Revealed type is "Never" +y: Union[*[list[T] for T in Iter[Any]]] # E: Type comprehension requires Iter over a tuple type, got Any; use Map(...) to propagate Any +reveal_type(y) # N: Revealed type is "Any" [builtins fixtures/typelevel.pyi] [typing fixtures/typing-full.pyi] @@ -289,9 +289,9 @@ x: MapList[Any] reveal_type(x) # N: Revealed type is "Any" # Non-Map form keeps prior behavior: Iter[Any] does not collapse. -type StarList[X] = tuple[*[list[T] for T in Iter[X]]] +type StarList[X] = tuple[*[list[T] for T in Iter[X]]] # E: Type comprehension requires Iter over a tuple type, got Any; use Map(...) to propagate Any y: StarList[Any] -reveal_type(y) # N: Revealed type is "tuple[Never]" +reveal_type(y) # N: Revealed type is "tuple[Any]" [builtins fixtures/typelevel.pyi] [typing fixtures/typing-full.pyi]