From e6124df86583a43c4c8d1f07b7d16cfec403099a Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Thu, 13 Nov 2025 13:25:02 -0800 Subject: [PATCH 1/6] Reorganize _eval_call a bit --- typemap/type_eval/_eval_call.py | 89 +++++++++++++++++++++++----- typemap/type_eval/_eval_operators.py | 22 +------ typemap/typing.py | 20 ------- 3 files changed, 77 insertions(+), 54 deletions(-) diff --git a/typemap/type_eval/_eval_call.py b/typemap/type_eval/_eval_call.py index 248030d..1893b76 100644 --- a/typemap/type_eval/_eval_call.py +++ b/typemap/type_eval/_eval_call.py @@ -1,38 +1,97 @@ +from dataclasses import dataclass + import annotationlib +import inspect import types import typing -if typing.TYPE_CHECKING: - from typing import Any +from typing import Any from typemap import typing as next from . import _eval_typing +RtType = Any + + +@dataclass(frozen=True, eq=False) +class _CallSpecWrapper: + _args: tuple[typing.Any] + _kwargs: dict[str, typing.Any] + # _args: type[tuple] + # _kwargs: type + + @property + def args(self) -> typing.Any: + return self._args + + @property + def kwargs(self) -> typing.Any: + return self._kwargs -def eval_call(func: types.FunctionType, /, *args: Any, **kwargs: Any) -> Any: - with _eval_typing._ensure_context() as ctx: - return _eval_call(func, ctx, *args, **kwargs) +def eval_call(func: types.FunctionType, /, *args: Any, **kwargs: Any) -> RtType: + # N.B: This doesn't *really* work!! + # TODO: Do Literals for bool, int, str, None? + arg_types = tuple(type(t) for t in args) + kwarg_types = {k: type(t) for k, t in kwargs.items()} + return eval_call_with_types(func, arg_types, kwarg_types) -def _eval_call( + +def _get_bound_args( func: types.FunctionType, - ctx: _eval_typing.EvalContext, - /, - *args: Any, - **kwargs: Any, -) -> Any: - vars: dict[str, Any] = {} + arg_types: tuple[RtType, ...], + kwarg_types: dict[str, RtType], +) -> inspect.BoundArguments: + # XXX: I don't think this really does anything useful. + # We should try to be smarter about this. + ff = types.FunctionType( + func.__code__, + func.__globals__, + func.__name__, + None, + (), + ) + + # We can't call `inspect.signature` on `spec` directly -- + # signature() will attempt to resolve annotations and fail. + # So we run it on a copy of the function that doesn't have + # annotations set. + sig = inspect.signature(ff) + bound = sig.bind(*arg_types, **kwarg_types) + return bound + + +def eval_call_with_types( + func: types.FunctionType, + arg_types: tuple[RtType, ...], + kwarg_types: dict[str, RtType], +) -> RtType: + vars: dict[str, Any] = {} params = func.__type_params__ for p in params: if hasattr(p, "__bound__") and p.__bound__ is next.CallSpec: - vars[p.__name__] = next._CallSpecWrapper( - args, tuple(kwargs.items()), func - ) + bound = _get_bound_args(func, arg_types, kwarg_types) + vars[p.__name__] = _CallSpecWrapper(bound.args, bound.kwargs) else: vars[p.__name__] = p + return eval_call_with_type_vars(func, vars) + + +def eval_call_with_type_vars( + func: types.FunctionType, vars: dict[str, RtType] +) -> RtType: + with _eval_typing._ensure_context() as ctx: + return _eval_call_with_type_vars(func, vars, ctx) + + +def _eval_call_with_type_vars( + func: types.FunctionType, + vars: dict[str, RtType], + ctx: _eval_typing.EvalContext, +) -> RtType: try: af = func.__annotate__ except AttributeError: diff --git a/typemap/type_eval/_eval_operators.py b/typemap/type_eval/_eval_operators.py index 0d80289..6ebd8ce 100644 --- a/typemap/type_eval/_eval_operators.py +++ b/typemap/type_eval/_eval_operators.py @@ -7,11 +7,11 @@ from typemap import type_eval from typemap.type_eval import _typing_inspect from typemap.type_eval._eval_typing import _eval_types +from typemap.type_eval._eval_call import _CallSpecWrapper from typemap.typing import ( Attrs, CallSpecKwargs, - _CallSpecWrapper, Iter, IsSubtype, IsSubSimilar, @@ -125,29 +125,13 @@ def _eval_IsSubSimilar(lhs, rhs, *, ctx): @type_eval.register_evaluator(CallSpecKwargs) def _eval_CallSpecKwargs(spec: _CallSpecWrapper, *, ctx): - ff = types.FunctionType( - spec._func.__code__, - spec._func.__globals__, - spec._func.__name__, - None, - (), - ) - - # We can't call `inspect.signature` on `spec` directly -- - # signature() will attempt to resolve annotations and fail. - # So we run it on a copy of the function that doesn't have - # annotations set. - sig = inspect.signature(ff) - bound = sig.bind(*spec._args, **dict(spec._kwargs)) - - # TODO: Get the real type instead of Never return tuple[ # type: ignore[misc] *[ Member[ typing.Literal[name], # type: ignore[valid-type] - typing.Never, + ty, # type: ignore[valid-type] ] - for name in bound.kwargs + for name, ty in spec._kwargs.items() ] ] diff --git a/typemap/typing.py b/typemap/typing.py index 5b08b87..d235e0a 100644 --- a/typemap/typing.py +++ b/typemap/typing.py @@ -1,7 +1,4 @@ -from dataclasses import dataclass - import contextvars -import types import typing from typing import _GenericAlias # type: ignore @@ -9,27 +6,10 @@ _SpecialForm: typing.Any = typing._SpecialForm -@dataclass(frozen=True) class CallSpec: pass -@dataclass(frozen=True) -class _CallSpecWrapper: - _args: tuple[typing.Any] - _kwargs: tuple[tuple[str, typing.Any], ...] - # TODO: Support MethodType! - _func: types.FunctionType # | types.MethodType - - @property - def args(self) -> None: - pass - - @property - def kwargs(self) -> None: - pass - - class CallSpecKwargs[Spec]: pass From de034e5b191bfdd94df8f596564ee073a5f26d2f Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Thu, 13 Nov 2025 15:30:05 -0800 Subject: [PATCH 2/6] Switch to using ParamSpec and creating a TypedDict -- probably not where we will stay --- tests/test_call.py | 10 ++++------ tests/test_qblike.py | 6 ++---- typemap/type_eval/_eval_call.py | 14 +++++++------- typemap/type_eval/_eval_operators.py | 18 ------------------ typemap/typing.py | 11 ----------- 5 files changed, 13 insertions(+), 46 deletions(-) diff --git a/tests/test_call.py b/tests/test_call.py index 2138404..465c9b0 100644 --- a/tests/test_call.py +++ b/tests/test_call.py @@ -1,23 +1,21 @@ import textwrap + from typemap.type_eval import eval_call from typemap.typing import ( - CallSpec, + Attrs, NewProtocol, Member, GetName, Iter, - CallSpecKwargs, ) from . import format_helper -def func[C: CallSpec]( +def func[**C]( *args: C.args, **kwargs: C.kwargs -) -> NewProtocol[ - *[Member[GetName[c], int] for c in Iter[CallSpecKwargs[C]]] -]: ... +) -> NewProtocol[*[Member[GetName[c], int] for c in Iter[Attrs[C.kwargs]]]]: ... def test_call_1(): diff --git a/tests/test_qblike.py b/tests/test_qblike.py index 8870589..38423ad 100644 --- a/tests/test_qblike.py +++ b/tests/test_qblike.py @@ -9,11 +9,9 @@ Attrs, Is, GetType, - CallSpec, Member, GetName, GetAttr, - CallSpecKwargs, GetArg, ) @@ -53,7 +51,7 @@ class A: w: Property[list[str]] -def select[C: CallSpec]( +def select[**C]( __rcv: A, *args: C.args, **kwargs: C.kwargs ) -> NewProtocol[ *[ @@ -61,7 +59,7 @@ def select[C: CallSpec]( GetName[c], FilterLinks[GetAttr[A, GetName[c]]], ] - for c in Iter[CallSpecKwargs[C]] + for c in Iter[Attrs[C.kwargs]] ] ]: ... diff --git a/typemap/type_eval/_eval_call.py b/typemap/type_eval/_eval_call.py index 1893b76..7a5e763 100644 --- a/typemap/type_eval/_eval_call.py +++ b/typemap/type_eval/_eval_call.py @@ -7,7 +7,6 @@ from typing import Any -from typemap import typing as next from . import _eval_typing @@ -16,10 +15,8 @@ @dataclass(frozen=True, eq=False) class _CallSpecWrapper: - _args: tuple[typing.Any] - _kwargs: dict[str, typing.Any] - # _args: type[tuple] - # _kwargs: type + _args: type[tuple] + _kwargs: type @property def args(self) -> typing.Any: @@ -71,9 +68,12 @@ def eval_call_with_types( vars: dict[str, Any] = {} params = func.__type_params__ for p in params: - if hasattr(p, "__bound__") and p.__bound__ is next.CallSpec: + if isinstance(p, typing.ParamSpec): bound = _get_bound_args(func, arg_types, kwarg_types) - vars[p.__name__] = _CallSpecWrapper(bound.args, bound.kwargs) + vars[p.__name__] = _CallSpecWrapper( + tuple[bound.args], # type: ignore[name-defined] + typing.TypedDict("**kwargs", bound.kwargs), # type: ignore[operator] + ) else: vars[p.__name__] = p diff --git a/typemap/type_eval/_eval_operators.py b/typemap/type_eval/_eval_operators.py index 6ebd8ce..ad5ff46 100644 --- a/typemap/type_eval/_eval_operators.py +++ b/typemap/type_eval/_eval_operators.py @@ -7,11 +7,9 @@ from typemap import type_eval from typemap.type_eval import _typing_inspect from typemap.type_eval._eval_typing import _eval_types -from typemap.type_eval._eval_call import _CallSpecWrapper from typemap.typing import ( Attrs, - CallSpecKwargs, Iter, IsSubtype, IsSubSimilar, @@ -123,22 +121,6 @@ def _eval_IsSubSimilar(lhs, rhs, *, ctx): ################################################################## -@type_eval.register_evaluator(CallSpecKwargs) -def _eval_CallSpecKwargs(spec: _CallSpecWrapper, *, ctx): - return tuple[ # type: ignore[misc] - *[ - Member[ - typing.Literal[name], # type: ignore[valid-type] - ty, # type: ignore[valid-type] - ] - for name, ty in spec._kwargs.items() - ] - ] - - -################################################################## - - def _function_type(func, *, is_method): root = inspect.unwrap(func) sig = inspect.signature(root) diff --git a/typemap/typing.py b/typemap/typing.py index d235e0a..5ce0b1b 100644 --- a/typemap/typing.py +++ b/typemap/typing.py @@ -6,17 +6,6 @@ _SpecialForm: typing.Any = typing._SpecialForm -class CallSpec: - pass - - -class CallSpecKwargs[Spec]: - pass - - -################################################################## - - class Member[N: str, T, Q: str = typing.Never, D = typing.Never]: pass From f9c595f1c8032d8b7a9f13277272b2f1623ec575 Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Thu, 13 Nov 2025 17:23:12 -0800 Subject: [PATCH 3/6] Drop ParamSpec also and try a new thing with TypedDicts... --- tests/test_call.py | 8 ++-- tests/test_qblike.py | 8 ++-- typemap/type_eval/_eval_call.py | 81 +++++++++++++++++---------------- 3 files changed, 50 insertions(+), 47 deletions(-) diff --git a/tests/test_call.py b/tests/test_call.py index 465c9b0..3667efd 100644 --- a/tests/test_call.py +++ b/tests/test_call.py @@ -1,5 +1,6 @@ import textwrap +from typing import Unpack from typemap.type_eval import eval_call from typemap.typing import ( @@ -13,9 +14,10 @@ from . import format_helper -def func[**C]( - *args: C.args, **kwargs: C.kwargs -) -> NewProtocol[*[Member[GetName[c], int] for c in Iter[Attrs[C.kwargs]]]]: ... +def func[*T, K: dict]( + *args: Unpack[T], + **kwargs: Unpack[K], +) -> NewProtocol[*[Member[GetName[c], int] for c in Iter[Attrs[K]]]]: ... def test_call_1(): diff --git a/tests/test_qblike.py b/tests/test_qblike.py index 38423ad..2b093bb 100644 --- a/tests/test_qblike.py +++ b/tests/test_qblike.py @@ -1,6 +1,6 @@ import textwrap -from typing import Literal +from typing import Literal, Unpack from typemap.type_eval import eval_call, eval_typing from typemap.typing import ( @@ -51,15 +51,15 @@ class A: w: Property[list[str]] -def select[**C]( - __rcv: A, *args: C.args, **kwargs: C.kwargs +def select[K: dict]( + __rcv: A, **kwargs: Unpack[K] ) -> NewProtocol[ *[ Member[ GetName[c], FilterLinks[GetAttr[A, GetName[c]]], ] - for c in Iter[Attrs[C.kwargs]] + for c in Iter[Attrs[K]] ] ]: ... diff --git a/typemap/type_eval/_eval_call.py b/typemap/type_eval/_eval_call.py index 7a5e763..2eed230 100644 --- a/typemap/type_eval/_eval_call.py +++ b/typemap/type_eval/_eval_call.py @@ -1,5 +1,3 @@ -from dataclasses import dataclass - import annotationlib import inspect import types @@ -12,19 +10,7 @@ RtType = Any - -@dataclass(frozen=True, eq=False) -class _CallSpecWrapper: - _args: type[tuple] - _kwargs: type - - @property - def args(self) -> typing.Any: - return self._args - - @property - def kwargs(self) -> typing.Any: - return self._kwargs +from typing import _UnpackGenericAlias # type: ignore [attr-defined] # noqa: PLC2701 def eval_call(func: types.FunctionType, /, *args: Any, **kwargs: Any) -> RtType: @@ -35,29 +21,49 @@ def eval_call(func: types.FunctionType, /, *args: Any, **kwargs: Any) -> RtType: return eval_call_with_types(func, arg_types, kwarg_types) -def _get_bound_args( +def _get_bound_type_args( func: types.FunctionType, arg_types: tuple[RtType, ...], kwarg_types: dict[str, RtType], -) -> inspect.BoundArguments: - # XXX: I don't think this really does anything useful. - # We should try to be smarter about this. - ff = types.FunctionType( - func.__code__, - func.__globals__, - func.__name__, - None, - (), - ) - - # We can't call `inspect.signature` on `spec` directly -- - # signature() will attempt to resolve annotations and fail. - # So we run it on a copy of the function that doesn't have - # annotations set. - sig = inspect.signature(ff) +) -> dict[str, RtType]: + sig = inspect.signature(func) bound = sig.bind(*arg_types, **kwarg_types) - return bound + vars: dict[str, RtType] = {} + # TODO: duplication, error cases + for param in sig.parameters.values(): + if ( + param.kind == inspect.Parameter.VAR_POSITIONAL + # XXX: typing_extensions also + and isinstance(param.annotation, _UnpackGenericAlias) + and param.annotation.__args__ + and (tv := param.annotation.__args__[0]) + # XXX: should we allow just a regular one with a tuple bound also? + # maybe! it would match what I want to do for kwargs! + and isinstance(tv, typing.TypeVarTuple) + ): + tps = bound.arguments.get(param.name, ()) + vars[tv.__name__] = tuple[tps] # type: ignore[valid-type] + elif ( + param.kind == inspect.Parameter.VAR_KEYWORD + # XXX: typing_extensions also + and isinstance(param.annotation, _UnpackGenericAlias) + and param.annotation.__args__ + and (tv := param.annotation.__args__[0]) + # XXX: should we allow just a regular one with a tuple bound also? + # maybe! it would match what I want to do for kwargs! + and isinstance(tv, typing.TypeVar) + and tv.__bound__ + and ( + issubclass(tv.__bound__, dict) + or typing.is_typeddict(tv.__bound__) + ) + ): + tp = typing.TypedDict(f"**{param.name}", bound.kwargs) # type: ignore[misc, operator] + vars[tv.__name__] = tp + # TODO: simple bindings to other variables too + + return vars def eval_call_with_types( @@ -67,14 +73,9 @@ def eval_call_with_types( ) -> RtType: vars: dict[str, Any] = {} params = func.__type_params__ + vars = _get_bound_type_args(func, arg_types, kwarg_types) for p in params: - if isinstance(p, typing.ParamSpec): - bound = _get_bound_args(func, arg_types, kwarg_types) - vars[p.__name__] = _CallSpecWrapper( - tuple[bound.args], # type: ignore[name-defined] - typing.TypedDict("**kwargs", bound.kwargs), # type: ignore[operator] - ) - else: + if p.__name__ not in vars: vars[p.__name__] = p return eval_call_with_type_vars(func, vars) From 7480c6e7803e56df813e1f62acd6d97a863d0b41 Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Thu, 13 Nov 2025 18:15:10 -0800 Subject: [PATCH 4/6] Put in Literal types --- tests/test_call.py | 21 +++++++++++++++++++++ typemap/type_eval/_eval_call.py | 14 ++++++++++---- 2 files changed, 31 insertions(+), 4 deletions(-) diff --git a/tests/test_call.py b/tests/test_call.py index 3667efd..1f00edb 100644 --- a/tests/test_call.py +++ b/tests/test_call.py @@ -30,3 +30,24 @@ class func[...]: b: int c: int """) + + +def func_trivial[*T, K: dict]( + *args: Unpack[T], + **kwargs: Unpack[K], +) -> K: + return kwargs + + +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/typemap/type_eval/_eval_call.py b/typemap/type_eval/_eval_call.py index 2eed230..3e48e79 100644 --- a/typemap/type_eval/_eval_call.py +++ b/typemap/type_eval/_eval_call.py @@ -1,4 +1,5 @@ import annotationlib +import enum import inspect import types import typing @@ -13,11 +14,16 @@ from typing import _UnpackGenericAlias # type: ignore [attr-defined] # noqa: PLC2701 +def _type(t): + if t is None or isinstance(t, (int, str, bool, bytes, enum.Enum)): + return typing.Literal[t] + else: + return type(t) + + def eval_call(func: types.FunctionType, /, *args: Any, **kwargs: Any) -> RtType: - # N.B: This doesn't *really* work!! - # TODO: Do Literals for bool, int, str, None? - arg_types = tuple(type(t) for t in args) - kwarg_types = {k: type(t) for k, t in kwargs.items()} + arg_types = tuple(_type(t) for t in args) + kwarg_types = {k: _type(t) for k, t in kwargs.items()} return eval_call_with_types(func, arg_types, kwarg_types) From c4f49fd3dfb477139db63a967dcf10dfb8ac51d9 Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Thu, 13 Nov 2025 18:29:49 -0800 Subject: [PATCH 5/6] Switch to using a BaseTypedDict --- tests/test_call.py | 5 +++-- tests/test_qblike.py | 28 +++++++++++++++------------- typemap/type_eval/_eval_call.py | 6 ++---- typemap/typing.py | 10 +++++++++- 4 files changed, 29 insertions(+), 20 deletions(-) diff --git a/tests/test_call.py b/tests/test_call.py index 1f00edb..0cdbe9c 100644 --- a/tests/test_call.py +++ b/tests/test_call.py @@ -5,6 +5,7 @@ from typemap.type_eval import eval_call from typemap.typing import ( Attrs, + BaseTypedDict, NewProtocol, Member, GetName, @@ -14,7 +15,7 @@ from . import format_helper -def func[*T, K: dict]( +def func[*T, K: BaseTypedDict]( *args: Unpack[T], **kwargs: Unpack[K], ) -> NewProtocol[*[Member[GetName[c], int] for c in Iter[Attrs[K]]]]: ... @@ -32,7 +33,7 @@ class func[...]: """) -def func_trivial[*T, K: dict]( +def func_trivial[*T, K: BaseTypedDict]( *args: Unpack[T], **kwargs: Unpack[K], ) -> K: diff --git a/tests/test_qblike.py b/tests/test_qblike.py index 2b093bb..87752d1 100644 --- a/tests/test_qblike.py +++ b/tests/test_qblike.py @@ -4,6 +4,7 @@ from typemap.type_eval import eval_call, eval_typing from typemap.typing import ( + BaseTypedDict, NewProtocol, Iter, Attrs, @@ -34,6 +35,20 @@ class Link[T]: type FilterLinks[T] = Link[PropsOnly[GetArg[T, Link, 0]]] if Is[T, Link] else T +def select[K: BaseTypedDict]( + __rcv: A, + **kwargs: Unpack[K], +) -> NewProtocol[ + *[ + Member[ + GetName[c], + FilterLinks[GetAttr[A, GetName[c]]], + ] + for c in Iter[Attrs[K]] + ] +]: ... + + # Basic filtering class Tgt2: pass @@ -51,19 +66,6 @@ class A: w: Property[list[str]] -def select[K: dict]( - __rcv: A, **kwargs: Unpack[K] -) -> NewProtocol[ - *[ - Member[ - GetName[c], - FilterLinks[GetAttr[A, GetName[c]]], - ] - for c in Iter[Attrs[K]] - ] -]: ... - - def test_qblike_1(): ret = eval_call( select, diff --git a/typemap/type_eval/_eval_call.py b/typemap/type_eval/_eval_call.py index 3e48e79..e00de08 100644 --- a/typemap/type_eval/_eval_call.py +++ b/typemap/type_eval/_eval_call.py @@ -3,6 +3,7 @@ import inspect import types import typing +import typing_extensions from typing import Any @@ -60,10 +61,7 @@ def _get_bound_type_args( # maybe! it would match what I want to do for kwargs! and isinstance(tv, typing.TypeVar) and tv.__bound__ - and ( - issubclass(tv.__bound__, dict) - or typing.is_typeddict(tv.__bound__) - ) + and typing_extensions.is_typeddict(tv.__bound__) ): tp = typing.TypedDict(f"**{param.name}", bound.kwargs) # type: ignore[misc, operator] vars[tv.__name__] = tp diff --git a/typemap/typing.py b/typemap/typing.py index 5ce0b1b..9d4eccb 100644 --- a/typemap/typing.py +++ b/typemap/typing.py @@ -2,9 +2,17 @@ import typing from typing import _GenericAlias # type: ignore - _SpecialForm: typing.Any = typing._SpecialForm +# Not type-level computation but related + + +class BaseTypedDict(typing.TypedDict): + pass + + +### + class Member[N: str, T, Q: str = typing.Never, D = typing.Never]: pass From fa75542cc13b8e0071ca23946a03a2c5885f3120 Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Fri, 5 Dec 2025 13:02:14 -0800 Subject: [PATCH 6/6] Spec note --- spec-draft.rst | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/spec-draft.rst b/spec-draft.rst index 0fb30b7..e5279b4 100644 --- a/spec-draft.rst +++ b/spec-draft.rst @@ -1,3 +1,11 @@ +A minor proposal that could be split out maybe: + +Supporting ``Unpack`` of typevars for ``*kwargs`` + + + + +----------------------------------------------------------------------- Grammar specification of the extensions to the type language.