Skip to content

Commit 92327e6

Browse files
committed
Generate introspection signatures for compiled functions
1 parent 5081c59 commit 92327e6

File tree

7 files changed

+242
-8
lines changed

7 files changed

+242
-8
lines changed

mypyc/codegen/emitclass.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from typing import Callable
77

88
from mypyc.codegen.emit import Emitter, HeaderDeclaration, ReturnHandler
9-
from mypyc.codegen.emitfunc import native_function_header
9+
from mypyc.codegen.emitfunc import native_function_doc_initializer, native_function_header
1010
from mypyc.codegen.emitwrapper import (
1111
generate_bin_op_wrapper,
1212
generate_bool_wrapper,
@@ -841,7 +841,8 @@ def generate_methods_table(cl: ClassIR, name: str, emitter: Emitter) -> None:
841841
elif fn.decl.kind == FUNC_CLASSMETHOD:
842842
flags.append("METH_CLASS")
843843

844-
emitter.emit_line(" {}, NULL}},".format(" | ".join(flags)))
844+
doc = native_function_doc_initializer(fn)
845+
emitter.emit_line(" {}, {}}},".format(" | ".join(flags), doc))
845846

846847
# Provide a default __getstate__ and __setstate__
847848
if not cl.has_method("__setstate__") and not cl.has_method("__getstate__"):

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,
@@ -105,6 +113,14 @@ def native_function_header(fn: FuncDecl, emitter: Emitter) -> str:
105113
)
106114

107115

116+
def native_function_doc_initializer(func: FuncIR) -> str:
117+
text_sig = get_text_signature(func)
118+
if text_sig is None:
119+
return "NULL"
120+
docstring = f"{text_sig}\n--\n\n"
121+
return c_string_initializer(docstring.encode("ascii", errors="backslashreplace"))
122+
123+
108124
def generate_native_function(
109125
fn: FuncIR, emitter: Emitter, source_path: str, module_name: str
110126
) -> 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_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,
@@ -915,11 +919,14 @@ def emit_module_methods(
915919
flag = "METH_FASTCALL"
916920
else:
917921
flag = "METH_VARARGS"
922+
doc = native_function_doc_initializer(fn)
918923
emitter.emit_line(
919924
(
920925
'{{"{name}", (PyCFunction){prefix}{cname}, {flag} | METH_KEYWORDS, '
921-
"NULL /* docstring */}},"
922-
).format(name=name, cname=fn.cname(emitter.names), prefix=PREFIX, flag=flag)
926+
"{doc} /* docstring */}},"
927+
).format(
928+
name=name, cname=fn.cname(emitter.names), prefix=PREFIX, flag=flag, doc=doc
929+
)
923930
)
924931
emitter.emit_line("{NULL, NULL, 0, NULL}")
925932
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

321322
Profiling hooks and tracing
322323
***************************

mypyc/ir/func_ir.py

Lines changed: 84 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,74 @@ 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) -> 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
415+
for arg in fn.decl.sig.args:
416+
if arg.name.startswith("__bitmap") or arg.name == "__mypyc_self__":
417+
continue
418+
kind = (
419+
inspect.Parameter.POSITIONAL_ONLY if arg.pos_only else _ARG_KIND_TO_INSPECT[arg.kind]
420+
)
421+
default: object = inspect.Parameter.empty
422+
if arg.optional:
423+
default = _find_default_argument(arg.name, fn.blocks)
424+
if default is _NOT_REPRESENTABLE:
425+
# This default argument cannot be represented in a __text_signature__
426+
return None
427+
428+
curr_param = inspect.Parameter(arg.name, kind, default=default)
429+
parameters.append(curr_param)
430+
if mark_self:
431+
# Parameter.__init__ does not accept $
432+
curr_param._name = f"${arg.name}" # type: ignore[attr-defined]
433+
mark_self = False
434+
sig = inspect.Signature(parameters)
435+
return f"{fn.name}{sig}"
436+
437+
438+
def _find_default_argument(name: str, blocks: list[BasicBlock]) -> object:
439+
# Find assignment inserted by gen_arg_defaults. Assumed to be the first assignment.
440+
for block in blocks:
441+
for op in block.ops:
442+
if isinstance(op, Assign) and op.dest.name == name:
443+
return _extract_python_literal(op.src)
444+
return _NOT_REPRESENTABLE
445+
446+
447+
def _extract_python_literal(value: Value) -> object:
448+
if isinstance(value, Integer):
449+
if is_none_rprimitive(value.type):
450+
return None
451+
val = value.numeric_value()
452+
return bool(val) if is_bool_rprimitive(value.type) else val
453+
elif isinstance(value, Float):
454+
return value.value
455+
elif isinstance(value, LoadLiteral):
456+
return value.value
457+
elif isinstance(value, Box):
458+
return _extract_python_literal(value.src)
459+
elif isinstance(value, TupleSet):
460+
items = tuple(_extract_python_literal(item) for item in value.items)
461+
if any(itm is _NOT_REPRESENTABLE for itm in items):
462+
return _NOT_REPRESENTABLE
463+
return items
464+
return _NOT_REPRESENTABLE
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
[case testSignaturesBasic]
2+
import inspect
3+
4+
def f1(): pass
5+
def f2(x): pass
6+
def f3(x, /): pass
7+
def f4(*, x): pass
8+
def f5(*x): pass
9+
def f6(**x): pass
10+
def f7(x=None): pass
11+
def f8(x=None, /): pass
12+
def f9(*, x=None): pass
13+
def f10(a, /, b, c=None, *args, d=None, **h): pass
14+
15+
def test_basic() -> None:
16+
assert str(inspect.signature(f1)) == "()"
17+
assert str(inspect.signature(f2)) == "(x)"
18+
assert str(inspect.signature(f3)) == "(x, /)"
19+
assert str(inspect.signature(f4)) == "(*, x)"
20+
assert str(inspect.signature(f5)) == "(*x)"
21+
assert str(inspect.signature(f6)) == "(**x)"
22+
assert str(inspect.signature(f7)) == "(x=None)"
23+
assert str(inspect.signature(f8)) == "(x=None, /)"
24+
assert str(inspect.signature(f9)) == "(*, x=None)"
25+
assert str(inspect.signature(f10)) == "(a, /, b, c=None, *args, d=None, **h)"
26+
27+
[case testSignaturesValidDefaults]
28+
import inspect
29+
30+
def default_int(x=1): pass
31+
def default_str(x="a"): pass
32+
def default_float(x=1.0): pass
33+
def default_true(x=True): pass
34+
def default_false(x=False): pass
35+
def default_none(x=None): pass
36+
def default_tuple_empty(x=()): pass
37+
def default_tuple_literals(x=(1, "a", 1.0, False, True, None, (), (1,2,(3,4)))): pass
38+
def default_tuple_singleton(x=(1,)): pass
39+
40+
def test_valid_defaults() -> None:
41+
assert str(inspect.signature(default_int)) == "(x=1)"
42+
assert str(inspect.signature(default_str)) == "(x='a')"
43+
assert str(inspect.signature(default_float)) == "(x=1.0)"
44+
assert str(inspect.signature(default_true)) == "(x=True)"
45+
assert str(inspect.signature(default_false)) == "(x=False)"
46+
assert str(inspect.signature(default_none)) == "(x=None)"
47+
assert str(inspect.signature(default_tuple_empty)) == "(x=())"
48+
assert str(inspect.signature(default_tuple_literals)) == "(x=(1, 'a', 1.0, False, True, None, (), (1, 2, (3, 4))))"
49+
50+
# Check __text_signature__ directly since inspect.signature produces
51+
# an incorrect signature for 1-tuple default arguments prior to
52+
# Python 3.12 (cpython#102379).
53+
# assert str(inspect.signature(default_tuple_singleton)) == "(x=(1,))"
54+
assert getattr(default_tuple_singleton, "__text_signature__") == "(x=(1,))"
55+
56+
[case testSignaturesStringDefaults]
57+
import inspect
58+
59+
def f1(x="'foo"): pass
60+
def f2(x='"foo'): pass
61+
def f3(x=""""Isn\'t," they said."""): pass
62+
def f4(x="\\ \a \b \f \n \r \t \v \x00"): pass
63+
def f5(x="\N{BANANA}sv"): pass
64+
65+
def test_string_defaults() -> None:
66+
assert str(inspect.signature(f1)) == """(x="'foo")"""
67+
assert str(inspect.signature(f2)) == """(x='"foo')"""
68+
assert str(inspect.signature(f3)) == r"""(x='"Isn\'t," they said.')"""
69+
assert str(inspect.signature(f4)) == r"""(x='\\ \x07 \x08 \x0c \n \r \t \x0b \x00')"""
70+
assert str(inspect.signature(f5)) == """(x='\U0001F34Csv')"""
71+
72+
[case testSignaturesIrrepresentableDefaults]
73+
import inspect
74+
from typing import Any
75+
76+
from testutil import assertRaises
77+
78+
def bad1(x=[]): pass
79+
def bad2(x={}): pass
80+
def bad3(x=set()): pass
81+
def bad4(x=int): pass
82+
def bad5(x=lambda: None): pass
83+
def bad6(x=bad1): pass
84+
# note: inspect supports constant folding for defaults in text signatures
85+
def bad7(x=1+2): pass
86+
def bad8(x=1-2): pass
87+
def bad9(x=1|2): pass
88+
def bad10(x=float("nan")): pass
89+
def bad11(x=([],)): pass
90+
91+
def test_irrepresentable_defaults() -> None:
92+
bad: Any
93+
for bad in [bad1, bad2, bad3, bad4, bad5, bad6, bad7, bad8, bad9, bad10, bad11]:
94+
assert bad.__text_signature__ is None, f"{bad.__name__} has unexpected __text_signature__"
95+
with assertRaises(ValueError, "no signature found for builtin"):
96+
inspect.signature(bad)
97+
98+
[case testSignaturesMethods]
99+
import inspect
100+
101+
class Foo:
102+
def f1(self, x): pass
103+
@classmethod
104+
def f2(cls, x): pass
105+
@staticmethod
106+
def f3(x): pass
107+
108+
def test_methods() -> None:
109+
assert getattr(Foo.f1, "__text_signature__") == "($self, x)"
110+
assert str(inspect.signature(Foo.f1)) == "(self, /, x)"
111+
112+
assert getattr(Foo.f2, "__text_signature__") == "($cls, x)"
113+
assert str(inspect.signature(Foo.f2)) == "(x)"
114+
115+
assert getattr(Foo.f3, "__text_signature__") == "(x)"
116+
assert str(inspect.signature(Foo.f3)) == "(x)"
117+
118+
assert getattr(Foo().f1, "__text_signature__") == "($self, x)"
119+
assert str(inspect.signature(Foo().f1)) == "(x)"
120+
121+
assert getattr(Foo().f2, "__text_signature__") == "($cls, x)"
122+
assert str(inspect.signature(Foo().f2)) == "(x)"
123+
124+
assert getattr(Foo().f3, "__text_signature__") == "(x)"
125+
assert str(inspect.signature(Foo().f3)) == "(x)"

mypyc/test/test_run.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@
7171
"run-attrs.test",
7272
"run-python37.test",
7373
"run-python38.test",
74+
"run-signatures.test",
7475
]
7576

7677
if sys.version_info >= (3, 10):

0 commit comments

Comments
 (0)