Skip to content

[mypyc] Generate introspection signatures for compiled functions #19307

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 4 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions mypyc/codegen/emitclass.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
from typing import Callable

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

emitter.emit_line(" {}, NULL}},".format(" | ".join(flags)))
doc = native_function_doc_initializer(fn)
emitter.emit_line(" {}, {}}},".format(" | ".join(flags), doc))

# Provide a default __getstate__ and __setstate__
if not cl.has_method("__setstate__") and not cl.has_method("__getstate__"):
Expand Down
18 changes: 17 additions & 1 deletion mypyc/codegen/emitfunc.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from typing import Final

from mypyc.analysis.blockfreq import frequently_executed_blocks
from mypyc.codegen.cstring import c_string_initializer
from mypyc.codegen.emit import DEBUG_ERRORS, Emitter, TracebackAndGotoHandler, c_array_initializer
from mypyc.common import (
HAVE_IMMORTAL,
Expand All @@ -16,7 +17,14 @@
TYPE_VAR_PREFIX,
)
from mypyc.ir.class_ir import ClassIR
from mypyc.ir.func_ir import FUNC_CLASSMETHOD, FUNC_STATICMETHOD, FuncDecl, FuncIR, all_values
from mypyc.ir.func_ir import (
FUNC_CLASSMETHOD,
FUNC_STATICMETHOD,
FuncDecl,
FuncIR,
all_values,
get_text_signature,
)
from mypyc.ir.ops import (
ERR_FALSE,
NAMESPACE_MODULE,
Expand Down Expand Up @@ -105,6 +113,14 @@ def native_function_header(fn: FuncDecl, emitter: Emitter) -> str:
)


def native_function_doc_initializer(func: FuncIR) -> str:
text_sig = get_text_signature(func)
if text_sig is None:
return "NULL"
docstring = f"{text_sig}\n--\n\n"
return c_string_initializer(docstring.encode("ascii", errors="backslashreplace"))


def generate_native_function(
fn: FuncIR, emitter: Emitter, source_path: str, module_name: str
) -> None:
Expand Down
13 changes: 10 additions & 3 deletions mypyc/codegen/emitmodule.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,11 @@
from mypyc.codegen.cstring import c_string_initializer
from mypyc.codegen.emit import Emitter, EmitterContext, HeaderDeclaration, c_array_initializer
from mypyc.codegen.emitclass import generate_class, generate_class_type_decl
from mypyc.codegen.emitfunc import generate_native_function, native_function_header
from mypyc.codegen.emitfunc import (
generate_native_function,
native_function_doc_initializer,
native_function_header,
)
from mypyc.codegen.emitwrapper import (
generate_legacy_wrapper_function,
generate_wrapper_function,
Expand Down Expand Up @@ -915,11 +919,14 @@ def emit_module_methods(
flag = "METH_FASTCALL"
else:
flag = "METH_VARARGS"
doc = native_function_doc_initializer(fn)
emitter.emit_line(
(
'{{"{name}", (PyCFunction){prefix}{cname}, {flag} | METH_KEYWORDS, '
"NULL /* docstring */}},"
).format(name=name, cname=fn.cname(emitter.names), prefix=PREFIX, flag=flag)
"{doc} /* docstring */}},"
).format(
name=name, cname=fn.cname(emitter.names), prefix=PREFIX, flag=flag, doc=doc
)
)
emitter.emit_line("{NULL, NULL, 0, NULL}")
emitter.emit_line("};")
Expand Down
3 changes: 2 additions & 1 deletion mypyc/doc/differences_from_python.rst
Original file line number Diff line number Diff line change
Expand Up @@ -316,7 +316,8 @@ non-exhaustive list of what won't work:
- Instance ``__annotations__`` is usually not kept
- Frames of compiled functions can't be inspected using ``inspect``
- Compiled methods aren't considered methods by ``inspect.ismethod``
- ``inspect.signature`` chokes on compiled functions
- ``inspect.signature`` chokes on compiled functions with default arguments that
are not simple literals

Profiling hooks and tracing
***************************
Expand Down
96 changes: 95 additions & 1 deletion mypyc/ir/func_ir.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from __future__ import annotations

import inspect
from collections.abc import Sequence
from typing import Final

Expand All @@ -11,13 +12,24 @@
Assign,
AssignMulti,
BasicBlock,
Box,
ControlOp,
DeserMaps,
Float,
Integer,
LoadAddress,
LoadLiteral,
Register,
TupleSet,
Value,
)
from mypyc.ir.rtypes import RType, bitmap_rprimitive, deserialize_type
from mypyc.ir.rtypes import (
RType,
bitmap_rprimitive,
deserialize_type,
is_bool_rprimitive,
is_none_rprimitive,
)
from mypyc.namegen import NameGenerator


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

return values


_ARG_KIND_TO_INSPECT: Final = {
ArgKind.ARG_POS: inspect.Parameter.POSITIONAL_OR_KEYWORD,
ArgKind.ARG_OPT: inspect.Parameter.POSITIONAL_OR_KEYWORD,
ArgKind.ARG_STAR: inspect.Parameter.VAR_POSITIONAL,
ArgKind.ARG_NAMED: inspect.Parameter.KEYWORD_ONLY,
ArgKind.ARG_STAR2: inspect.Parameter.VAR_KEYWORD,
ArgKind.ARG_NAMED_OPT: inspect.Parameter.KEYWORD_ONLY,
}

# Sentinel indicating a value that cannot be represented in a text signature.
_NOT_REPRESENTABLE = object()


def get_text_signature(fn: FuncIR) -> str | None:
"""Return a text signature in CPython's internal doc format, or None
if the function's signature cannot be represented.
"""
parameters = []
mark_self = fn.class_name is not None and fn.decl.kind != FUNC_STATICMETHOD
# Pre-scan for end of positional-only parameters.
# This is needed to handle signatures like 'def foo(self, __x)', where mypy
# currently sees 'self' as being positional-or-keyword and '__x' as positional-only.
pos_only_idx = -1
for idx, arg in enumerate(fn.decl.sig.args):
if arg.pos_only and arg.kind in (ArgKind.ARG_POS, ArgKind.ARG_OPT):
pos_only_idx = idx
for idx, arg in enumerate(fn.decl.sig.args):
if arg.name.startswith("__bitmap") or arg.name == "__mypyc_self__":
continue
kind = (
inspect.Parameter.POSITIONAL_ONLY
if idx <= pos_only_idx
else _ARG_KIND_TO_INSPECT[arg.kind]
)
default: object = inspect.Parameter.empty
if arg.optional:
default = _find_default_argument(arg.name, fn.blocks)
if default is _NOT_REPRESENTABLE:
# This default argument cannot be represented in a __text_signature__
return None

curr_param = inspect.Parameter(arg.name, kind, default=default)
parameters.append(curr_param)
if mark_self:
# Parameter.__init__/Parameter.replace do not accept $
curr_param._name = f"${arg.name}" # type: ignore[attr-defined]
mark_self = False
sig = inspect.Signature(parameters)
return f"{fn.name}{sig}"


def _find_default_argument(name: str, blocks: list[BasicBlock]) -> object:
# Find assignment inserted by gen_arg_defaults. Assumed to be the first assignment.
for block in blocks:
for op in block.ops:
if isinstance(op, Assign) and op.dest.name == name:
return _extract_python_literal(op.src)
return _NOT_REPRESENTABLE


def _extract_python_literal(value: Value) -> object:
if isinstance(value, Integer):
if is_none_rprimitive(value.type):
return None
val = value.numeric_value()
if is_bool_rprimitive(value.type):
return bool(val)
return val
elif isinstance(value, Float):
return value.value
elif isinstance(value, LoadLiteral):
return value.value
elif isinstance(value, Box):
return _extract_python_literal(value.src)
elif isinstance(value, TupleSet):
items = tuple(_extract_python_literal(item) for item in value.items)
if any(itm is _NOT_REPRESENTABLE for itm in items):
return _NOT_REPRESENTABLE
return items
return _NOT_REPRESENTABLE
150 changes: 150 additions & 0 deletions mypyc/test-data/run-signatures.test
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
[case testSignaturesBasic]
import inspect

def f1(): pass
def f2(x): pass
def f3(x, /): pass
def f4(*, x): pass
def f5(*x): pass
def f6(**x): pass
def f7(x=None): pass
def f8(x=None, /): pass
def f9(*, x=None): pass
def f10(a, /, b, c=None, *args, d=None, **h): pass

def test_basic() -> None:
assert str(inspect.signature(f1)) == "()"
assert str(inspect.signature(f2)) == "(x)"
assert str(inspect.signature(f3)) == "(x, /)"
assert str(inspect.signature(f4)) == "(*, x)"
assert str(inspect.signature(f5)) == "(*x)"
assert str(inspect.signature(f6)) == "(**x)"
assert str(inspect.signature(f7)) == "(x=None)"
assert str(inspect.signature(f8)) == "(x=None, /)"
assert str(inspect.signature(f9)) == "(*, x=None)"
assert str(inspect.signature(f10)) == "(a, /, b, c=None, *args, d=None, **h)"

[case testSignaturesValidDefaults]
import inspect

def default_int(x=1): pass
def default_str(x="a"): pass
def default_float(x=1.0): pass
def default_true(x=True): pass
def default_false(x=False): pass
def default_none(x=None): pass
def default_tuple_empty(x=()): pass
def default_tuple_literals(x=(1, "a", 1.0, False, True, None, (), (1,2,(3,4)))): pass
def default_tuple_singleton(x=(1,)): pass

def test_valid_defaults() -> None:
assert str(inspect.signature(default_int)) == "(x=1)"
assert str(inspect.signature(default_str)) == "(x='a')"
assert str(inspect.signature(default_float)) == "(x=1.0)"
assert str(inspect.signature(default_true)) == "(x=True)"
assert str(inspect.signature(default_false)) == "(x=False)"
assert str(inspect.signature(default_none)) == "(x=None)"
assert str(inspect.signature(default_tuple_empty)) == "(x=())"
assert str(inspect.signature(default_tuple_literals)) == "(x=(1, 'a', 1.0, False, True, None, (), (1, 2, (3, 4))))"

# Check __text_signature__ directly since inspect.signature produces
# an incorrect signature for 1-tuple default arguments prior to
# Python 3.12 (cpython#102379).
# assert str(inspect.signature(default_tuple_singleton)) == "(x=(1,))"
assert getattr(default_tuple_singleton, "__text_signature__") == "(x=(1,))"

[case testSignaturesStringDefaults]
import inspect

def f1(x="'foo"): pass
def f2(x='"foo'): pass
def f3(x=""""Isn\'t," they said."""): pass
def f4(x="\\ \a \b \f \n \r \t \v \x00"): pass
def f5(x="\N{BANANA}sv"): pass

def test_string_defaults() -> None:
assert str(inspect.signature(f1)) == """(x="'foo")"""
assert str(inspect.signature(f2)) == """(x='"foo')"""
assert str(inspect.signature(f3)) == r"""(x='"Isn\'t," they said.')"""
assert str(inspect.signature(f4)) == r"""(x='\\ \x07 \x08 \x0c \n \r \t \x0b \x00')"""
assert str(inspect.signature(f5)) == """(x='\U0001F34Csv')"""

[case testSignaturesIrrepresentableDefaults]
import inspect
from typing import Any

from testutil import assertRaises

def bad1(x=[]): pass
def bad2(x={}): pass
def bad3(x=set()): pass
def bad4(x=int): pass
def bad5(x=lambda: None): pass
def bad6(x=bad1): pass
# note: inspect supports constant folding for defaults in text signatures
def bad7(x=1+2): pass
def bad8(x=1-2): pass
def bad9(x=1|2): pass
def bad10(x=float("nan")): pass
def bad11(x=([],)): pass

def test_irrepresentable_defaults() -> None:
bad: Any
for bad in [bad1, bad2, bad3, bad4, bad5, bad6, bad7, bad8, bad9, bad10, bad11]:
assert bad.__text_signature__ is None, f"{bad.__name__} has unexpected __text_signature__"
with assertRaises(ValueError, "no signature found for builtin"):
inspect.signature(bad)

[case testSignaturesMethods]
import inspect

class Foo:
def f1(self, x): pass
@classmethod
def f2(cls, x): pass
@staticmethod
def f3(x): pass
def __eq__(self, x: object): pass

def test_methods() -> None:
assert getattr(Foo.f1, "__text_signature__") == "($self, x)"
assert getattr(Foo().f1, "__text_signature__") == "($self, x)"
assert str(inspect.signature(Foo.f1)) == "(self, /, x)"
assert str(inspect.signature(Foo().f1)) == "(x)"

assert getattr(Foo.f2, "__text_signature__") == "($cls, x)"
assert getattr(Foo().f2, "__text_signature__") == "($cls, x)"
assert str(inspect.signature(Foo.f2)) == "(x)"
assert str(inspect.signature(Foo().f2)) == "(x)"

assert getattr(Foo.f3, "__text_signature__") == "(x)"
assert getattr(Foo().f3, "__text_signature__") == "(x)"
assert str(inspect.signature(Foo.f3)) == "(x)"
assert str(inspect.signature(Foo().f3)) == "(x)"

assert getattr(Foo.__eq__, "__text_signature__") == "($self, value, /)"
assert getattr(Foo().__eq__, "__text_signature__") == "($self, value, /)"
assert str(inspect.signature(Foo.__eq__)) == "(self, value, /)"
assert str(inspect.signature(Foo().__eq__)) == "(value, /)"

[case testSignaturesHistoricalPositionalOnly]
import inspect

def f1(__x): pass
def f2(__x, y): pass
def f3(*, __y): pass
def f4(x, *, __y): pass
def f5(__x, *, __y): pass

class A:
def func(self, __x): pass

def test_historical_positional_only() -> None:
assert str(inspect.signature(f1)) == "(__x, /)"
assert str(inspect.signature(f2)) == "(__x, /, y)"
assert str(inspect.signature(f3)) == "(*, __y)"
assert str(inspect.signature(f4)) == "(x, *, __y)"
assert str(inspect.signature(f5)) == "(__x, /, *, __y)"

assert str(inspect.signature(A.func)) == "(self, __x, /)"
assert str(inspect.signature(A().func)) == "(__x, /)"
1 change: 1 addition & 0 deletions mypyc/test/test_run.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@
"run-attrs.test",
"run-python37.test",
"run-python38.test",
"run-signatures.test",
]

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