Skip to content

Commit 3db5920

Browse files
committed
[mypyc] Generate introspection signatures
1 parent 5081c59 commit 3db5920

File tree

6 files changed

+227
-7
lines changed

6 files changed

+227
-7
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/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 = [_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 tuple(items)
464+
return _NOT_REPRESENTABLE
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
[case testSignaturesBasic]
2+
import inspect
3+
4+
def f1(a, /, b, c=None, *args, d=None, **h): pass
5+
def f2(a=None, /): pass
6+
def f3(*, a): pass
7+
def f4(): pass
8+
9+
def test_basic() -> None:
10+
assert str(inspect.signature(f1)) == "(a, /, b, c=None, *args, d=None, **h)"
11+
assert str(inspect.signature(f2)) == "(a=None, /)"
12+
assert str(inspect.signature(f3)) == "(*, a)"
13+
assert str(inspect.signature(f4)) == "()"
14+
15+
[case testSignaturesValidDefaults]
16+
import inspect
17+
18+
def valid_defaults(
19+
int=1,
20+
str="a",
21+
float=1.0,
22+
false=False,
23+
true=True,
24+
none=None,
25+
empty_tuple=(),
26+
misc_tuple=(1, "a", 1.0, False, True, None, (), (1,2,(3,4))),
27+
): pass
28+
29+
def valid_tuple_singleton(x=(1,)): pass
30+
31+
def test_valid_defaults() -> None:
32+
assert (
33+
str(inspect.signature(valid_defaults))
34+
== "(int=1, str='a', float=1.0, false=False, true=True, none=None, "
35+
"empty_tuple=(), misc_tuple=(1, 'a', 1.0, False, True, None, (), (1, 2, (3, 4))))"
36+
)
37+
38+
# Check __text_signature__ directly since inspect.signature produces
39+
# an incorrect signature for 1-tuple default arguments prior to
40+
# Python 3.12 (cpython#102379).
41+
assert getattr(valid_tuple_singleton, "__text_signature__") == "(x=(1,))"
42+
43+
[case testSignaturesStringDefaults]
44+
import inspect
45+
46+
def f1(x="'foo"): pass
47+
def f2(x='"foo'): pass
48+
def f3(x=""""Isn\'t," they said."""): pass
49+
def f4(x="\\ \a \b \f \n \r \t \v \x00"): pass
50+
def f5(x="\U0001F34Csv"): pass
51+
52+
def test_string_defaults() -> None:
53+
assert str(inspect.signature(f1)) == """(x="'foo")"""
54+
assert str(inspect.signature(f2)) == """(x='"foo')"""
55+
assert str(inspect.signature(f3)) == r"""(x='"Isn\'t," they said.')"""
56+
assert str(inspect.signature(f4)) == r"""(x='\\ \x07 \x08 \x0c \n \r \t \x0b \x00')"""
57+
assert str(inspect.signature(f5)) == """(x='\U0001F34Csv')"""
58+
59+
[case testSignaturesIrrepresentableDefaults]
60+
import inspect
61+
from typing import Any
62+
63+
from testutil import assertRaises
64+
65+
def bad1(x=[]): pass
66+
def bad2(x={}): pass
67+
def bad3(x=set()): pass
68+
def bad4(x=int): pass
69+
def bad5(x=lambda: None): pass
70+
def bad6(x=bad1): pass
71+
# note: inspect supports constant folding for defaults in text signatures
72+
def bad7(x=1+2): pass
73+
def bad8(x=1-2): pass
74+
def bad9(x=1|2): pass
75+
def bad10(x=float("nan")): pass
76+
77+
def test_irrepresentable_defaults() -> None:
78+
bad: Any
79+
for bad in [bad1, bad2, bad3, bad4, bad5, bad6, bad7, bad8, bad9, bad10]:
80+
assert bad.__text_signature__ is None, f"{bad.__name__} has unexpected __text_signature__"
81+
with assertRaises(ValueError, "no signature found for builtin"):
82+
inspect.signature(bad)
83+
84+
85+
[case testSignaturesMethods]
86+
import inspect
87+
88+
class Foo:
89+
def f1(self, x): pass
90+
@classmethod
91+
def f2(cls, x): pass
92+
@staticmethod
93+
def f3(x): pass
94+
95+
def test_methods() -> None:
96+
assert getattr(Foo.f1, "__text_signature__") == "($self, x)"
97+
assert str(inspect.signature(Foo.f1)) == "(self, /, x)"
98+
99+
assert getattr(Foo.f2, "__text_signature__") == "($cls, x)"
100+
assert str(inspect.signature(Foo.f2)) == "(x)"
101+
102+
assert getattr(Foo.f3, "__text_signature__") == "(x)"
103+
assert str(inspect.signature(Foo.f3)) == "(x)"
104+
105+
assert getattr(Foo().f1, "__text_signature__") == "($self, x)"
106+
assert str(inspect.signature(Foo().f1)) == "(x)"
107+
108+
assert getattr(Foo().f2, "__text_signature__") == "($cls, x)"
109+
assert str(inspect.signature(Foo().f2)) == "(x)"
110+
111+
assert getattr(Foo().f3, "__text_signature__") == "(x)"
112+
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)