From 6ae84edc8fe8223b445df01b1b593d943e3a429f Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Thu, 8 Jan 2026 18:32:03 -0800 Subject: [PATCH 1/5] Notes about what to do --- tests/test_type_dir.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/test_type_dir.py b/tests/test_type_dir.py index 30c345d..f471d86 100644 --- a/tests/test_type_dir.py +++ b/tests/test_type_dir.py @@ -306,6 +306,15 @@ class Funny2: """) +def test_type_dir_9(): + d = eval_typing(Last[bool]) + + assert format_helper.format_class(d) == textwrap.dedent("""\ + class Last[bool]: + last: bool | typing.Literal[True] + """) + + def _get_member(members, name): return next( iter(m for m in members.__args__ if m.__args__[0].__args__[0] == name) From 3248edb892882e705a6de383c6fd529deab2e6ba Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Fri, 9 Jan 2026 13:13:15 -0800 Subject: [PATCH 2/5] WIP: Stop using apply Still use boxing. Lots of tests broken! --- tests/test_type_dir.py | 36 ++++++++--- typemap/type_eval/_apply_generic.py | 11 ++-- typemap/type_eval/_eval_operators.py | 97 +++++++++++++++------------- typemap/type_eval/_eval_typing.py | 30 +++++---- typemap/type_eval/_typing_inspect.py | 13 ++++ 5 files changed, 116 insertions(+), 71 deletions(-) diff --git a/tests/test_type_dir.py b/tests/test_type_dir.py index f471d86..b0052da 100644 --- a/tests/test_type_dir.py +++ b/tests/test_type_dir.py @@ -280,11 +280,6 @@ class NoLiterals2[tests.test_type_dir.Final]: """) -def test_type_dir_7(): - d = eval_typing(BaseArg[Final]) - assert d is int - - class Simple[T]: simple: T @@ -297,7 +292,7 @@ class Funny2(Funny[int]): pass -def test_type_dir_8(): +def test_type_dir_7(): d = eval_typing(Funny2) assert format_helper.format_class(d) == textwrap.dedent("""\ @@ -315,13 +310,18 @@ class Last[bool]: """) +def test_type_dir_get_arg_1(): + d = eval_typing(BaseArg[Final]) + assert d is int + + def _get_member(members, name): return next( iter(m for m in members.__args__ if m.__args__[0].__args__[0] == name) ) -def test_type_members_attr_(): +def test_type_members_attr_1(): d = eval_typing(Members[Final]) member = _get_member(d, "ordinary") assert typing.get_origin(member) is Member @@ -329,7 +329,25 @@ def test_type_members_attr_(): assert origin.__name__ == "Ordinary" -def test_type_members_func_1a(): +def test_type_members_attr_2(): + d = eval_typing(Members[Final]) + member = _get_member(d, "last") + assert typing.get_origin(member) is Member + _, typ, _, origin = typing.get_args(member) + assert typ == int | Literal[True] + assert str(origin) == "tests.test_type_dir.Last[int]" + + +def test_type_members_attr_3(): + d = eval_typing(Members[Last[int]]) + member = _get_member(d, "last") + assert typing.get_origin(member) is Member + _, typ, _, origin = typing.get_args(member) + assert typ == int | Literal[True] + assert str(origin) == "tests.test_type_dir.Last[int]" + + +def test_type_members_func_1(): d = eval_typing(Members[Final]) member = _get_member(d, "foo") assert typing.get_origin(member) is Member @@ -348,7 +366,7 @@ def test_type_members_func_1a(): dict[str, int]]" ) - assert origin.__name__ == "Base[int]" + assert str(origin) == "tests.test_type_dir.Base[int]" def test_type_members_func_2(): diff --git a/typemap/type_eval/_apply_generic.py b/typemap/type_eval/_apply_generic.py index c9318bc..1b12d3b 100644 --- a/typemap/type_eval/_apply_generic.py +++ b/typemap/type_eval/_apply_generic.py @@ -75,6 +75,7 @@ def substitute(ty, args): def box(cls: type[Any]) -> Boxed: + # TODO: We want a cache for this!! def _box(cls: type[Any], args: dict[Any, Any]) -> Boxed: boxed_bases: list[Boxed] = [] @@ -106,9 +107,6 @@ def _box(cls: type[Any], args: dict[Any, Any]) -> Boxed: return Boxed(cls, boxed_bases, args) if isinstance(cls, (typing._GenericAlias, types.GenericAlias)): # type: ignore[attr-defined] - # XXX this feels out of place, `box()` needs to only accept types. - # this never gets activated now, but I want to basically - # support this later -sully args = dict( zip(cls.__origin__.__parameters__, cls.__args__, strict=True) ) @@ -194,11 +192,10 @@ def _get_closure_types(af: types.FunctionType) -> dict[str, type]: for name, variable in zip( af.__code__.co_freevars, af.__closure__, strict=True ) - if isinstance(variable.cell_contents, type) } -def _get_local_defns(boxed: Boxed) -> tuple[dict[str, Any], dict[str, Any]]: +def get_local_defns(boxed: Boxed) -> tuple[dict[str, Any], dict[str, Any]]: annos: dict[str, Any] = {} dct: dict[str, Any] = {} @@ -237,6 +234,7 @@ def _get_local_defns(boxed: Boxed) -> tuple[dict[str, Any], dict[str, Any]]: else: annos[k] = v elif af := getattr(boxed.cls, "__annotations__", None): + # TODO: substitute vars in this case annos.update(af) for name, orig in boxed.cls.__dict__.items(): @@ -293,6 +291,7 @@ def _type_repr(t: Any) -> str: return repr(t) +# XXX: This is all dead now??? def apply( cls: type[Any], ctx: _eval_typing.EvalContext ) -> type[_eval_typing._EvalProxy]: @@ -336,7 +335,7 @@ def apply( dct: dict[str, Any] = {} sources: dict[str, Any] = {} - cboxed.__local_annotations__, cboxed.__local_defns__ = _get_local_defns( + cboxed.__local_annotations__, cboxed.__local_defns__ = get_local_defns( boxed ) for base in reversed(boxed.mro): diff --git a/typemap/type_eval/_eval_operators.py b/typemap/type_eval/_eval_operators.py index 65ba807..639959e 100644 --- a/typemap/type_eval/_eval_operators.py +++ b/typemap/type_eval/_eval_operators.py @@ -9,6 +9,7 @@ import typing from typemap import type_eval +from typemap.type_eval import _apply_generic from typemap.type_eval import _typing_inspect from typemap.type_eval._eval_typing import _eval_types from typemap.typing import ( @@ -34,6 +35,7 @@ Uppercase, ) + ################################################################## @@ -49,46 +51,47 @@ def get_annotated_type_hints(cls, **kwargs): This traverses the mro and finds the definition site for each annotation. """ - ohints = typing.get_type_hints(cls, **kwargs) + + # TODO: Cache the box (slash don't need it??) + box = _apply_generic.box(cls) + hints = {} - for acls in cls.__mro__: - if not hasattr(acls, "__annotations__"): - continue - # XXX: This is super janky; we should just use the real mro - # and not flatten things - sources = getattr(acls, "__defn_sources__", {}) - for k in acls.__annotations__: - if k not in hints: - quals = set() - ty = ohints[k] - - # Strip ClassVar/Final from ty and add them to quals - while True: - for form in [typing.ClassVar, typing.Final]: - if _typing_inspect.is_special_form(ty, form): - quals.add(form.__name__) - ty = ( - typing.get_args(ty)[0] - if typing.get_args(ty) - else typing.Any - ) - break - else: + for abox in reversed(box.mro): + acls = abox.alias_type() + + annos, _ = _apply_generic.get_local_defns(abox) + for k, ty in annos.items(): + quals = set() + + # Strip ClassVar/Final from ty and add them to quals + while True: + for form in [typing.ClassVar, typing.Final]: + if _typing_inspect.is_special_form(ty, form): + quals.add(form.__name__) + ty = ( + typing.get_args(ty)[0] + if typing.get_args(ty) + else typing.Any + ) break + else: + break - hints[k] = ty, tuple(sorted(quals)), sources.get(k, acls) + hints[k] = ty, tuple(sorted(quals)), acls - # Stop early if we are done. - if len(hints) == len(ohints): - break return hints -def get_annotated_method_hints(tp): +def get_annotated_method_hints(cls): + # TODO: Cache the box (slash don't need it??) + box = _apply_generic.box(cls) + hints = {} - for ptp in reversed(tp.mro()): - sources = getattr(ptp, "__defn_sources__", {}) - for name, attr in ptp.__dict__.items(): + for abox in reversed(box.mro): + acls = abox.alias_type() + + _, dct = _apply_generic.get_local_defns(abox) + for name, attr in dct.items(): if isinstance( attr, ( @@ -101,11 +104,10 @@ def get_annotated_method_hints(tp): if attr is typing._no_init_or_replace_init: continue - rtp = sources.get(name, ptp) hints[name] = ( - _function_type(attr, receiver_type=rtp), + _function_type(attr, receiver_type=acls), ("ClassVar",), - rtp, + acls, ) return hints @@ -250,7 +252,12 @@ def _eval_Attrs(tp, *, ctx): return tuple[ *[ - Member[typing.Literal[n], t, _mk_literal_union(*qs), d] + Member[ + typing.Literal[n], + _eval_types(t, ctx), + _mk_literal_union(*qs), + d, + ] for n, (t, qs, d) in hints.items() ] ] @@ -265,7 +272,9 @@ def _eval_Members(tp, *, ctx): } attrs = [ - Member[typing.Literal[n], t, _mk_literal_union(*qs), d] + Member[ + typing.Literal[n], _eval_types(t, ctx), _mk_literal_union(*qs), d + ] for n, (t, qs, d) in hints.items() ] @@ -306,15 +315,13 @@ def _get_raw_args(tp, base_head, ctx) -> typing.Any: return typing.get_args(evaled) # Scan the fully-annotated MRO to find the base - elif gen_mro := getattr(evaled, "__generalized_mro__", None): - for anc in gen_mro: - if _typing_inspect.get_head(anc) is base_head: - return anc.__local_args__ - return None + box = _apply_generic.box(tp) + for anc in box.mro: + if anc.cls is base_head: + return tuple(anc.args.values()) - else: - # or error?? - return None + # or error?? + return None def _get_args(tp, base, ctx) -> typing.Any: diff --git a/typemap/type_eval/_eval_typing.py b/typemap/type_eval/_eval_typing.py index a8a1c50..4ef21f7 100644 --- a/typemap/type_eval/_eval_typing.py +++ b/typemap/type_eval/_eval_typing.py @@ -11,6 +11,7 @@ import typing from typing import _GenericAlias as typing_GenericAlias # type: ignore [attr-defined] # noqa: PLC2701 +from typing import _CallableGenericAlias as typing_CallableGenericAlias # type: ignore [attr-defined] # noqa: PLC2701 if typing.TYPE_CHECKING: @@ -259,14 +260,6 @@ def _eval_func( @_eval_types_impl.register def _eval_type_type(obj: type, ctx: EvalContext): - if isinstance(obj, type) and issubclass(obj, typing.Generic): - try: - return _apply_generic.apply(obj, ctx) - except Exception: - # XXX: should apply handle this? - ctx.seen.pop(obj, None) - raise - return obj @@ -341,14 +334,29 @@ def _eval_applied_class(obj: typing_GenericAlias, ctx: EvalContext): """Eval a typing._GenericAlias -- an applied user-defined class""" # generic *classes* are typing._GenericAlias while generic type # aliases are types.GenericAlias? Why in the world. + new_args = tuple(_eval_types(arg, ctx) for arg in typing.get_args(obj)) + if func := _eval_funcs.get(obj.__origin__): - new_args = tuple(_eval_types(arg, ctx) for arg in obj.__args__) ret = func(*new_args, ctx=ctx) # return _eval_types(ret, ctx) # ??? return ret + else: + return obj.__origin__[new_args] # type: ignore[index] - # TODO: Actually evaluate in this case! - return obj + +@_eval_types_impl.register +def _eval_callable(obj: typing_CallableGenericAlias, ctx: EvalContext): + """Eval a typing._CallableGenericAlias""" + + def _eval_ty_or_list(obj): + if isinstance(obj, list): + return [_eval_types(t, ctx) for t in obj] + else: + return _eval_types(obj, ctx) + + new_args = tuple(_eval_ty_or_list(arg) for arg in typing.get_args(obj)) + # origin for Callable is collections.abc.Callable which kind of annoying + return typing.Callable[*new_args] @_eval_types_impl.register diff --git a/typemap/type_eval/_typing_inspect.py b/typemap/type_eval/_typing_inspect.py index f6195ae..1904962 100644 --- a/typemap/type_eval/_typing_inspect.py +++ b/typemap/type_eval/_typing_inspect.py @@ -3,6 +3,7 @@ # SPDX-FileCopyrightText: Copyright Gel Data Inc. and the contributors. +import annotationlib import typing from types import GenericAlias, UnionType from typing import ( # type: ignore [attr-defined] # noqa: PLC2701 @@ -153,7 +154,19 @@ def param_default(p) -> Any: return Any if p.__default__ == typing.NoDefault else p.__default__ +def get_local_type_hints(obj, **kwargs) -> dict[str, Any]: + """Return type hints for an object, excluding inherited annotations. + + This works by calling typing.get_type_hints() and then filtering out + any keys that don't also appear in annotationlib.get_annotations(). + """ + hints = typing.get_type_hints(obj, **kwargs) + local_annotations = annotationlib.get_annotations(obj) + return {k: v for k, v in hints.items() if k in local_annotations} + + __all__ = ( + "get_local_type_hints", "is_annotated", "is_forward_ref", "is_generic_alias", From 10833b5c4a730d372bd65a7553052f4a5fc54cde Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Fri, 9 Jan 2026 13:53:23 -0800 Subject: [PATCH 3/5] Implement a flatten_class that uses NewProtocol --- tests/format_helper.py | 16 +++++++++++----- tests/test_call.py | 2 -- tests/test_qblike.py | 4 +--- tests/test_type_dir.py | 6 +++--- tests/test_type_eval.py | 21 ++++++++++++++++++++- typemap/type_eval/__init__.py | 2 ++ typemap/type_eval/_apply_generic.py | 20 ++++++++++++++++++++ typemap/type_eval/_eval_typing.py | 4 +++- 8 files changed, 60 insertions(+), 15 deletions(-) diff --git a/tests/format_helper.py b/tests/format_helper.py index 95efb63..a637e59 100644 --- a/tests/format_helper.py +++ b/tests/format_helper.py @@ -4,8 +4,8 @@ import typing -def format_class(cls: type) -> str: - def format_meth(meth): +def format_class_basic(cls: type) -> str: + def format_meth(name, meth): root = inspect.unwrap(meth) sig = inspect.signature(root) @@ -13,14 +13,14 @@ def format_meth(meth): if params := root.__type_params__: ts = "[" + ", ".join(str(p) for p in params) + "]" - return f"{root.__name__}{ts}{sig}" + return f"{name}{ts}{sig}" code = f"class {cls.__name__}:\n" for attr_name, attr_type in cls.__annotations__.items(): attr_type_s = annotationlib.type_repr(attr_type) code += f" {attr_name}: {attr_type_s}\n" - for attr in cls.__dict__.values(): + for name, attr in cls.__dict__.items(): if attr is typing._no_init_or_replace_init: continue if isinstance(attr, classmethod): @@ -32,5 +32,11 @@ def format_meth(meth): # Intentionally not elif; classmethod and staticmethod cases # fall through if isinstance(attr, (types.FunctionType, types.MethodType)): - code += f" def {format_meth(attr)}: ...\n" + code += f" def {format_meth(name, attr)}: ...\n" return code + + +def format_class(cls): + from typemap.type_eval import flatten_class + + return format_class_basic(flatten_class(cls)) diff --git a/tests/test_call.py b/tests/test_call.py index 0cdbe9c..e517bc5 100644 --- a/tests/test_call.py +++ b/tests/test_call.py @@ -44,11 +44,9 @@ def test_call_2(): ret = eval_call(func_trivial, a=1, b=2, c="aaa") fmt = format_helper.format_class(ret) - # XXX: can we get rid of the annotate?? assert fmt == textwrap.dedent("""\ class **kwargs: a: typing.Literal[1] b: typing.Literal[2] c: typing.Literal['aaa'] - def __annotate__(format): ... """) diff --git a/tests/test_qblike.py b/tests/test_qblike.py index 87752d1..ff7e65f 100644 --- a/tests/test_qblike.py +++ b/tests/test_qblike.py @@ -108,10 +108,8 @@ def test_qblike_3(): class select[...]: x: tests.test_qblike.Property[int] w: tests.test_qblike.Property[list[str]] - z: tests.test_qblike.Link[PropsOnly[typemap.typing.GetArg[\ -tests.test_qblike.Link[tests.test_qblike.Tgt], tests.test_qblike.Link, 0]]] + z: tests.test_qblike.Link[tests.test_qblike.PropsOnly[tests.test_qblike.Tgt]] """) - # z: tests.test_qblike.Link[PropsOnly[tests.test_qblike.Tgt]] res = eval_typing(GetAttr[ret, Literal["z"]]) tgt = res.__args__[0] diff --git a/tests/test_type_dir.py b/tests/test_type_dir.py index b0052da..6e6ce81 100644 --- a/tests/test_type_dir.py +++ b/tests/test_type_dir.py @@ -154,7 +154,7 @@ class Final(Mine, Ordinary, Wrapper[float], AnotherBase[float], Last[int]): ] -# Subtyping this forces real type evaluation +# Subtyping Eval used to do something class Eval[T]: pass @@ -175,14 +175,14 @@ def test_type_dir_link_1(): d = eval_typing(Loop) loop = d.__annotations__["loop"] assert loop is d - assert loop is not Foo + assert loop is Loop def test_type_dir_link_2(): d = eval_typing(Foo) loop = d.__annotations__["bar"].__annotations__["foo"] assert loop is d - assert loop is not Foo + assert loop is Foo def test_type_dir_1(): diff --git a/tests/test_type_eval.py b/tests/test_type_eval.py index c4f4b60..4d47382 100644 --- a/tests/test_type_eval.py +++ b/tests/test_type_eval.py @@ -91,7 +91,18 @@ def test_eval_types_2(): # # Validate that recursion worked properly and "Recursive" was only walked once # assert evaled.__annotations__["a"].__args__[0] is evaled - assert format_helper.format_class(evaled) == textwrap.dedent("""\ + # XXX: I don't have a good intuition about whether the inner MapRecursive ought to expand or not. + # + # Currently there are two test implementations for flatten_class + # and the canonical one does not expand it and the + # NewProtocol-based one does. + # + # I don't really think they ought to differ; something funny is + # going on with recursively alias handling. + res = format_helper.format_class(evaled) + res = res.replace('tests.test_type_eval.MapRecursive', 'MapRecursive') + + assert res == textwrap.dedent("""\ class MapRecursive[tests.test_type_eval.Recursive]: n: int | typing.Literal['gotcha!'] m: str | typing.Literal['gotcha!'] @@ -696,3 +707,11 @@ def test_eval_length_01(): d = eval_typing(Length[tuple[int, ...]]) assert d == Literal[None] + + +def test_eval_literal_idempotent_01(): + t = Literal[int] + for _ in range(5): + nt = eval_typing(t) + assert t == nt + t = nt diff --git a/typemap/type_eval/__init__.py b/typemap/type_eval/__init__.py index b28eabf..b63c49d 100644 --- a/typemap/type_eval/__init__.py +++ b/typemap/type_eval/__init__.py @@ -4,6 +4,7 @@ register_evaluator, _EvalProxy, ) +from ._apply_generic import flatten_class # XXX: this needs to go second due to nasty circularity -- try to fix that!! from ._eval_call import eval_call @@ -18,6 +19,7 @@ "eval_typing", "register_evaluator", "eval_call", + "flatten_class", "issubtype", "issubsimilar", "_EvalProxy", diff --git a/typemap/type_eval/_apply_generic.py b/typemap/type_eval/_apply_generic.py index 1b12d3b..73b37ae 100644 --- a/typemap/type_eval/_apply_generic.py +++ b/typemap/type_eval/_apply_generic.py @@ -281,6 +281,26 @@ def get_local_defns(boxed: Boxed) -> tuple[dict[str, Any], dict[str, Any]]: return annos, dct +def flatten_class(cls: type) -> type: + # This is a hacky version of flatten_class that works by using + # NewProtocol on Members! + # + # It works except for methods, since NewProtocol doesn't understand those. + from typemap.typing import ( + Iter, + Members, + NewProtocol, + ) + + type ClsAlias = NewProtocol[*[m for m in Iter[Members[cls]]]] # type: ignore[valid-type] + nt = _eval_typing.eval_typing(ClsAlias) + nt.__name__ = cls.__name__ + nt.__qualname__ = cls.__qualname__ + del nt.__subclasshook__ + + return nt + + def _type_repr(t: Any) -> str: if isinstance(t, type): if t.__module__ == "builtins": diff --git a/typemap/type_eval/_eval_typing.py b/typemap/type_eval/_eval_typing.py index 4ef21f7..dd72439 100644 --- a/typemap/type_eval/_eval_typing.py +++ b/typemap/type_eval/_eval_typing.py @@ -273,7 +273,9 @@ def _eval_type_alias(obj: typing.TypeAliasType, ctx: EvalContext): assert obj.__module__ # FIXME: or can this really happen? func = obj.evaluate_value mod = sys.modules[obj.__module__] - ff = types.FunctionType(func.__code__, mod.__dict__, None, None, ()) + ff = types.FunctionType( + func.__code__, mod.__dict__, None, None, func.__closure__ + ) unpacked = ff(annotationlib.Format.VALUE) return _eval_types(unpacked, ctx) From 73fcf1b1934e4ce9c6aca662892275b1158231a1 Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Fri, 9 Jan 2026 14:43:27 -0800 Subject: [PATCH 4/5] Make the explicit one work again --- typemap/type_eval/_apply_generic.py | 26 ++++++++++++++++++++------ typemap/type_eval/_eval_typing.py | 16 +++++++++++++--- 2 files changed, 33 insertions(+), 9 deletions(-) diff --git a/typemap/type_eval/_apply_generic.py b/typemap/type_eval/_apply_generic.py index 73b37ae..d2daaef 100644 --- a/typemap/type_eval/_apply_generic.py +++ b/typemap/type_eval/_apply_generic.py @@ -281,7 +281,7 @@ def get_local_defns(boxed: Boxed) -> tuple[dict[str, Any], dict[str, Any]]: return annos, dct -def flatten_class(cls: type) -> type: +def flatten_class_new_proto(cls: type) -> type: # This is a hacky version of flatten_class that works by using # NewProtocol on Members! # @@ -294,8 +294,13 @@ def flatten_class(cls: type) -> type: type ClsAlias = NewProtocol[*[m for m in Iter[Members[cls]]]] # type: ignore[valid-type] nt = _eval_typing.eval_typing(ClsAlias) - nt.__name__ = cls.__name__ - nt.__qualname__ = cls.__qualname__ + + args = typing.get_args(cls) + args_str = ", ".join(_type_repr(a) for a in args) + args_str = f'[{args_str}]' if args_str else '' + + nt.__name__ = f'{cls.__name__}{args_str}' + nt.__qualname__ = f'{cls.__qualname__}{args_str}' del nt.__subclasshook__ return nt @@ -311,8 +316,9 @@ def _type_repr(t: Any) -> str: return repr(t) -# XXX: This is all dead now??? -def apply( +# TODO: Potentially most of this could be ripped out. The internals +# don't use this at all, it's only used by format_class. +def _flatten_class_explicit( cls: type[Any], ctx: _eval_typing.EvalContext ) -> type[_eval_typing._EvalProxy]: cls_boxed = box(cls) @@ -349,7 +355,7 @@ def apply( "__local_args__": args, }, ) - ctx.seen[boxed.alias_type()] = new[boxed] = cboxed + new[boxed] = cboxed annos: dict[str, Any] = {} dct: dict[str, Any] = {} @@ -383,3 +389,11 @@ def apply( setattr(cboxed, k, _eval_typing._eval_types(v, ctx=ctx)) return new[cls_boxed] + + +def flatten_class_explicit(obj: typing.Any): + with _eval_typing._ensure_context() as ctx: + return _flatten_class_explicit(obj, ctx) + + +flatten_class = flatten_class_explicit diff --git a/typemap/type_eval/_eval_typing.py b/typemap/type_eval/_eval_typing.py index dd72439..2b9a218 100644 --- a/typemap/type_eval/_eval_typing.py +++ b/typemap/type_eval/_eval_typing.py @@ -188,6 +188,16 @@ def _is_type_alias_type(obj: typing.Any) -> bool: ) +def _apply_type(base, args): + # Some type aliases (like Final) get mad if they get a 1-ary tuple... + # TODO: Should we special case 0? + # (Should we fill in Anys???) + if len(args) == 1: + return base[args[0]] + else: + return base[*args] + + def _eval_types(obj: typing.Any, ctx: EvalContext): # Found a recursive alias, we need to unwind it if obj in ctx.alias_stack: @@ -289,7 +299,7 @@ def _eval_applied_type_alias(obj: types.GenericAlias, ctx: EvalContext): """ new_args = tuple(_eval_types(arg, ctx) for arg in obj.__args__) - new_obj = obj.__origin__[new_args] # type: ignore[index] + new_obj = _apply_type(obj.__origin__, new_args) if isinstance(obj.__origin__, type): # This is a GenericAlias over a Python class, e.g. `dict[str, int]` # Let's reconstruct it by evaluating all arguments @@ -343,7 +353,7 @@ def _eval_applied_class(obj: typing_GenericAlias, ctx: EvalContext): # return _eval_types(ret, ctx) # ??? return ret else: - return obj.__origin__[new_args] # type: ignore[index] + return _apply_type(obj.__origin__, new_args) @_eval_types_impl.register @@ -358,7 +368,7 @@ def _eval_ty_or_list(obj): new_args = tuple(_eval_ty_or_list(arg) for arg in typing.get_args(obj)) # origin for Callable is collections.abc.Callable which kind of annoying - return typing.Callable[*new_args] + return _apply_type(typing.Callable, new_args) @_eval_types_impl.register From a9529519c1b6236a2349f9c6fd819814adc7047f Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Mon, 12 Jan 2026 08:49:44 -0800 Subject: [PATCH 5/5] Don't evaluate the body of Literals --- tests/test_type_eval.py | 15 +++++++++++++++ typemap/type_eval/_eval_typing.py | 8 ++++++++ 2 files changed, 23 insertions(+) diff --git a/tests/test_type_eval.py b/tests/test_type_eval.py index 4d47382..fc966c4 100644 --- a/tests/test_type_eval.py +++ b/tests/test_type_eval.py @@ -652,6 +652,21 @@ class Container2[T]: ... assert eval_typing(GetArg[t, Container, 1]) == Never +type _Works[Ts, I] = Literal[True] +type Works[Ts] = _Works[Ts, Length[Ts]] + +type _Fails[Ts, I] = Literal[False] +type Fails[Ts] = _Fails[Ts, Literal[0]] + + +def test_consistency_01(): + t = eval_typing(Works[tuple[int, str]]) + assert t == Literal[True] + + t = eval_typing(Fails[tuple[int, str]]) + assert t == Literal[False] + + def test_uppercase_never(): d = eval_typing(Uppercase[Never]) assert d is Never diff --git a/typemap/type_eval/_eval_typing.py b/typemap/type_eval/_eval_typing.py index 2b9a218..7f76660 100644 --- a/typemap/type_eval/_eval_typing.py +++ b/typemap/type_eval/_eval_typing.py @@ -12,6 +12,7 @@ from typing import _GenericAlias as typing_GenericAlias # type: ignore [attr-defined] # noqa: PLC2701 from typing import _CallableGenericAlias as typing_CallableGenericAlias # type: ignore [attr-defined] # noqa: PLC2701 +from typing import _LiteralGenericAlias as typing_LiteralGenericAlias # type: ignore [attr-defined] # noqa: PLC2701 if typing.TYPE_CHECKING: @@ -278,6 +279,13 @@ def _eval_type_var(obj: typing.TypeVar, ctx: EvalContext): return obj +# We don't want to evaluate the body of Literals; there's nothing to +# do there, and doing it puts weird stuff in the caches. +@_eval_types_impl.register +def _eval_literal(obj: typing_LiteralGenericAlias, ctx: EvalContext): + return obj + + @_eval_types_impl.register def _eval_type_alias(obj: typing.TypeAliasType, ctx: EvalContext): assert obj.__module__ # FIXME: or can this really happen?