From a3cb55c4ecb594c5c32844d7d47bfd85ef59b3b8 Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Fri, 6 Feb 2026 19:04:11 -0800 Subject: [PATCH] Build GenericCallables when we can't evaluate a method's params The lambda we stick into the GenericCallable will actually go evaluate the type annotations, then produce a Callable. --- tests/test_type_dir.py | 26 +++++++++-- typemap/type_eval/__init__.py | 2 + typemap/type_eval/_apply_generic.py | 69 +++++++++++++++++++++++++--- typemap/type_eval/_eval_operators.py | 30 ++++++++---- typemap/typing.py | 12 ++++- 5 files changed, 119 insertions(+), 20 deletions(-) diff --git a/tests/test_type_dir.py b/tests/test_type_dir.py index 523ef08..19bf2d1 100644 --- a/tests/test_type_dir.py +++ b/tests/test_type_dir.py @@ -2,7 +2,7 @@ import typing from typing import Literal, Never, TypeVar, TypedDict, Union, ReadOnly -from typemap.type_eval import eval_typing +from typemap.type_eval import eval_typing, _ensure_context from typemap_extensions import ( Attrs, FromUnion, @@ -323,6 +323,25 @@ class Last[bool]: """) +def test_type_dir_10(): + class Lurr: + def foo[T](x: T) -> int if IsAssignable[T, str] else list[int]: ... + + d = eval_typing(Lurr) + + assert format_helper.format_class(d) == textwrap.dedent("""\ + class Lurr: + foo: typing.ClassVar[typemap.typing.GenericCallable[tuple[T], <...>]] + """) + + member = _get_member(eval_typing(Members[Lurr]), "foo") + + fn = member.__args__[1].__args__[1] + with _ensure_context(): + assert fn(str).__args__[1] is int + assert fn(bool).__args__[1] == list[int] + + def test_type_dir_get_arg_1(): d = eval_typing(BaseArg[Final]) assert d is int @@ -405,10 +424,7 @@ def test_type_members_func_3(): assert name == typing.Literal["sbase"] assert quals == typing.Literal["ClassVar"] - assert ( - str(typ) - == "typemap.typing.GenericCallable[tuple[Z], typemap.type_eval._eval_operators._create_generic_callable_lambda..]" - ) + assert str(typ) == "typemap.typing.GenericCallable[tuple[Z], <...>]" evaled = eval_typing( typing.get_args(typ)[1](*typing.get_args(typing.get_args(typ)[0])) diff --git a/typemap/type_eval/__init__.py b/typemap/type_eval/__init__.py index 030362b..f6d8fbf 100644 --- a/typemap/type_eval/__init__.py +++ b/typemap/type_eval/__init__.py @@ -1,6 +1,7 @@ from ._eval_typing import ( eval_typing, _get_current_context, + _ensure_context, register_evaluator, StuckException, _EvalProxy, @@ -28,4 +29,5 @@ "StuckException", "_EvalProxy", "_get_current_context", + "_ensure_context", ) diff --git a/typemap/type_eval/_apply_generic.py b/typemap/type_eval/_apply_generic.py index ea8b4d3..c8ec175 100644 --- a/typemap/type_eval/_apply_generic.py +++ b/typemap/type_eval/_apply_generic.py @@ -11,6 +11,7 @@ from . import _eval_typing from . import _typing_inspect + if typing.TYPE_CHECKING: from typing import Any, Mapping @@ -265,7 +266,34 @@ def get_annotations( return rr +def _resolved_function_signature(func, args): + """Get the signature of a function with type hints resolved to arg values""" + + import typemap.typing as nt + + token = nt.special_form_evaluator.set(None) + try: + sig = inspect.signature(func) + finally: + nt.special_form_evaluator.reset(token) + + if hints := get_annotations(func, args): + params = [] + for name, param in sig.parameters.items(): + annotation = hints.get(name, param.annotation) + params.append(param.replace(annotation=annotation)) + + return_annotation = hints.get("return", sig.return_annotation) + sig = sig.replace( + parameters=params, return_annotation=return_annotation + ) + + return sig + + def get_local_defns(boxed: Boxed) -> tuple[dict[str, Any], dict[str, Any]]: + from typemap.typing import GenericCallable + annos: dict[str, Any] = {} dct: dict[str, Any] = {} @@ -284,25 +312,54 @@ def get_local_defns(boxed: Boxed) -> tuple[dict[str, Any], dict[str, Any]]: # TODO: This annos_ok thing is a hack because processing # __annotations__ on methods broke stuff and I didn't want # to chase it down yet. + stuck = False try: rr = get_annotations( stuff, boxed.str_args, cls=boxed.cls, annos_ok=False ) except _eval_typing.StuckException: - # TODO: Either generate a GenericCallable or a - # function with our own __annotate__ for this case - # where we can't even fetch the signature without - # trouble. + stuck = True rr = None if rr is not None: local_fn = make_func(orig, rr) - elif getattr(stuff, "__annotations__", None): + elif not stuck and getattr(stuff, "__annotations__", None): # XXX: This is totally wrong; we still need to do # substitute in class vars local_fn = stuff - if local_fn is not None: + # If we got stuck, we build a GenericCallable that + # computes the type once it has been given type + # variables! + if stuck and stuff.__type_params__: + type_params = stuff.__type_params__ + str_args = boxed.str_args + + def _make_lambda(fn, o, sa, tp): + from ._eval_operators import _function_type_from_sig + + def lam(*vs): + args = dict(sa) + args.update( + zip( + (str(p) for p in tp), + vs, + strict=True, + ) + ) + sig = _resolved_function_signature(fn, args) + return _function_type_from_sig( + sig, o, receiver_type=None + ) + + return lam + + gc = GenericCallable[ # type: ignore[valid-type,misc] + tuple[*type_params], # type: ignore[valid-type] + _make_lambda(stuff, orig, str_args, type_params), + ] + annos[name] = typing.ClassVar[gc] + elif local_fn is not None: if orig.__class__ is classmethod: local_fn = classmethod(local_fn) elif orig.__class__ is staticmethod: diff --git a/typemap/type_eval/_eval_operators.py b/typemap/type_eval/_eval_operators.py index 6c9e611..60a12dd 100644 --- a/typemap/type_eval/_eval_operators.py +++ b/typemap/type_eval/_eval_operators.py @@ -97,7 +97,7 @@ def cached_box(cls, *, ctx): return box -def get_annotated_type_hints(cls, *, ctx, **kwargs): +def get_annotated_type_hints(cls, *, ctx, attrs_only=False, **kwargs): """Get the type hints/quals for a cls annotated with definition site. This traverses the mro and finds the definition site for each annotation. @@ -127,6 +127,10 @@ def get_annotated_type_hints(cls, *, ctx, **kwargs): else: break + # Skip method-like ClassVars when only attributes are wanted + if attrs_only and "ClassVar" in quals and _is_method_like(ty): + continue + if k in abox.cls.__dict__: # Wrap in tuple when creating Literal in case it *is* a tuple init = _make_init_type(abox.cls.__dict__[k]) @@ -647,11 +651,7 @@ def _callable_type_to_method(name, typ, ctx): return head(func) -def _function_type(func, *, receiver_type): - root = inspect.unwrap(func) - sig = inspect.signature(root) - # XXX: __type_params__!!! - +def _function_type_from_sig(sig, func, *, receiver_type): empty = inspect.Parameter.empty def _ann(x): @@ -707,6 +707,15 @@ def _ann(x): f = classmethod[specified_receiver, tuple[*params[1:]], ret] else: f = typing.Callable[params, ret] + + return f + + +def _function_type(func, *, receiver_type): + root = inspect.unwrap(func) + sig = inspect.signature(root) + f = _function_type_from_sig(sig, func, receiver_type=receiver_type) + if root.__type_params__: # Must store a lambda that performs type variable substitution type_params = root.__type_params__ @@ -762,7 +771,9 @@ def _hints_to_members(hints, ctx): @type_eval.register_evaluator(Attrs) @_lift_over_unions def _eval_Attrs(tp, *, ctx): - hints = get_annotated_type_hints(tp, include_extras=True, ctx=ctx) + hints = get_annotated_type_hints( + tp, include_extras=True, attrs_only=True, ctx=ctx + ) return _hints_to_members(hints, ctx) @@ -1190,7 +1201,10 @@ def _eval_NewProtocol(*etyps: Member, ctx): if type_eval.issubtype( typing.Literal["ClassVar"], tquals ) and _is_method_like(typ): - dct[name] = _callable_type_to_method(name, typ, ctx) + try: + dct[name] = _callable_type_to_method(name, typ, ctx) + except type_eval.StuckException: + annos[name] = _add_quals(typ, tquals) else: annos[name] = _add_quals(typ, tquals) _unpack_init(dct, name, init) diff --git a/typemap/typing.py b/typemap/typing.py index aac61c7..8a89f26 100644 --- a/typemap/typing.py +++ b/typemap/typing.py @@ -72,7 +72,17 @@ class SpecialFormEllipsis: class _GenericCallableGenericAlias(_GenericAlias, _root=True): - pass + def __repr__(self): + from typing import _type_repr + + name = _type_repr(self.__origin__) + if self.__args__: + rargs = [_type_repr(self.__args__[0]), "<...>"] + args = ", ".join(rargs) + else: + # To ensure the repr is eval-able. + args = "()" + return f'{name}[{args}]' class GenericCallable: