From c6c2e6b68379acae05ae77fee6e9bbb3d6ad37f1 Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Thu, 16 Apr 2026 17:17:49 -0700 Subject: [PATCH 1/4] Replace *[comprehension] with *Map(comprehension) for type-level iteration Map is a new operator that wraps the comprehension and will eventually support different semantics (e.g. propagating Any). For now it behaves identically to the bare list comprehension. Mypy tests for files that use Map are xfailed until the stubs are updated. --- pyproject.toml | 2 +- tests/test_astlike_1.py | 13 +++++----- tests/test_call.py | 3 ++- tests/test_dataclass_like.py | 6 ++--- tests/test_eval_call_with_types.py | 5 ++-- tests/test_fastapilike_1.py | 21 ++++++++-------- tests/test_fastapilike_2.py | 22 ++++++++-------- tests/test_qblike.py | 7 +++--- tests/test_qblike_2.py | 8 +++--- tests/test_qblike_3.py | 39 ++++++++++++++++------------- tests/test_schemalike.py | 7 +++--- tests/test_ts_utility.py | 28 +++++++++++---------- tests/test_type_dir.py | 23 +++++++++-------- tests/test_type_eval.py | 37 ++++++++++++++------------- tests/test_ziplike.py | 8 +++--- typemap/type_eval/_apply_generic.py | 3 ++- typemap/typing.py | 5 ++++ uv.lock | 6 ++--- 18 files changed, 132 insertions(+), 111 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index ee5d963..68d3803 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,7 +13,7 @@ include = ["typemap", "typemap.*", "typemap_extensions"] test = [ "pytest>=7.0", "ruff", - "mypy @ git+https://github.com/msullivan/mypy-typemap@fbc5d6c16834379307857318e6c32326b5d8a201", + "mypy @ git+https://github.com/msullivan/mypy-typemap@5250279d38109fedafff709488939c38901783bd", ] [tool.uv] diff --git a/tests/test_astlike_1.py b/tests/test_astlike_1.py index 957253b..ec13fe3 100644 --- a/tests/test_astlike_1.py +++ b/tests/test_astlike_1.py @@ -14,6 +14,7 @@ IsAssignable, Iter, IsEquivalent, + Map, Member, NewProtocol, RaiseError, @@ -46,7 +47,7 @@ type CombineVarArgs[Ls: tuple[VarArg], Rs: tuple[VarArg]] = tuple[ - *[ + *Map( VarArg[ VarArgName[x], ( @@ -56,7 +57,7 @@ ) else GetArg[ # Common to both Ls and Rs tuple[ - *[ + *Map( ( VarArgType[x] if IsAssignable[VarArgType[x], VarArgType[y]] @@ -73,7 +74,7 @@ ) for y in Iter[Rs] if IsEquivalent[VarArgName[x], VarArgName[y]] - ] + ) ], tuple, typing.Literal[0], @@ -81,14 +82,14 @@ ), ] for x in Iter[Ls] - ], - *[ # Unique to Rs + ), + *Map( # Unique to Rs x for x in Iter[Rs] if not any( # Unique to Rs IsEquivalent[VarArgName[x], VarArgName[y]] for y in Iter[Ls] ) - ], + ), ] diff --git a/tests/test_call.py b/tests/test_call.py index e730dca..3b0e91f 100644 --- a/tests/test_call.py +++ b/tests/test_call.py @@ -7,6 +7,7 @@ Attrs, BaseTypedDict, NewProtocol, + Map, Member, Iter, ) @@ -17,7 +18,7 @@ def func[*T, K: BaseTypedDict]( *args: Unpack[T], **kwargs: Unpack[K], -) -> NewProtocol[*[Member[c.name, int] for c in Iter[Attrs[K]]]]: +) -> NewProtocol[*Map(Member[c.name, int] for c in Iter[Attrs[K]])]: raise NotImplementedError diff --git a/tests/test_dataclass_like.py b/tests/test_dataclass_like.py index 36a66bf..cd6aaf7 100644 --- a/tests/test_dataclass_like.py +++ b/tests/test_dataclass_like.py @@ -65,7 +65,7 @@ def _check_hero_init() -> None: Callable[ typing.Params[ typing.Param[Literal["self"], T], - *[ + *typing.Map( typing.Param[ p.name, p.type, @@ -79,7 +79,7 @@ def _check_hero_init() -> None: else Literal["keyword", "default"], ] for p in typing.Iter[typing.Attrs[T]] - ], + ), ], None, ], @@ -87,7 +87,7 @@ def _check_hero_init() -> None: ] type AddInit[T] = typing.NewProtocol[ InitFnType[T], - *[x for x in typing.Iter[typing.Members[T]]], + *typing.Map(x for x in typing.Iter[typing.Members[T]]), ] """ diff --git a/tests/test_eval_call_with_types.py b/tests/test_eval_call_with_types.py index 1c953b8..534bea2 100644 --- a/tests/test_eval_call_with_types.py +++ b/tests/test_eval_call_with_types.py @@ -10,6 +10,7 @@ GetArg, IsAssignable, Iter, + Map, Members, Param, Params, @@ -287,7 +288,7 @@ def func[T](x: C[T]) -> T: ... type GetCallableMember[T, N: str] = GetArg[ tuple[ - *[ + *Map( m.type for m in Iter[Members[T]] if ( @@ -295,7 +296,7 @@ def func[T](x: C[T]) -> T: ... or IsAssignable[m.type, GenericCallable] ) and IsAssignable[m.name, N] - ] + ) ], tuple, Literal[0], diff --git a/tests/test_fastapilike_1.py b/tests/test_fastapilike_1.py index 3456f23..b3ac9ef 100644 --- a/tests/test_fastapilike_1.py +++ b/tests/test_fastapilike_1.py @@ -14,6 +14,7 @@ from typemap_extensions import ( NewProtocol, Iter, + Map, Attrs, IsAssignable, GetAnnotations, @@ -53,7 +54,7 @@ class _Default: Callable[ Params[ Param[Literal["self"], Self], - *[ + *Map( Param[ p.name, DropAnnotations[p.type], @@ -65,7 +66,7 @@ class _Default: else Literal["keyword"], ] for p in Iter[Attrs[T]] - ], + ), ], None, ], @@ -76,13 +77,13 @@ class _Default: InitFnType[T], # TODO: mypy rejects this -- should it work? # *Members[T], - *[t for t in Iter[Members[T]]], + *Map(t for t in Iter[Members[T]]), ] # Strip `| None` from a type by iterating over its union components # and filtering type NotOptional[T] = Union[ - *[x for x in Iter[FromUnion[T]] if not IsAssignable[x, None]] + *Map(x for x in Iter[FromUnion[T]] if not IsAssignable[x, None]) ] # Adjust an attribute type for use in Public below by dropping | None for @@ -97,27 +98,27 @@ class _Default: # 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] = NewProtocol[ - *[ + *Map( Member[p.name, FixPublicType[p.type], p.quals] for p in Iter[Attrs[T]] if not IsAssignable[Literal[PropQuals.HIDDEN], GetAnnotations[p.type]] - ] + ) ] # Create takes everything but the primary key and preserves defaults type Create[T] = NewProtocol[ - *[ + *Map( Member[p.name, p.type, p.quals] for p in Iter[Attrs[T]] if not IsAssignable[Literal[PropQuals.PRIMARY], GetAnnotations[p.type]] - ] + ) ] # Update takes everything but the primary key, but makes them all have # None defaults type Update[T] = NewProtocol[ - *[ + *Map( Member[ p.name, HasDefault[DropAnnotations[p.type] | None, None], @@ -125,7 +126,7 @@ class _Default: ] for p in Iter[Attrs[T]] if not IsAssignable[Literal[PropQuals.PRIMARY], GetAnnotations[p.type]] - ] + ) ] diff --git a/tests/test_fastapilike_2.py b/tests/test_fastapilike_2.py index 6913e05..94cb636 100644 --- a/tests/test_fastapilike_2.py +++ b/tests/test_fastapilike_2.py @@ -37,11 +37,11 @@ class Field[T: FieldArgs](typing.InitField[T]): # Strip `| None` from a type by iterating over its union components # and filtering type NotOptional[T] = Union[ - *[ + *typing.Map( 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 @@ -58,7 +58,7 @@ class Field[T: FieldArgs](typing.InitField[T]): # 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.Map( typing.Member[ p.name, FixPublicType[p.type, p.init], @@ -68,7 +68,7 @@ class Field[T: FieldArgs](typing.InitField[T]): if not typing.IsAssignable[ Literal[True], GetFieldItem[p.init, Literal["hidden"]] ] - ] + ) ] # Begin PEP section: Automatically deriving FastAPI CRUD models @@ -88,7 +88,7 @@ class Field[T: FieldArgs](typing.InitField[T]): # Create takes everything but the primary key and preserves defaults type Create[T] = typing.NewProtocol[ - *[ + *typing.Map( typing.Member[ p.name, p.type, @@ -100,7 +100,7 @@ class Field[T: FieldArgs](typing.InitField[T]): Literal[True], GetFieldItem[p.init, Literal["primary_key"]], ] - ] + ) ] """ @@ -122,7 +122,7 @@ class Field[T: FieldArgs](typing.InitField[T]): # Update takes everything but the primary key, but makes them all have # None defaults type Update[T] = typing.NewProtocol[ - *[ + *typing.Map( typing.Member[ p.name, p.type | None, @@ -134,7 +134,7 @@ class Field[T: FieldArgs](typing.InitField[T]): Literal[True], GetFieldItem[p.init, Literal["primary_key"]], ] - ] + ) ] ## @@ -145,7 +145,7 @@ class Field[T: FieldArgs](typing.InitField[T]): Callable[ typing.Params[ typing.Param[Literal["self"], Self], # type: ignore[misc] - *[ + *typing.Map( typing.Param[ p.name, p.type, @@ -159,7 +159,7 @@ class Field[T: FieldArgs](typing.InitField[T]): else Literal["keyword", "default"], ] for p in typing.Iter[typing.Attrs[T]] - ], + ), ], None, ], @@ -167,7 +167,7 @@ class Field[T: FieldArgs](typing.InitField[T]): ] type AddInit[T] = typing.NewProtocol[ InitFnType[T], - *[x for x in typing.Iter[typing.Members[T]]], + *typing.Map(x for x in typing.Iter[typing.Members[T]]), ] diff --git a/tests/test_qblike.py b/tests/test_qblike.py index 3fb088c..1665659 100644 --- a/tests/test_qblike.py +++ b/tests/test_qblike.py @@ -11,6 +11,7 @@ BaseTypedDict, NewProtocol, Iter, + Map, Attrs, IsAssignable, Member, @@ -30,7 +31,7 @@ class Link[T]: type PropsOnly[T] = NewProtocol[ - *[p for p in Iter[Attrs[T]] if IsAssignable[p.type, Property]] + *Map(p for p in Iter[Attrs[T]] if IsAssignable[p.type, Property]) ] # Conditional type alias! @@ -44,13 +45,13 @@ def select[K: BaseTypedDict]( /, **kwargs: Unpack[K], ) -> NewProtocol[ - *[ + *Map( Member[ c.name, FilterLinks[GetMemberType[A, c.name]], ] for c in Iter[Attrs[K]] - ] + ) ]: raise NotImplementedError diff --git a/tests/test_qblike_2.py b/tests/test_qblike_2.py index 67a8c45..edddac3 100644 --- a/tests/test_qblike_2.py +++ b/tests/test_qblike_2.py @@ -58,13 +58,13 @@ def select[ModelT, K: typing.BaseTypedDict]( **kwargs: Unpack[K], ) -> list[ typing.NewProtocol[ - *[ + *typing.Map( typing.Member[ c.name, ConvertField[typing.GetMemberType[ModelT, c.name]], ] for c in typing.Iter[typing.Attrs[K]] - ] + ) ] ]: raise NotImplementedError @@ -112,11 +112,11 @@ def select[ModelT, K: typing.BaseTypedDict]( """ type PropsOnly[T] = typing.NewProtocol[ - *[ + *typing.Map( typing.Member[p.name, PointerArg[p.type]] for p in typing.Iter[typing.Attrs[T]] if typing.IsAssignable[p.type, Property] - ] + ) ] """ diff --git a/tests/test_qblike_3.py b/tests/test_qblike_3.py index b6cea25..50162ac 100644 --- a/tests/test_qblike_3.py +++ b/tests/test_qblike_3.py @@ -27,6 +27,7 @@ IsAssignable, Iter, IsEquivalent, + Map, Member, Members, NewProtocol, @@ -139,7 +140,7 @@ class Table[name: str]: def __init_subclass__[T]( cls: type[T], ) -> UpdateClass[ - *[ + *Map( Member[ m.name, _Field[ @@ -152,8 +153,8 @@ def __init_subclass__[T]( ] for m in Iter[Members[T]] if IsAssignable[m.type, Field] - ], - *[m for m in Iter[Members[T]] if not IsAssignable[m.type, Field]], + ), + *Map(m for m in Iter[Members[T]] if not IsAssignable[m.type, Field]), ]: super().__init_subclass__() @@ -241,11 +242,11 @@ class DbLinkSource[Args: DbLinkSourceArgs](InitField[Args]): type EntryFields[E: QueryEntry] = GetArg[E, tuple, Literal[1]] type EntryFieldMembers[T: Table, FieldNames: tuple[Literal[str], ...]] = tuple[ - *[ + *Map( m for m in Iter[Attrs[T]] if any(IsAssignable[m.name, f] for f in Iter[FieldNames]) - ] + ) ] type EntryIsTable[E: QueryEntry, T: Table] = IsEquivalent[EntryTable[E], T] @@ -255,7 +256,9 @@ class DbLinkSource[Args: DbLinkSourceArgs](InitField[Args]): type MakeQueryEntryAllFields[T: Table] = QueryEntry[ T, - tuple[*[m.name for m in Iter[Attrs[T]] if IsAssignable[m.type, _Field]],], + tuple[ + *Map(m.name for m in Iter[Attrs[T]] if IsAssignable[m.type, _Field]), + ], ] type MakeQueryEntryNamedFields[ T: Table, @@ -263,22 +266,22 @@ class DbLinkSource[Args: DbLinkSourceArgs](InitField[Args]): ] = QueryEntry[ T, tuple[ - *[ + *Map( m.name for m in Iter[Attrs[T]] if IsAssignable[m.type, _Field] and any( IsAssignable[FieldName[m.type], f] for f in Iter[FieldNames] ) - ], + ), ], ] type AddTable[Entries, New: Table] = tuple[ - *[ # Existing entries + *Map( # Existing entries (e if not Bool[EntryIsTable[e, New]] else MakeQueryEntryAllFields[New]) for e in Iter[Entries] - ], + ), *( # Add entries if not present [] if Bool[EntriesHasTable[Entries, New]] @@ -286,17 +289,17 @@ class DbLinkSource[Args: DbLinkSourceArgs](InitField[Args]): ), ] type AddField[Entries, New: _Field] = tuple[ - *[ # Existing entries + *Map( # Existing entries ( e # Non-matching entry if not Bool[EntryIsTable[e, FieldTable[New]]] else MakeQueryEntryNamedFields[ EntryTable[e], - tuple[*[f for f in Iter[EntryFields[e]]], FieldName[New]], + tuple[*Map(f for f in Iter[EntryFields[e]]), FieldName[New]], ] ) for e in Iter[Entries] - ], + ), *( # Add entries if not present e for e in Iter[tuple[QueryEntry[FieldTable[New], tuple[FieldName[New]]]]] @@ -326,7 +329,7 @@ class Query[Es: tuple[QueryEntry[Table, tuple[Member]], ...]]: type Select[T: Table, FieldNames: tuple[Literal[str], ...]] = NewProtocol[ - *[ + *Map( Member[ m.name, ( @@ -336,7 +339,7 @@ class Query[Es: tuple[QueryEntry[Table, tuple[Member]], ...]]: ), ] for m in Iter[EntryFieldMembers[T, FieldNames]] - ], + ), ] @@ -347,13 +350,13 @@ class Query[Es: tuple[QueryEntry[Table, tuple[Member]], ...]]: ] if IsAssignable[Literal[1], Length[Es]] else NewProtocol[ - *[ + *Map( Member[ GetSpecialAttr[EntryTable[e], Literal["__name__"]], Select[EntryTable[e], EntryFields[e]], ] for e in Iter[Es] - ] + ) ] ) @@ -413,7 +416,7 @@ class Comment(Table[Literal["comments"]]): # Tests -type AttrNames[T] = tuple[*[f.name for f in Iter[Attrs[T]]]] +type AttrNames[T] = tuple[*Map(f.name for f in Iter[Attrs[T]])] def test_qblike_3_select_01(): diff --git a/tests/test_schemalike.py b/tests/test_schemalike.py index c982533..62a9be1 100644 --- a/tests/test_schemalike.py +++ b/tests/test_schemalike.py @@ -6,6 +6,7 @@ from typemap_extensions import ( NewProtocol, Iter, + Map, Attrs, Member, NamedParam, @@ -42,8 +43,8 @@ class Property: type Schemaify[T] = NewProtocol[ - *[p for p in Iter[Attrs[T]]], - *[ + *Map(p for p in Iter[Attrs[T]]), + *Map( Member[ Concat[Literal["get_"], p.name], Callable[ @@ -56,7 +57,7 @@ class Property: Literal["ClassVar"], ] for p in Iter[Attrs[T]] - ], + ), ] diff --git a/tests/test_ts_utility.py b/tests/test_ts_utility.py index 5eed2a5..c0bf4f6 100644 --- a/tests/test_ts_utility.py +++ b/tests/test_ts_utility.py @@ -48,66 +48,68 @@ class TodoTD(TypedDict): # Pick # Constructs a type by picking the set of properties Keys from T. type Pick[T, Keys] = typing.NewProtocol[ - *[ + *typing.Map( p for p in typing.Iter[typing.Members[T]] if typing.IsAssignable[p.name, Keys] - ] + ) ] # Omit # Constructs a type by picking all properties from T and then removing Keys. # Note that unlike in TS, our Omit does not depend on Exclude. type Omit[T, Keys] = typing.NewProtocol[ - *[ + *typing.Map( p for p in typing.Iter[typing.Members[T]] if not typing.IsAssignable[p.name, Keys] - ] + ) ] # KeyOf[T] # Constructs a union of the names of every member of T. -type KeyOf[T] = Union[*[p.name for p in typing.Iter[typing.Members[T]]]] +type KeyOf[T] = Union[ + *typing.Map(p.name for p in typing.Iter[typing.Members[T]]) +] # Exclude # Constructs a type by excluding from T all union members assignable to U. type Exclude[T, U] = Union[ - *[ + *typing.Map( x for x in typing.Iter[typing.FromUnion[T]] if not typing.IsAssignable[x, U] - ] + ) ] # Extract # Constructs a type by extracting from T all union members assignable to U. type Extract[T, U] = Union[ - *[ + *typing.Map( x for x in typing.Iter[typing.FromUnion[T]] # Just the inverse of Exclude, really if typing.IsAssignable[x, U] - ] + ) ] # Partial # Constructs a type with all properties of T set to optional (T | None). type Partial[T] = typing.NewProtocol[ - *[ + *typing.Map( typing.Member[p.name, p.type | None, p.quals] for p in typing.Iter[typing.Attrs[T]] - ] + ) ] # PartialTD # Like Partial, but for TypedDicts: wraps all fields in NotRequired # rather than making them T | None. type PartialTD[T] = typing.NewTypedDict[ - *[ + *typing.Map( typing.Member[p.name, p.type, p.quals | Literal["NotRequired"]] for p in typing.Iter[typing.Attrs[T]] - ] + ) ] # End PEP section: Utility types diff --git a/tests/test_type_dir.py b/tests/test_type_dir.py index 894c5d2..7c7135d 100644 --- a/tests/test_type_dir.py +++ b/tests/test_type_dir.py @@ -13,6 +13,7 @@ InitField, IsAssignable, Iter, + Map, Member, Members, NewProtocol, @@ -86,37 +87,37 @@ class Final(Mine, Ordinary, Wrapper[float], AnotherBase[float], Last[int]): type AllOptional[T] = NewProtocol[ - *[Member[p.name, p.type | None, p.quals] for p in Iter[Attrs[T]]] + *Map(Member[p.name, p.type | None, p.quals] for p in Iter[Attrs[T]]) ] type OptionalFinal = AllOptional[Final] type Capitalize[T] = NewProtocol[ - *[Member[Uppercase[p.name], p.type, p.quals] for p in Iter[Attrs[T]]] + *Map(Member[Uppercase[p.name], p.type, p.quals] for p in Iter[Attrs[T]]) ] type Prims[T] = NewProtocol[ - *[p for p in Iter[Attrs[T]] if IsAssignable[p.type, int | str]] + *Map(p for p in Iter[Attrs[T]] if IsAssignable[p.type, int | str]) ] type NoLiterals1[T] = NewProtocol[ - *[ + *Map( Member[ p.name, Union[ - *[ + *Map( t for t in Iter[FromUnion[p.type]] # XXX: 'typing.Literal' is not *really* a type... # Maybe we can't do this, which maybe is fine. if not IsAssignable[t, Literal] - ] + ) ], p.quals, ] for p in Iter[Attrs[T]] - ] + ) ] @@ -136,23 +137,23 @@ class Final(Mine, Ordinary, Wrapper[float], AnotherBase[float], Last[int]): ) type NoLiterals2[T] = NewProtocol[ - *[ + *Map( Member[ p.name, Union[ - *[ + *Map( t for t in Iter[FromUnion[p.type]] # XXX: 'typing.Literal' is not *really* a type... # Maybe we can't do this, which maybe is fine. # if not IsAssignabletype[t, Literal] if not IsAssignable[IsLiteral[t], Literal[True]] - ] + ) ], p.quals, ] for p in Iter[Attrs[T]] - ] + ) ] diff --git a/tests/test_type_eval.py b/tests/test_type_eval.py index 136ba6b..f47e70d 100644 --- a/tests/test_type_eval.py +++ b/tests/test_type_eval.py @@ -42,6 +42,7 @@ Iter, Length, IsEquivalent, + Map, Member, Members, NewProtocol, @@ -72,19 +73,19 @@ class F_int(F[int]): type ConcatTuples[A, B] = tuple[ - *[x for x in Iter[A]], - *[x for x in Iter[B]], + *Map(x for x in Iter[A]), + *Map(x for x in Iter[B]), ] type MapRecursive[A] = NewProtocol[ - *[ + *Map( ( Member[p.name, OrGotcha[p.type]] if not IsAssignable[p.type, A] else Member[p.name, OrGotcha[MapRecursive[A]]] ) for p in Iter[tuple[*Attrs[A], *Attrs[F_int]]] - ], + ), Member[Literal["control"], float], ] @@ -658,13 +659,13 @@ class A: def f[T]( self: T, ) -> tuple[ - *[ + *Map( m.type for m in Iter[Attrs[T]] if not IsAssignable[ Slice[m.name, None, Literal[1]], Literal["_"] ] - ] + ) ]: ... class B(A): @@ -698,13 +699,13 @@ class A: def f[T]( cls: type[T], ) -> tuple[ - *[ + *Map( m.type for m in Iter[Attrs[T]] if not IsAssignable[ Slice[m.name, None, Literal[1]], Literal["_"] ] - ] + ) ]: ... class B(A): @@ -855,10 +856,10 @@ def test_eval_getarg_callable_02(): eval_typing(GetArg[gc, GenericCallable, Literal[1]]) -type IndirectProtocol[T] = NewProtocol[*[m for m in Iter[Members[T]]],] +type IndirectProtocol[T] = NewProtocol[*Map(m for m in Iter[Members[T]]),] type GetMethodLike[T, Name] = GetArg[ tuple[ - *[ + *Map( p.type for p in Iter[Members[T]] if ( @@ -868,7 +869,7 @@ def test_eval_getarg_callable_02(): or IsAssignable[p.type, GenericCallable] ) and IsAssignable[Name, p.name] - ], + ), ], tuple, Literal[0], @@ -1529,7 +1530,9 @@ def test_eval_iter_01(): assert tuple(d) == () -type DuplicateTuple[T] = tuple[*[x for x in Iter[T]], *[x for x in Iter[T]]] +type DuplicateTuple[T] = tuple[ + *Map(x for x in Iter[T]), *Map(x for x in Iter[T]) +] type ConcatTupleWithSelf[T] = ConcatTuples[T, T] @@ -1993,11 +1996,11 @@ def g(self) -> int: ... # kept type MembersExceptInitSubclass[T] = tuple[ - *[ + *Map( m for m in Iter[Members[T]] if not IsAssignable[m.name, Literal["__init_subclass__"]] - ] + ) ] @@ -2080,7 +2083,7 @@ def g(self) -> int: ... # kept type AttrsAsSets[T] = UpdateClass[ - *[Member[m.name, set[m.type]] for m in Iter[Attrs[T]]] + *Map(Member[m.name, set[m.type]] for m in Iter[Attrs[T]]) ] @@ -2511,7 +2514,7 @@ class B(A): def __init_subclass__[T]( cls: type[T], ) -> UpdateClass[ - *[Member[m.name, list[m.type]] for m in Iter[Attrs[T]]] + *Map(Member[m.name, list[m.type]] for m in Iter[Attrs[T]]) ]: super().__init_subclass__() @@ -2521,7 +2524,7 @@ class C: def __init_subclass__[T]( cls: type[T], ) -> UpdateClass[ - *[Member[m.name, tuple[m.type]] for m in Iter[Attrs[T]]] + *Map(Member[m.name, tuple[m.type]] for m in Iter[Attrs[T]]) ]: super().__init_subclass__() diff --git a/tests/test_ziplike.py b/tests/test_ziplike.py index c01e288..0109b58 100644 --- a/tests/test_ziplike.py +++ b/tests/test_ziplike.py @@ -20,7 +20,7 @@ def zip[*Ts]( *args: *Ts, strict: bool = False -) -> Iterator[tuple[*[ElemOf[t] for t in typing.Iter[tuple[*Ts]]]]]: +) -> Iterator[tuple[*typing.Map(ElemOf[t] for t in typing.Iter[tuple[*Ts]])]]: return builtins.zip(*args, strict=strict) # type: ignore[call-overload] @@ -74,10 +74,10 @@ def zip_pairs[*Ts, *Us]( # single Literal iff they agree. type First[T] = typing.GetArg[T, tuple, Literal[0]] -type DropLastEach[T] = tuple[*[DropLast[t] for t in typing.Iter[T]]] -type LastEach[T] = tuple[*[Last[t] for t in typing.Iter[T]]] +type DropLastEach[T] = tuple[*typing.Map(DropLast[t] for t in typing.Iter[T])] +type LastEach[T] = tuple[*typing.Map(Last[t] for t in typing.Iter[T])] type AllSameLength[T] = typing.IsEquivalent[ - Union[*[typing.Length[t] for t in typing.Iter[T]]], + Union[*typing.Map(typing.Length[t] for t in typing.Iter[T])], typing.Length[First[T]], ] diff --git a/typemap/type_eval/_apply_generic.py b/typemap/type_eval/_apply_generic.py index fc43df1..00c5f9c 100644 --- a/typemap/type_eval/_apply_generic.py +++ b/typemap/type_eval/_apply_generic.py @@ -424,11 +424,12 @@ def flatten_class_new_proto(cls: type) -> type: # It works except for methods, since NewProtocol doesn't understand those. from typemap.typing import ( Iter, + Map, Members, NewProtocol, ) - type ClsAlias = NewProtocol[*[m for m in Iter[Members[cls]]]] # type: ignore[valid-type] + type ClsAlias = NewProtocol[*Map(m for m in Iter[Members[cls]])] # type: ignore[valid-type, type-arg] nt = _eval_typing.eval_typing(ClsAlias) args = typing.get_args(cls) diff --git a/typemap/typing.py b/typemap/typing.py index b258951..7bfc0aa 100644 --- a/typemap/typing.py +++ b/typemap/typing.py @@ -327,6 +327,11 @@ class RaiseError[S: str, *Ts]: pass +class Map: + def __new__(cls, gen): + return tuple(gen) + + ################################################################## # TODO: type better diff --git a/uv.lock b/uv.lock index d13dd20..9995275 100644 --- a/uv.lock +++ b/uv.lock @@ -56,8 +56,8 @@ wheels = [ [[package]] name = "mypy" -version = "1.20.0+dev.fbc5d6c16834379307857318e6c32326b5d8a201" -source = { git = "https://github.com/msullivan/mypy-typemap?rev=fbc5d6c16834379307857318e6c32326b5d8a201#fbc5d6c16834379307857318e6c32326b5d8a201" } +version = "1.20.0+dev.5250279d38109fedafff709488939c38901783bd" +source = { git = "https://github.com/msullivan/mypy-typemap?rev=5250279d38109fedafff709488939c38901783bd#5250279d38109fedafff709488939c38901783bd" } dependencies = [ { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, { name = "mypy-extensions" }, @@ -168,7 +168,7 @@ test = [ [package.metadata.requires-dev] test = [ - { name = "mypy", git = "https://github.com/msullivan/mypy-typemap?rev=fbc5d6c16834379307857318e6c32326b5d8a201" }, + { name = "mypy", git = "https://github.com/msullivan/mypy-typemap?rev=5250279d38109fedafff709488939c38901783bd" }, { name = "pytest", specifier = ">=7.0" }, { name = "ruff" }, ] From 2ad7211cdd5fdf149ec89ab383d35193e9cedb18 Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Thu, 16 Apr 2026 21:17:07 -0700 Subject: [PATCH 2/4] Collapse tuple[*Map(... for _ in Iter[Any])] to Any Iter[Any] now returns a generator that raises IterAnyError on first __next__ (not __iter__, which CPython calls eagerly when constructing a genexpr -- before Map can wrap it). Map catches IterAnyError via `yield from` and yields a _UnpackAny sentinel, which callers of _eval_args short-circuit into typing.Any. --- tests/test_type_eval.py | 22 ++++++++++++++++++++++ typemap/type_eval/_eval_operators.py | 15 +++++++++++++++ typemap/type_eval/_eval_typing.py | 10 +++++++++- typemap/typing.py | 27 +++++++++++++++++++++++++-- 4 files changed, 71 insertions(+), 3 deletions(-) diff --git a/tests/test_type_eval.py b/tests/test_type_eval.py index f47e70d..bfc29a4 100644 --- a/tests/test_type_eval.py +++ b/tests/test_type_eval.py @@ -1548,6 +1548,28 @@ def test_eval_iter_02(): assert d == tuple[int, str, int, str] +type TupleFromIter[T] = tuple[*Map(x for x in Iter[T])] +type TupleAroundIter[T] = tuple[int, *Map(x for x in Iter[T]), str] +type ProtoFromIter[T] = NewProtocol[*Map(Member[m.name, int] for m in Iter[T])] + + +def test_eval_iter_any_01(): + # tuple[*Map(... Iter[Any])] collapses to Any + assert eval_typing(TupleFromIter[Any]) is Any + assert eval_typing(TupleFromIter[tuple[int, str]]) == tuple[int, str] + + +def test_eval_iter_any_02(): + # _UnpackAny propagates out of mixed positional args + assert eval_typing(TupleAroundIter[Any]) is Any + assert eval_typing(TupleAroundIter[tuple[float]]) == tuple[int, float, str] + + +def test_eval_iter_any_03(): + # Works through NewProtocol when Map's body references m attributes + assert eval_typing(ProtoFromIter[Any]) is Any + + type NotLiteralGeneric[T] = not T type AndLiteralGeneric[L, R] = L and R type OrLiteralGeneric[L, R] = L or R diff --git a/typemap/type_eval/_eval_operators.py b/typemap/type_eval/_eval_operators.py index cb9a799..46ad511 100644 --- a/typemap/type_eval/_eval_operators.py +++ b/typemap/type_eval/_eval_operators.py @@ -36,6 +36,7 @@ IsAssignable, IsEquivalent, Iter, + IterAnyError, Length, Lowercase, Member, @@ -434,10 +435,24 @@ def wrapper(*args, ctx): ################################################################## +def _iter_any_raiser(): + """Iterator whose first __next__ raises IterAnyError. + + Raising from __next__ (rather than from Iter's __iter__) lets Map's + __iter__ catch the exception: CPython evaluates the outermost + iterable of a generator expression eagerly at construction time, so + raising from __iter__ would escape before Map ever sees it. + """ + raise IterAnyError("Iter[Any] cannot be iterated") + yield # unreachable; makes this a generator + + @type_eval.register_evaluator(Iter) @_lift_evaluated def _eval_Iter(tp, *, ctx): tp = _eval_types(tp, ctx) + if tp is typing.Any: + return _iter_any_raiser() if ( _typing_inspect.is_generic_alias(tp) and tp.__origin__ is tuple diff --git a/typemap/type_eval/_eval_typing.py b/typemap/type_eval/_eval_typing.py index 9830bf6..5e5b795 100644 --- a/typemap/type_eval/_eval_typing.py +++ b/typemap/type_eval/_eval_typing.py @@ -15,7 +15,7 @@ _UnpackGenericAlias as typing_UnpackGenericAlias, ) -from typemap.typing import _AssociatedTypeGenericAlias +from typemap.typing import _AssociatedTypeGenericAlias, _UnpackAny if typing.TYPE_CHECKING: @@ -347,6 +347,10 @@ def _eval_args(args: Sequence[Any], ctx: EvalContext) -> tuple[Any]: return tuple(evaled) +def _has_unpack_any(args: typing.Iterable[Any]) -> bool: + return any(a is _UnpackAny for a in args) + + @_eval_types_impl.register def _eval_applied_type_alias(obj: types.GenericAlias, ctx: EvalContext): """Eval a types.GenericAlias -- typically an applied type alias @@ -365,6 +369,8 @@ def _eval_applied_type_alias(obj: types.GenericAlias, ctx: EvalContext): return typing.Unpack[_eval_types(stripped, ctx)] new_args = _eval_args(obj.__args__, ctx) + if _has_unpack_any(new_args): + return typing.Any new_obj = _apply_type(obj.__origin__, new_args) if isinstance(obj.__origin__, type): @@ -428,6 +434,8 @@ def _eval_applied_class(obj: typing_GenericAlias, ctx: EvalContext): # generic *classes* are typing._GenericAlias while generic type # aliases are types.GenericAlias? Why in the world. new_args = _eval_args(typing.get_args(obj), ctx) + if _has_unpack_any(new_args): + return typing.Any if func := _eval_funcs.get(obj.__origin__): _tvars = ( diff --git a/typemap/typing.py b/typemap/typing.py index 7bfc0aa..495c833 100644 --- a/typemap/typing.py +++ b/typemap/typing.py @@ -327,9 +327,32 @@ class RaiseError[S: str, *Ts]: pass +class IterAnyError(TypeError): + """Raised by Iter[Any]; caught by Map.__iter__ to yield _UnpackAny.""" + + pass + + +class _UnpackAnyMarker: + def __repr__(self): + return "_UnpackAny" + + +# Sentinel yielded by Map when its generator iterates Iter[Any]. +# When seen as an argument to a type operator during evaluation, the +# surrounding type is collapsed to typing.Any. +_UnpackAny = _UnpackAnyMarker() + + class Map: - def __new__(cls, gen): - return tuple(gen) + def __init__(self, gen): + self._gen = gen + + def __iter__(self): + try: + yield from self._gen + except IterAnyError: + yield _UnpackAny ################################################################## From d99f35644fa6d45647a576dafac33a98c50e915e Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Thu, 16 Apr 2026 21:29:35 -0700 Subject: [PATCH 3/4] Defer Map iteration from __iter__ to the evaluator Map.__iter__ now just yields an (_UnpackedMap, _UnpackedMapEnd) pair; the evaluator drives the wrapped generator inside _eval_args, catches IterAnyError there, and emits _UnpackAny. The trailing sentinel keeps Union[*Map(...)] from collapsing to a single arg before _eval_union can see it. IterAnyError is now a TypeMapError subclass, living entirely inside the evaluator. _eval_args iterates the generator one value at a time so that nested genexprs closing over the outer iteration variable don't all see its final value. _eval_union now routes through _eval_args too. --- typemap/type_eval/_eval_operators.py | 7 ++++- typemap/type_eval/_eval_typing.py | 42 ++++++++++++++++++++++--- typemap/typing.py | 47 ++++++++++++++++++++-------- 3 files changed, 78 insertions(+), 18 deletions(-) diff --git a/typemap/type_eval/_eval_operators.py b/typemap/type_eval/_eval_operators.py index 46ad511..1d9b4d0 100644 --- a/typemap/type_eval/_eval_operators.py +++ b/typemap/type_eval/_eval_operators.py @@ -36,7 +36,6 @@ IsAssignable, IsEquivalent, Iter, - IterAnyError, Length, Lowercase, Member, @@ -1227,6 +1226,12 @@ class TypeMapError(TypeError): pass +class IterAnyError(TypeMapError): + """Raised when Iter[Any] is iterated; caught inside _eval_args.""" + + pass + + @type_eval.register_evaluator(RaiseError) @_lift_evaluated def _eval_RaiseError(msg, *extra_types, ctx): diff --git a/typemap/type_eval/_eval_typing.py b/typemap/type_eval/_eval_typing.py index 5e5b795..6f2375c 100644 --- a/typemap/type_eval/_eval_typing.py +++ b/typemap/type_eval/_eval_typing.py @@ -15,7 +15,12 @@ _UnpackGenericAlias as typing_UnpackGenericAlias, ) -from typemap.typing import _AssociatedTypeGenericAlias, _UnpackAny +from typemap.typing import ( + _AssociatedTypeGenericAlias, + _UnpackAny, + _UnpackedMap, + _UnpackedMapEnd, +) if typing.TYPE_CHECKING: @@ -333,9 +338,37 @@ def _eval_type_alias(obj: typing.TypeAliasType, ctx: EvalContext): return _eval_types(unpacked, ctx) -def _eval_args(args: Sequence[Any], ctx: EvalContext) -> tuple[Any]: +def _eval_args(args: Sequence[Any], ctx: EvalContext) -> tuple[Any, ...]: + from typemap.type_eval._eval_operators import IterAnyError + evaled = [] for arg in args: + if arg is _UnpackedMapEnd: + continue + if isinstance(arg, _UnpackedMap): + # Drive the Map's generator one value at a time, evaluating + # each before pulling the next. Collecting all values up + # front would advance the generator's iteration variable to + # its final value before nested genexprs (which close over + # that variable) get a chance to run. + gen = arg.map._gen + while True: + try: + v = next(gen) + except StopIteration: + break + except IterAnyError: + evaled.append(_UnpackAny) + break + ev = _eval_types(v, ctx) + if isinstance(ev, typing_UnpackGenericAlias): + if (sub := ev.__typing_unpacked_tuple_args__) is not None: + evaled.extend(sub) + else: + evaled.append(ev) + else: + evaled.append(ev) + continue ev = _eval_types(arg, ctx) if isinstance(ev, typing_UnpackGenericAlias): if (args := ev.__typing_unpacked_tuple_args__) is not None: @@ -469,6 +502,7 @@ def _eval_ty_or_list(obj): @_eval_types_impl.register def _eval_union(obj: typing.Union, ctx: EvalContext): - args: typing.Sequence[typing.Any] = obj.__args__ - new_args = tuple(_eval_types(arg, ctx) for arg in args) + new_args = _eval_args(obj.__args__, ctx) + if _has_unpack_any(new_args): + return typing.Any return typing.Union[new_args] diff --git a/typemap/typing.py b/typemap/typing.py index 495c833..6e624da 100644 --- a/typemap/typing.py +++ b/typemap/typing.py @@ -327,32 +327,53 @@ class RaiseError[S: str, *Ts]: pass -class IterAnyError(TypeError): - """Raised by Iter[Any]; caught by Map.__iter__ to yield _UnpackAny.""" - - pass - - class _UnpackAnyMarker: def __repr__(self): return "_UnpackAny" -# Sentinel yielded by Map when its generator iterates Iter[Any]. -# When seen as an argument to a type operator during evaluation, the -# surrounding type is collapsed to typing.Any. +# Sentinel emitted by the evaluator when a Map's genexpr raises +# IterAnyError (e.g. for Iter[Any]). When seen as an argument to a type +# operator during evaluation, the surrounding type is collapsed to Any. _UnpackAny = _UnpackAnyMarker() +class _UnpackedMap: + """Wrapper yielded by Map.__iter__ so the evaluator drives iteration.""" + + __slots__ = ('map',) + + def __init__(self, map_inst): + self.map = map_inst + + def __repr__(self): + return f"_UnpackedMap({self.map!r})" + + +class _UnpackedMapEndMarker: + __slots__ = () + + def __repr__(self): + return "_UnpackedMapEnd" + + +# Paired with _UnpackedMap in Map.__iter__'s output so that +# `Union[*Map(...)]` -- which Python would otherwise collapse +# (Union[X] == X) -- keeps at least two args and reaches _eval_union +# intact. Stripped by _eval_args. +_UnpackedMapEnd = _UnpackedMapEndMarker() + + class Map: def __init__(self, gen): self._gen = gen def __iter__(self): - try: - yield from self._gen - except IterAnyError: - yield _UnpackAny + # Iteration of the underlying generator is deferred to the + # evaluator so that Iter[Any] (and any other type-level errors + # raised from the body) can be handled in one place. + yield _UnpackedMap(self) + yield _UnpackedMapEnd ################################################################## From c2975abc6c71258db42c24043ac6cde112fa6fb8 Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Thu, 16 Apr 2026 21:40:56 -0700 Subject: [PATCH 4/4] Extract _eval_and_append helper in _eval_args Both the _UnpackedMap branch and the plain-arg branch did the same eval-and-unpack dance; pull it out. --- typemap/type_eval/_eval_typing.py | 53 +++++++++++++------------------ 1 file changed, 22 insertions(+), 31 deletions(-) diff --git a/typemap/type_eval/_eval_typing.py b/typemap/type_eval/_eval_typing.py index 6f2375c..8565152 100644 --- a/typemap/type_eval/_eval_typing.py +++ b/typemap/type_eval/_eval_typing.py @@ -338,45 +338,36 @@ def _eval_type_alias(obj: typing.TypeAliasType, ctx: EvalContext): return _eval_types(unpacked, ctx) +def _eval_and_append(evaled: list, arg: Any, ctx: EvalContext) -> None: + ev = _eval_types(arg, ctx) + if isinstance(ev, typing_UnpackGenericAlias): + if (sub := ev.__typing_unpacked_tuple_args__) is not None: + evaled.extend(sub) + else: + evaled.append(ev) + else: + evaled.append(ev) + + def _eval_args(args: Sequence[Any], ctx: EvalContext) -> tuple[Any, ...]: from typemap.type_eval._eval_operators import IterAnyError - evaled = [] + evaled: list[Any] = [] for arg in args: if arg is _UnpackedMapEnd: continue if isinstance(arg, _UnpackedMap): - # Drive the Map's generator one value at a time, evaluating - # each before pulling the next. Collecting all values up - # front would advance the generator's iteration variable to - # its final value before nested genexprs (which close over - # that variable) get a chance to run. - gen = arg.map._gen - while True: - try: - v = next(gen) - except StopIteration: - break - except IterAnyError: - evaled.append(_UnpackAny) - break - ev = _eval_types(v, ctx) - if isinstance(ev, typing_UnpackGenericAlias): - if (sub := ev.__typing_unpacked_tuple_args__) is not None: - evaled.extend(sub) - else: - evaled.append(ev) - else: - evaled.append(ev) + # We need to evaluate the inner arguments as we iterate, + # rather than converting to a list first, since there + # might be inner iterators that depend on the outer + # variables. + try: + for v in arg.map._gen: + _eval_and_append(evaled, v, ctx) + except IterAnyError: + evaled.append(_UnpackAny) continue - ev = _eval_types(arg, ctx) - if isinstance(ev, typing_UnpackGenericAlias): - if (args := ev.__typing_unpacked_tuple_args__) is not None: - evaled.extend(args) - else: - evaled.append(ev) - else: - evaled.append(ev) + _eval_and_append(evaled, arg, ctx) return tuple(evaled)