From a28e51f10bb9c24164beb4df4dfec3d7c1dc49b8 Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Fri, 9 Jan 2026 15:27:15 -0800 Subject: [PATCH 1/4] Vibe up a callable_to_signature function --- tests/test_type_eval.py | 43 +++++++++- typemap/type_eval/_eval_operators.py | 113 ++++++++++++++++++++++++++- 2 files changed, 151 insertions(+), 5 deletions(-) diff --git a/tests/test_type_eval.py b/tests/test_type_eval.py index fc966c4..a314aff 100644 --- a/tests/test_type_eval.py +++ b/tests/test_type_eval.py @@ -1,5 +1,3 @@ -import pytest - import collections import textwrap import unittest @@ -15,6 +13,8 @@ Union, ) +import pytest + from typemap.type_eval import eval_typing from typemap.typing import ( Attrs, @@ -730,3 +730,42 @@ def test_eval_literal_idempotent_01(): nt = eval_typing(t) assert t == nt t = nt + + +def test_callable_to_signature(): + from typemap.type_eval._eval_operators import _callable_type_to_signature + from typemap.typing import Param + + # Test the example from the docstring: + # def func( + # a: int, + # /, + # b: int, + # c: int = 0, + # *args: int, + # d: int, + # e: int = 0, + # **kwargs: int + # ) -> int: + callable_type = Callable[ + [ + Param[None, int], + 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, + ] + + sig = _callable_type_to_signature(callable_type) + + params = list(sig.parameters.values()) + assert len(params) == 7 + + assert str(sig) == ( + '(_arg0: int, /, b: int, c: int = Ellipsis, *args: int, ' + 'd: int, e: int = Ellipsis, **kwargs: int) -> int' + ) diff --git a/typemap/type_eval/_eval_operators.py b/typemap/type_eval/_eval_operators.py index 639959e..9714c8c 100644 --- a/typemap/type_eval/_eval_operators.py +++ b/typemap/type_eval/_eval_operators.py @@ -9,8 +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 import _apply_generic, _typing_inspect from typemap.type_eval._eval_typing import _eval_types from typemap.typing import ( Attrs, @@ -35,7 +34,6 @@ Uppercase, ) - ################################################################## @@ -191,6 +189,115 @@ def _eval_IsSubSimilar(lhs, rhs, *, ctx): ################################################################## +def _callable_type_to_signature(callable_type: object) -> inspect.Signature: + """Convert a Callable type with Param specs to an inspect.Signature. + + The callable_type should be of the form: + Callable[ + [ + Param[name, type, quals], + ... + ], + return_type, + ] + + Where: + - name is None for positional-only or variadic params, or a string + - type is the parameter type annotation + - quals is a Literal with any of: "*", "**", "keyword", "default" + or Never if no qualifiers + """ + args = typing.get_args(callable_type) + if len(args) != 2: + raise TypeError(f"Expected Callable[[...], ret], got {callable_type}") + + param_types, return_type = args + + # Handle the case where param_types is a list of Param types + if not isinstance(param_types, (list, tuple)): + raise TypeError(f"Expected list of Param types, got {param_types}") + + parameters: list[inspect.Parameter] = [] + saw_keyword_only = False + + for param_type in param_types: + # Extract Param arguments: Param[name, type, quals] + origin = typing.get_origin(param_type) + if origin is not Param: + raise TypeError(f"Expected Param type, got {param_type}") + + param_args = typing.get_args(param_type) + if len(param_args) < 2: + raise TypeError( + f"Param must have at least name and type, got {param_type}" + ) + + name_type = param_args[0] + annotation = param_args[1] + quals_type = param_args[2] if len(param_args) > 2 else typing.Never + + # Extract name from Literal[name] or None + if _typing_inspect.is_literal(name_type): + name = typing.get_args(name_type)[0] + else: + name = None + + # Extract qualifiers from Literal["*", "**", ...] or Never + quals: set[str] = set() + if quals_type is not typing.Never: + if _typing_inspect.is_literal(quals_type): + qual_args = typing.get_args(quals_type) + quals = set(qual_args) + else: + quals = set() + + # Determine parameter kind and default + kind: inspect._ParameterKind + if "**" in quals: + kind = inspect.Parameter.VAR_KEYWORD + name = name or "kwargs" + elif "*" in quals: + kind = inspect.Parameter.VAR_POSITIONAL + name = name or "args" + # XXX: not sure we need this + saw_keyword_only = True + elif "keyword" in quals: + kind = inspect.Parameter.KEYWORD_ONLY + saw_keyword_only = True + elif name is None: + kind = inspect.Parameter.POSITIONAL_ONLY + elif saw_keyword_only: + kind = inspect.Parameter.KEYWORD_ONLY + else: + kind = inspect.Parameter.POSITIONAL_OR_KEYWORD + + # Handle default value + default: typing.Any + if "default" in quals: + # We don't have the actual default value, use a sentinel + default = ... + else: + default = inspect.Parameter.empty + + # Generate a name for positional-only params if needed + if name is None: + name = f"_arg{len(parameters)}" + + parameters.append( + inspect.Parameter( + name=name, + kind=kind, + default=default, + annotation=annotation, + ) + ) + + return inspect.Signature( + parameters=parameters, + return_annotation=return_type, + ) + + def _function_type(func, *, receiver_type): root = inspect.unwrap(func) sig = inspect.signature(root) From e0b676e35f3933fa73288774e7a6dfdb85d680c0 Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Fri, 9 Jan 2026 16:26:44 -0800 Subject: [PATCH 2/4] Almost everything working --- pyproject.toml | 1 + tests/test_type_dir.py | 24 +++++- typemap/type_eval/_apply_generic.py | 2 +- typemap/type_eval/_eval_operators.py | 116 +++++++++++++++++++++++---- 4 files changed, 121 insertions(+), 22 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 3a5e742..2c90019 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -56,6 +56,7 @@ extend-ignore = [ "E402", # module-import-not-at-top-of-file "E252", # missing-whitespace-around-parameter-equals "F541", # f-string-missing-placeholders + "E731", # don't assign lambdas ] [tool.ruff.lint.per-file-ignores] diff --git a/tests/test_type_dir.py b/tests/test_type_dir.py index 6e6ce81..20e1109 100644 --- a/tests/test_type_dir.py +++ b/tests/test_type_dir.py @@ -58,6 +58,12 @@ def sbase[Z](a: OrGotcha[T] | Z | None, b: K) -> dict[str, T | Z]: pass +class CMethod: + @classmethod + def cbase2(cls, lol: int, /, a: bool | None) -> int: + pass + + class Wrapper[X](Base[X], AnotherBase[X]): x: "Wrapper[X | None]" @@ -185,7 +191,7 @@ def test_type_dir_link_2(): assert loop is Foo -def test_type_dir_1(): +def test_type_dir_1a(): d = eval_typing(Final) assert format_helper.format_class(d) == textwrap.dedent("""\ @@ -197,15 +203,25 @@ class Final: fin: typing.Final[int] x: tests.test_type_dir.Wrapper[int | None] ordinary: str - def foo(self, a: int | None, *, b: int = 0) -> dict[str, int]: ... - def base[Z](self, a: int | Z | None, b: ~K) -> dict[str, int | Z]: ... + def foo(self: tests.test_type_dir.Base[int], a: int | None, *, b: int = ...) -> dict[str, int]: ... + def base[Z](self: self: tests.test_type_dir.Base[int], a: int | Z | None, b: ~K) -> dict[str, int | Z]: ... @classmethod - def cbase(cls, a: int | None, b: ~K) -> dict[str, int]: ... + def cbase(cls: type[tests.test_type_dir.Base[int]], a: int | None, b: ~K) -> dict[str, int]: ... @staticmethod def sbase[Z](a: int | Literal['gotcha!'] | Z | None, b: ~K) -> dict[str, int | Z]: ... """) +def test_type_dir_1b(): + d = eval_typing(CMethod) + + assert format_helper.format_class(d) == textwrap.dedent("""\ + class CMethod: + @classmethod + def cbase2(_arg0: type[tests.test_type_dir.CMethod], _arg1: int, /, a: bool | None) -> int: ... + """) + + def test_type_dir_2(): d = eval_typing(OptionalFinal) diff --git a/typemap/type_eval/_apply_generic.py b/typemap/type_eval/_apply_generic.py index d2daaef..12bd207 100644 --- a/typemap/type_eval/_apply_generic.py +++ b/typemap/type_eval/_apply_generic.py @@ -396,4 +396,4 @@ def flatten_class_explicit(obj: typing.Any): return _flatten_class_explicit(obj, ctx) -flatten_class = flatten_class_explicit +flatten_class = flatten_class_new_proto diff --git a/typemap/type_eval/_eval_operators.py b/typemap/type_eval/_eval_operators.py index 9714c8c..44af74a 100644 --- a/typemap/type_eval/_eval_operators.py +++ b/typemap/type_eval/_eval_operators.py @@ -37,11 +37,14 @@ ################################################################## -def _from_literal(val, ctx): - val = _eval_types(val, ctx) - if _typing_inspect.is_literal(val): - val = val.__args__[0] - return val +def _from_literal(val): + assert _typing_inspect.is_literal(val) + # XXX: check length? + return val.__args__[0] + + +def _eval_literal(val, ctx): + return _from_literal(_eval_types(val, ctx)) def get_annotated_type_hints(cls, **kwargs): @@ -188,6 +191,24 @@ def _eval_IsSubSimilar(lhs, rhs, *, ctx): ################################################################## +def _get_quals(quals_type): + # Extract qualifiers from Literal["*", "**", ...] or Never + quals: set[str] = set() + if _typing_inspect.is_literal(quals_type): + qual_args = typing.get_args(quals_type) + return set(qual_args) + else: + return set() + + +class _DummyDefault: + # A dummy class to assign to defaults that will display as '...' + # Putting actual `...` displays as 'Ellipsis'. + def __repr__(self): + return "..." + +_DUMMY_DEFAULT = _DummyDefault() + def _callable_type_to_signature(callable_type: object) -> inspect.Signature: """Convert a Callable type with Param specs to an inspect.Signature. @@ -275,7 +296,7 @@ def _callable_type_to_signature(callable_type: object) -> inspect.Signature: default: typing.Any if "default" in quals: # We don't have the actual default value, use a sentinel - default = ... + default = _DUMMY_DEFAULT else: default = inspect.Parameter.empty @@ -298,6 +319,54 @@ def _callable_type_to_signature(callable_type: object) -> inspect.Signature: ) +def _signature_to_function(name: str, sig: inspect.Signature): + """ + Creates a new function with a specific inspect.Signature. + """ + + def fn(*args, **kwargs): + raise NotImplementedError + + fn.__name__ = fn.__qualname__ = name + fn.__signature__ = sig # type: ignore[attr-defined] + fn.__annotations__ = { + p.name: p.annotation + for p in sig.parameters.values() + if p.annotation is not inspect.Parameter.empty + } + + return fn + + +def _is_pos_only(param): + name, _, quals = typing.get_args(param) + name = _from_literal(name) + return name is None and not (_get_quals(quals) & {"*", "**"}) + + +def _callable_type_to_method(name, typ): + head = typing.get_origin(typ) + # XXX: handle other amounts + if head is classmethod: + cls, params, ret = typing.get_args(typ) + # We have to make class positional only if there is some other + # positional only argument. Annoying! + pname = "cls" if not any(_is_pos_only(p) for p in typing.get_args(params)) else None + cls_param = Param[ + typing.Literal[pname], + type[cls], + typing.Never, + ] + typ = typing.Callable[[cls_param] + list(typing.get_args(params)), ret] + elif head is staticmethod: + params, ret = typing.get_args(typ) + typ = typing.Callable[list(typing.get_args(params)), ret] + else: + head = lambda x: x + + return head(_signature_to_function(name, _callable_type_to_signature(typ))) + + def _function_type(func, *, receiver_type): root = inspect.unwrap(func) sig = inspect.signature(root) @@ -407,7 +476,7 @@ def _eval_FromUnion(tp, *, ctx): def _eval_GetAttr(lhs, prop, *, ctx): # TODO: the prop missing, etc! # XXX: extras? - name = _from_literal(prop, ctx) + name = _eval_literal(prop, ctx) return typing.get_type_hints(lhs)[name] @@ -556,7 +625,7 @@ def _eval_GetArg(tp, base, idx, *, ctx) -> typing.Any: return typing.Never try: - return _fix_type(args[_from_literal(idx, ctx)]) + return _fix_type(args[_eval_literal(idx, ctx)]) except IndexError: return typing.Never @@ -589,7 +658,7 @@ def _eval_Length(tp, *, ctx) -> typing.Any: def _string_literal_op(typ, op): @_lift_over_unions def func(*args, ctx): - return typing.Literal[op(*[_from_literal(x, ctx) for x in args])] + return typing.Literal[op(*[_eval_literal(x, ctx) for x in args])] type_eval.register_evaluator(typ)(func) @@ -612,17 +681,30 @@ def _add_quals(typ, quals): return typ +def _is_method_like(typ): + return typing.get_origin(typ) in ( + collections.abc.Callable, + staticmethod, + classmethod, + ) + + @type_eval.register_evaluator(NewProtocol) def _eval_NewProtocol(*etyps: Member, ctx): dct: dict[str, object] = {} - dct["__annotations__"] = { - # XXX: Should eval_typing on the etyps evaluate the arguments?? - _from_literal(name, ctx): _add_quals( - _eval_types(typ, ctx), - _eval_types(quals, ctx), - ) - for name, typ, quals, _ in (typing.get_args(prop) for prop in etyps) - } + dct["__annotations__"] = annos = {} + + for tname, typ, quals, _ in (typing.get_args(prop) for prop in etyps): + name = _eval_literal(tname, ctx) + typ = _eval_types(typ, ctx) + tquals = _eval_types(quals, ctx) + + if type_eval.issubsimilar( + typing.Literal["ClassVar"], tquals + ) and _is_method_like(typ): + dct[name] = _callable_type_to_method(name, typ) + else: + annos[name] = _add_quals(typ, tquals) module_name = __name__ name = "NewProtocol" From 444dd14f679bb8c71cadac836ba1d8baaa24035d Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Fri, 9 Jan 2026 16:54:32 -0800 Subject: [PATCH 3/4] Make it all work - main change is GenericCallable --- tests/test_type_dir.py | 6 ++- tests/test_type_eval.py | 4 +- typemap/type_eval/_eval_operators.py | 56 +++++++++++++++++++++------- typemap/typing.py | 13 +++++++ 4 files changed, 61 insertions(+), 18 deletions(-) diff --git a/tests/test_type_dir.py b/tests/test_type_dir.py index 20e1109..b6b9772 100644 --- a/tests/test_type_dir.py +++ b/tests/test_type_dir.py @@ -204,7 +204,7 @@ class Final: x: tests.test_type_dir.Wrapper[int | None] ordinary: str def foo(self: tests.test_type_dir.Base[int], a: int | None, *, b: int = ...) -> dict[str, int]: ... - def base[Z](self: self: tests.test_type_dir.Base[int], a: int | Z | None, b: ~K) -> dict[str, int | Z]: ... + def base[Z](self: tests.test_type_dir.Base[int], a: int | Z | None, b: ~K) -> dict[str, int | Z]: ... @classmethod def cbase(cls: type[tests.test_type_dir.Base[int]], a: int | None, b: ~K) -> dict[str, int]: ... @staticmethod @@ -410,6 +410,8 @@ def test_type_members_func_3(): assert ( str(typ) + # == "\ + # staticmethod[tuple[typemap.typing.Param[typing.Literal['a'], int | typing.Literal['gotcha!'] | Z | None, typing.Never], typemap.typing.Param[typing.Literal['b'], ~K, typing.Never]], dict[str, int | Z]]" == "\ -staticmethod[tuple[typemap.typing.Param[typing.Literal['a'], int | typing.Literal['gotcha!'] | Z | None, typing.Never], typemap.typing.Param[typing.Literal['b'], ~K, typing.Never]], dict[str, int | Z]]" +typemap.typing.GenericCallable[tuple[Z], staticmethod[tuple[typemap.typing.Param[typing.Literal['a'], int | typing.Literal['gotcha!'] | Z | None, typing.Never], typemap.typing.Param[typing.Literal['b'], ~K, typing.Never]], dict[str, int | Z]]]" ) diff --git a/tests/test_type_eval.py b/tests/test_type_eval.py index a314aff..dcb0168 100644 --- a/tests/test_type_eval.py +++ b/tests/test_type_eval.py @@ -766,6 +766,6 @@ def test_callable_to_signature(): assert len(params) == 7 assert str(sig) == ( - '(_arg0: int, /, b: int, c: int = Ellipsis, *args: int, ' - 'd: int, e: int = Ellipsis, **kwargs: int) -> int' + '(_arg0: int, /, b: int, c: int = ..., *args: int, ' + 'd: int, e: int = ..., **kwargs: int) -> int' ) diff --git a/typemap/type_eval/_eval_operators.py b/typemap/type_eval/_eval_operators.py index 44af74a..de91ad0 100644 --- a/typemap/type_eval/_eval_operators.py +++ b/typemap/type_eval/_eval_operators.py @@ -15,6 +15,7 @@ Attrs, Capitalize, FromUnion, + GenericCallable, GetArg, GetArgs, GetAttr, @@ -38,9 +39,14 @@ def _from_literal(val): - assert _typing_inspect.is_literal(val) - # XXX: check length? - return val.__args__[0] + if _typing_inspect.is_literal(val): + # TODO: check length? + return val.__args__[0] + elif val is type(None): + return None + else: + # TODO: check it is some literal + return val def _eval_literal(val, ctx): @@ -191,9 +197,9 @@ def _eval_IsSubSimilar(lhs, rhs, *, ctx): ################################################################## + def _get_quals(quals_type): # Extract qualifiers from Literal["*", "**", ...] or Never - quals: set[str] = set() if _typing_inspect.is_literal(quals_type): qual_args = typing.get_args(quals_type) return set(qual_args) @@ -207,6 +213,7 @@ class _DummyDefault: def __repr__(self): return "..." + _DUMMY_DEFAULT = _DummyDefault() @@ -258,10 +265,7 @@ def _callable_type_to_signature(callable_type: object) -> inspect.Signature: quals_type = param_args[2] if len(param_args) > 2 else typing.Never # Extract name from Literal[name] or None - if _typing_inspect.is_literal(name_type): - name = typing.get_args(name_type)[0] - else: - name = None + name = _from_literal(name_type) # Extract qualifiers from Literal["*", "**", ...] or Never quals: set[str] = set() @@ -345,13 +349,30 @@ def _is_pos_only(param): def _callable_type_to_method(name, typ): + """Turn a callable type into a method. + + I'm not totally sure if this is worth doing! The main accomplishment + is in how it pretty prints... + """ + + type_params = () + head = typing.get_origin(typ) - # XXX: handle other amounts + if head is GenericCallable: + ttparams, typ = typing.get_args(typ) + type_params = typing.get_args(ttparams) + head = typing.get_origin(typ) + if head is classmethod: + # XXX: handle other amounts cls, params, ret = typing.get_args(typ) # We have to make class positional only if there is some other # positional only argument. Annoying! - pname = "cls" if not any(_is_pos_only(p) for p in typing.get_args(params)) else None + pname = ( + "cls" + if not any(_is_pos_only(p) for p in typing.get_args(params)) + else None + ) cls_param = Param[ typing.Literal[pname], type[cls], @@ -364,7 +385,9 @@ def _callable_type_to_method(name, typ): else: head = lambda x: x - return head(_signature_to_function(name, _callable_type_to_signature(typ))) + func = _signature_to_function(name, _callable_type_to_signature(typ)) + func.__type_params__ = type_params + return head(func) def _function_type(func, *, receiver_type): @@ -414,12 +437,16 @@ def _ann(x): # TODO: Is doing the tuple for staticmethod/classmethod legit? # Putting a list in makes it unhashable... + f: typing.Any if isinstance(func, staticmethod): - return staticmethod[tuple[*params], ret] + f = staticmethod[tuple[*params], ret] elif isinstance(func, classmethod): - return classmethod[specified_receiver, tuple[*params[1:]], ret] + f = classmethod[specified_receiver, tuple[*params[1:]], ret] else: - return typing.Callable[params, ret] + f = typing.Callable[params, ret] + if root.__type_params__: + f = GenericCallable[tuple[*root.__type_params__], f] + return f @type_eval.register_evaluator(Attrs) @@ -683,6 +710,7 @@ def _add_quals(typ, quals): def _is_method_like(typ): return typing.get_origin(typ) in ( + GenericCallable, collections.abc.Callable, staticmethod, classmethod, diff --git a/typemap/typing.py b/typemap/typing.py index 1413cc7..e972ad4 100644 --- a/typemap/typing.py +++ b/typemap/typing.py @@ -15,6 +15,19 @@ class SpecialFormEllipsis: pass +### + + +# We really need to be able to represent generic function types but it +# is a problem for all kinds of reasons... +# Can we bang it into Callable?? +class GenericCallable[ + TVs: tuple[typing.TypeVar, ...], + C: typing.Callable | staticmethod | classmethod, +]: + pass + + ### MemberQuals = typing.Literal["ClassVar", "Final"] From 932bf7ac5a93574674f421fb6ff0c6a9b1e2343c Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Fri, 9 Jan 2026 17:08:49 -0800 Subject: [PATCH 4/4] Add a test for generating get_... methods --- tests/test_schemalike.py | 79 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 tests/test_schemalike.py diff --git a/tests/test_schemalike.py b/tests/test_schemalike.py new file mode 100644 index 0000000..ba793d1 --- /dev/null +++ b/tests/test_schemalike.py @@ -0,0 +1,79 @@ +import textwrap + +from typing import Callable, Literal + +from typemap.type_eval import eval_typing +from typemap.typing import ( + NewProtocol, + Iter, + Attrs, + GetType, + GetName, + Member, + Param, + StrConcat, +) + +from . import format_helper + + +class Schema: + pass + + +class Type: + pass + + +class Expression: + pass + + +# hmmmm... recursion with this sort of thing will be funny... +# how will we handle the decorators or __init_subclass__ or what have you + + +class Property: + name: str + required: bool + multi: bool + typ: Type + expr: Expression | None + + +type Schemaify[T] = NewProtocol[ + *[p for p in Iter[Attrs[T]]], + *[ + Member[ + StrConcat[Literal["get_"], GetName[p]], + Callable[ + [ + Param[Literal["self"], Schemaify[T]], + Param[Literal["schema"], Schema, Literal["keyword"]], + ], + GetType[p], + ], + Literal["ClassVar"], + ] + for p in Iter[Attrs[T]] + ], +] + + +def test_schema_like_1(): + tgt = eval_typing(Schemaify[Property]) + fmt = format_helper.format_class(tgt) + + assert fmt == textwrap.dedent("""\ + class Schemaify[tests.test_schemalike.Property]: + name: str + required: bool + multi: bool + typ: tests.test_schemalike.Type + expr: tests.test_schemalike.Expression | None + def get_name(self: Schemaify[tests.test_schemalike.Property], *, schema: tests.test_schemalike.Schema) -> str: ... + def get_required(self: Schemaify[tests.test_schemalike.Property], *, schema: tests.test_schemalike.Schema) -> bool: ... + def get_multi(self: Schemaify[tests.test_schemalike.Property], *, schema: tests.test_schemalike.Schema) -> bool: ... + def get_typ(self: Schemaify[tests.test_schemalike.Property], *, schema: tests.test_schemalike.Schema) -> tests.test_schemalike.Type: ... + def get_expr(self: Schemaify[tests.test_schemalike.Property], *, schema: tests.test_schemalike.Schema) -> tests.test_schemalike.Expression | None: ... + """)