Skip to content

Commit 526fec3

Browse files
[mypyc] Generate introspection signatures for compiled functions (#19307)
Refs mypyc/mypyc#838 This PR populates `__text_signature__` for compiled functions, making runtime signature introspection possible (i.e. `inspect.signature(func)`). While `__text_signature__` is an undocumented CPython implementation detail, other extension module generators are using it in practice. For example, PyO3 and Cython both support it. I think it would be reasonable for mypyc to support it too. Some function signatures can't be represented by `__text_signature__` (namely, those with complex default arguments). In those cases, no signatures are generated (same as the current behavior).
1 parent b8dd6f3 commit 526fec3

File tree

7 files changed

+358
-9
lines changed

7 files changed

+358
-9
lines changed

mypyc/codegen/emitclass.py

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,9 @@
55
from collections.abc import Mapping
66
from typing import Callable
77

8+
from mypyc.codegen.cstring import c_string_initializer
89
from mypyc.codegen.emit import Emitter, HeaderDeclaration, ReturnHandler
9-
from mypyc.codegen.emitfunc import native_function_header
10+
from mypyc.codegen.emitfunc import native_function_doc_initializer, native_function_header
1011
from mypyc.codegen.emitwrapper import (
1112
generate_bin_op_wrapper,
1213
generate_bool_wrapper,
@@ -21,7 +22,13 @@
2122
)
2223
from mypyc.common import BITMAP_BITS, BITMAP_TYPE, NATIVE_PREFIX, PREFIX, REG_PREFIX
2324
from mypyc.ir.class_ir import ClassIR, VTableEntries
24-
from mypyc.ir.func_ir import FUNC_CLASSMETHOD, FUNC_STATICMETHOD, FuncDecl, FuncIR
25+
from mypyc.ir.func_ir import (
26+
FUNC_CLASSMETHOD,
27+
FUNC_STATICMETHOD,
28+
FuncDecl,
29+
FuncIR,
30+
get_text_signature,
31+
)
2532
from mypyc.ir.rtypes import RTuple, RType, object_rprimitive
2633
from mypyc.namegen import NameGenerator
2734
from mypyc.sametype import is_same_type
@@ -368,6 +375,8 @@ def emit_line() -> None:
368375
flags.append("Py_TPFLAGS_MANAGED_DICT")
369376
fields["tp_flags"] = " | ".join(flags)
370377

378+
fields["tp_doc"] = native_class_doc_initializer(cl)
379+
371380
emitter.emit_line(f"static PyTypeObject {emitter.type_struct_name(cl)}_template_ = {{")
372381
emitter.emit_line("PyVarObject_HEAD_INIT(NULL, 0)")
373382
for field, value in fields.items():
@@ -915,7 +924,8 @@ def generate_methods_table(cl: ClassIR, name: str, emitter: Emitter) -> None:
915924
elif fn.decl.kind == FUNC_CLASSMETHOD:
916925
flags.append("METH_CLASS")
917926

918-
emitter.emit_line(" {}, NULL}},".format(" | ".join(flags)))
927+
doc = native_function_doc_initializer(fn)
928+
emitter.emit_line(" {}, {}}},".format(" | ".join(flags), doc))
919929

920930
# Provide a default __getstate__ and __setstate__
921931
if not cl.has_method("__setstate__") and not cl.has_method("__getstate__"):
@@ -1173,3 +1183,16 @@ def has_managed_dict(cl: ClassIR, emitter: Emitter) -> bool:
11731183
and cl.has_dict
11741184
and cl.builtin_base != "PyBaseExceptionObject"
11751185
)
1186+
1187+
1188+
def native_class_doc_initializer(cl: ClassIR) -> str:
1189+
init_fn = cl.get_method("__init__")
1190+
if init_fn is not None:
1191+
text_sig = get_text_signature(init_fn, bound=True)
1192+
if text_sig is None:
1193+
return "NULL"
1194+
text_sig = text_sig.replace("__init__", cl.name, 1)
1195+
else:
1196+
text_sig = f"{cl.name}()"
1197+
docstring = f"{text_sig}\n--\n\n"
1198+
return c_string_initializer(docstring.encode("ascii", errors="backslashreplace"))

mypyc/codegen/emitfunc.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from typing import Final
66

77
from mypyc.analysis.blockfreq import frequently_executed_blocks
8+
from mypyc.codegen.cstring import c_string_initializer
89
from mypyc.codegen.emit import DEBUG_ERRORS, Emitter, TracebackAndGotoHandler, c_array_initializer
910
from mypyc.common import (
1011
HAVE_IMMORTAL,
@@ -16,7 +17,14 @@
1617
TYPE_VAR_PREFIX,
1718
)
1819
from mypyc.ir.class_ir import ClassIR
19-
from mypyc.ir.func_ir import FUNC_CLASSMETHOD, FUNC_STATICMETHOD, FuncDecl, FuncIR, all_values
20+
from mypyc.ir.func_ir import (
21+
FUNC_CLASSMETHOD,
22+
FUNC_STATICMETHOD,
23+
FuncDecl,
24+
FuncIR,
25+
all_values,
26+
get_text_signature,
27+
)
2028
from mypyc.ir.ops import (
2129
ERR_FALSE,
2230
NAMESPACE_MODULE,
@@ -106,6 +114,14 @@ def native_function_header(fn: FuncDecl, emitter: Emitter) -> str:
106114
)
107115

108116

117+
def native_function_doc_initializer(func: FuncIR) -> str:
118+
text_sig = get_text_signature(func)
119+
if text_sig is None:
120+
return "NULL"
121+
docstring = f"{text_sig}\n--\n\n"
122+
return c_string_initializer(docstring.encode("ascii", errors="backslashreplace"))
123+
124+
109125
def generate_native_function(
110126
fn: FuncIR, emitter: Emitter, source_path: str, module_name: str
111127
) -> None:

mypyc/codegen/emitmodule.py

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,11 @@
3030
from mypyc.codegen.cstring import c_string_initializer
3131
from mypyc.codegen.emit import Emitter, EmitterContext, HeaderDeclaration, c_array_initializer
3232
from mypyc.codegen.emitclass import generate_class, generate_class_reuse, generate_class_type_decl
33-
from mypyc.codegen.emitfunc import generate_native_function, native_function_header
33+
from mypyc.codegen.emitfunc import (
34+
generate_native_function,
35+
native_function_doc_initializer,
36+
native_function_header,
37+
)
3438
from mypyc.codegen.emitwrapper import (
3539
generate_legacy_wrapper_function,
3640
generate_wrapper_function,
@@ -917,11 +921,14 @@ def emit_module_methods(
917921
flag = "METH_FASTCALL"
918922
else:
919923
flag = "METH_VARARGS"
924+
doc = native_function_doc_initializer(fn)
920925
emitter.emit_line(
921926
(
922927
'{{"{name}", (PyCFunction){prefix}{cname}, {flag} | METH_KEYWORDS, '
923-
"NULL /* docstring */}},"
924-
).format(name=name, cname=fn.cname(emitter.names), prefix=PREFIX, flag=flag)
928+
"{doc} /* docstring */}},"
929+
).format(
930+
name=name, cname=fn.cname(emitter.names), prefix=PREFIX, flag=flag, doc=doc
931+
)
925932
)
926933
emitter.emit_line("{NULL, NULL, 0, NULL}")
927934
emitter.emit_line("};")

mypyc/doc/differences_from_python.rst

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -316,7 +316,8 @@ non-exhaustive list of what won't work:
316316
- Instance ``__annotations__`` is usually not kept
317317
- Frames of compiled functions can't be inspected using ``inspect``
318318
- Compiled methods aren't considered methods by ``inspect.ismethod``
319-
- ``inspect.signature`` chokes on compiled functions
319+
- ``inspect.signature`` chokes on compiled functions with default arguments that
320+
are not simple literals
320321
- ``inspect.iscoroutinefunction`` and ``asyncio.iscoroutinefunction`` will always return False for compiled functions, even those defined with `async def`
321322

322323
Profiling hooks and tracing

mypyc/ir/func_ir.py

Lines changed: 95 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from __future__ import annotations
44

5+
import inspect
56
from collections.abc import Sequence
67
from typing import Final
78

@@ -11,13 +12,24 @@
1112
Assign,
1213
AssignMulti,
1314
BasicBlock,
15+
Box,
1416
ControlOp,
1517
DeserMaps,
18+
Float,
19+
Integer,
1620
LoadAddress,
21+
LoadLiteral,
1722
Register,
23+
TupleSet,
1824
Value,
1925
)
20-
from mypyc.ir.rtypes import RType, bitmap_rprimitive, deserialize_type
26+
from mypyc.ir.rtypes import (
27+
RType,
28+
bitmap_rprimitive,
29+
deserialize_type,
30+
is_bool_rprimitive,
31+
is_none_rprimitive,
32+
)
2133
from mypyc.namegen import NameGenerator
2234

2335

@@ -379,3 +391,85 @@ def all_values_full(args: list[Register], blocks: list[BasicBlock]) -> list[Valu
379391
values.append(op)
380392

381393
return values
394+
395+
396+
_ARG_KIND_TO_INSPECT: Final = {
397+
ArgKind.ARG_POS: inspect.Parameter.POSITIONAL_OR_KEYWORD,
398+
ArgKind.ARG_OPT: inspect.Parameter.POSITIONAL_OR_KEYWORD,
399+
ArgKind.ARG_STAR: inspect.Parameter.VAR_POSITIONAL,
400+
ArgKind.ARG_NAMED: inspect.Parameter.KEYWORD_ONLY,
401+
ArgKind.ARG_STAR2: inspect.Parameter.VAR_KEYWORD,
402+
ArgKind.ARG_NAMED_OPT: inspect.Parameter.KEYWORD_ONLY,
403+
}
404+
405+
# Sentinel indicating a value that cannot be represented in a text signature.
406+
_NOT_REPRESENTABLE = object()
407+
408+
409+
def get_text_signature(fn: FuncIR, *, bound: bool = False) -> str | None:
410+
"""Return a text signature in CPython's internal doc format, or None
411+
if the function's signature cannot be represented.
412+
"""
413+
parameters = []
414+
mark_self = (fn.class_name is not None) and (fn.decl.kind != FUNC_STATICMETHOD) and not bound
415+
sig = fn.decl.bound_sig if bound and fn.decl.bound_sig is not None else fn.decl.sig
416+
# Pre-scan for end of positional-only parameters.
417+
# This is needed to handle signatures like 'def foo(self, __x)', where mypy
418+
# currently sees 'self' as being positional-or-keyword and '__x' as positional-only.
419+
pos_only_idx = -1
420+
for idx, arg in enumerate(sig.args):
421+
if arg.pos_only and arg.kind in (ArgKind.ARG_POS, ArgKind.ARG_OPT):
422+
pos_only_idx = idx
423+
for idx, arg in enumerate(sig.args):
424+
if arg.name.startswith(("__bitmap", "__mypyc")):
425+
continue
426+
kind = (
427+
inspect.Parameter.POSITIONAL_ONLY
428+
if idx <= pos_only_idx
429+
else _ARG_KIND_TO_INSPECT[arg.kind]
430+
)
431+
default: object = inspect.Parameter.empty
432+
if arg.optional:
433+
default = _find_default_argument(arg.name, fn.blocks)
434+
if default is _NOT_REPRESENTABLE:
435+
# This default argument cannot be represented in a __text_signature__
436+
return None
437+
438+
curr_param = inspect.Parameter(arg.name, kind, default=default)
439+
parameters.append(curr_param)
440+
if mark_self:
441+
# Parameter.__init__/Parameter.replace do not accept $
442+
curr_param._name = f"${arg.name}" # type: ignore[attr-defined]
443+
mark_self = False
444+
return f"{fn.name}{inspect.Signature(parameters)}"
445+
446+
447+
def _find_default_argument(name: str, blocks: list[BasicBlock]) -> object:
448+
# Find assignment inserted by gen_arg_defaults. Assumed to be the first assignment.
449+
for block in blocks:
450+
for op in block.ops:
451+
if isinstance(op, Assign) and op.dest.name == name:
452+
return _extract_python_literal(op.src)
453+
return _NOT_REPRESENTABLE
454+
455+
456+
def _extract_python_literal(value: Value) -> object:
457+
if isinstance(value, Integer):
458+
if is_none_rprimitive(value.type):
459+
return None
460+
val = value.numeric_value()
461+
if is_bool_rprimitive(value.type):
462+
return bool(val)
463+
return val
464+
elif isinstance(value, Float):
465+
return value.value
466+
elif isinstance(value, LoadLiteral):
467+
return value.value
468+
elif isinstance(value, Box):
469+
return _extract_python_literal(value.src)
470+
elif isinstance(value, TupleSet):
471+
items = tuple(_extract_python_literal(item) for item in value.items)
472+
if any(itm is _NOT_REPRESENTABLE for itm in items):
473+
return _NOT_REPRESENTABLE
474+
return items
475+
return _NOT_REPRESENTABLE

0 commit comments

Comments
 (0)