Skip to content

Commit

Permalink
Adding minimal support for Cython functions
Browse files Browse the repository at this point in the history
Minimal support for Cython functions in stubgen for generating .pyi
files from a C module. Based on python#7542.
  • Loading branch information
pbotros committed Apr 5, 2020
1 parent f4351ba commit 4d3dd92
Show file tree
Hide file tree
Showing 3 changed files with 133 additions and 21 deletions.
104 changes: 84 additions & 20 deletions mypy/stubgenc.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@
import inspect
import os.path
import re
from typing import List, Dict, Tuple, Optional, Mapping, Any, Set
from types import ModuleType
from typing import List, Dict, Tuple, Optional, Mapping, Any, Set, ClassVar, Union, Callable, cast

from mypy.moduleinspect import is_c_module
from mypy.stubdoc import (
Expand Down Expand Up @@ -92,7 +92,7 @@ def add_typing_import(output: List[str]) -> List[str]:


def is_c_function(obj: object) -> bool:
return inspect.isbuiltin(obj) or type(obj) is type(ord)
return inspect.isbuiltin(obj) or type(obj) is type(ord) or type(obj).__name__ == 'cython_function_or_method'


def is_c_method(obj: object) -> bool:
Expand Down Expand Up @@ -139,24 +139,12 @@ def generate_c_function_stub(module: ModuleType,
if class_sigs is None:
class_sigs = {}

ret_type = 'None' if name == '__init__' and class_name else 'Any'

if (name in ('__new__', '__init__') and name not in sigs and class_name and
class_name in class_sigs):
inferred = [FunctionSig(name=name,
args=infer_arg_sig_from_docstring(class_sigs[class_name]),
ret_type=ret_type)] # type: Optional[List[FunctionSig]]
else:
docstr = getattr(obj, '__doc__', None)
inferred = infer_sig_from_docstring(docstr, name)
if not inferred:
if class_name and name not in sigs:
inferred = [FunctionSig(name, args=infer_method_sig(name), ret_type=ret_type)]
else:
inferred = [FunctionSig(name=name,
args=infer_arg_sig_from_docstring(
sigs.get(name, '(*args, **kwargs)')),
ret_type=ret_type)]
inferred = _infer_signature_for_c_function_stub(
class_name=class_name,
class_sigs=class_sigs,
name=name,
obj=obj,
sigs=sigs)

is_overloaded = len(inferred) > 1 if inferred else False
if is_overloaded:
Expand Down Expand Up @@ -189,6 +177,82 @@ def generate_c_function_stub(module: ModuleType,
))


def _infer_signature_for_c_function_stub(
class_name: Optional[str],
class_sigs: Dict[str, str],
name: str,
obj: object,
sigs: Dict[str, str]) -> List[FunctionSig]:
default_ret_type = 'None' if name == '__init__' and class_name else 'Any'

if type(obj).__name__ == 'cython_function_or_method':
# Special-case Cython functions: if binding=True when compiling a Cython binary, it generates Python annotations
# sufficient to use inspect#signature.
sig = _infer_signature_via_inspect(obj=cast(Callable[..., Any], obj), default_ret_type=default_ret_type)
if sig is not None:
return [sig]
# Fall through to parse via doc if inspect.signature() didn't work

if (name in ('__new__', '__init__') and name not in sigs and class_name and
class_name in class_sigs):
return [FunctionSig(name=name,
args=infer_arg_sig_from_docstring(class_sigs[class_name]),
ret_type=default_ret_type)]

docstr = getattr(obj, '__doc__', None)
inferred = infer_sig_from_docstring(docstr, name)
if inferred:
return inferred

if class_name and name not in sigs:
return [FunctionSig(name, args=infer_method_sig(name), ret_type=default_ret_type)]
else:
return [FunctionSig(name=name,
args=infer_arg_sig_from_docstring(
sigs.get(name, '(*args, **kwargs)')),
ret_type=default_ret_type)]


def _infer_signature_via_inspect(obj: Callable[..., Any], default_ret_type: str) -> Optional[FunctionSig]:
"""
Parses a FunctionSig via annotations found in inspect#signature(). Returns None if inspect.signature() failed to
generate a signature.
"""

try:
signature = inspect.signature(obj)
except (ValueError, TypeError):
# inspect.signature() failed to generate a signature; this can happen for some methods depending on the
# implementation of Python, or if a cython function was not compiled with binding=True.
return None
args = []

def annotation_to_name(annotation: Any) -> Optional[str]:
if annotation == inspect.Signature.empty:
return None
if isinstance(annotation, str):
return annotation
if inspect.isclass(annotation):
return annotation.__name__
if hasattr(annotation, '__str__'):
return annotation.__str__()
# Can't do anything here, so ignore
return None

for arg_param in signature.parameters.values():
args.append(ArgSig(
name=arg_param.name,
type=annotation_to_name(arg_param.annotation),
default=arg_param.default != inspect.Parameter.empty,
))
ret_type = annotation_to_name(signature.return_annotation) or default_ret_type
return FunctionSig(
name=obj.__name__,
args=args,
ret_type=ret_type,
)


def strip_or_import(typ: str, module: ModuleType, imports: List[str]) -> str:
"""Strips unnecessary module names from typ.
Expand Down
49 changes: 48 additions & 1 deletion mypy/test/teststubgen.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import glob
import io
import os.path
import shutil
import subprocess
import sys
import tempfile
import re
Expand All @@ -19,7 +21,7 @@
mypy_options, is_blacklisted_path, is_non_library_module
)
from mypy.stubutil import walk_packages, remove_misplaced_type_comments, common_dir_prefix
from mypy.stubgenc import generate_c_type_stub, infer_method_sig, generate_c_function_stub
from mypy.stubgenc import generate_c_type_stub, infer_method_sig, generate_c_function_stub, generate_stub_for_c_module
from mypy.stubdoc import (
parse_signature, parse_all_signatures, build_signature, find_unique_signatures,
infer_sig_from_docstring, infer_prop_type_from_docstring, FunctionSig, ArgSig,
Expand Down Expand Up @@ -804,6 +806,51 @@ def __init__(self, arg0: str) -> None:
'def __init__(*args, **kwargs) -> Any: ...'])
assert_equal(set(imports), {'from typing import overload'})

def test_cython(self) -> None:
pyx_source = """
#cython: binding=True
import typing
def f(path: str, a: int = 0, b: bool = True) -> typing.List[str]:
return []
cdef class MyClass(object):
def run(self, action: str) -> None:
pass
"""

expected_pyi_snippets = [
"""
class MyClass:
@classmethod
def __init__(self, *args, **kwargs) -> None: ...
def run(self, action: str) -> None: ...
""",
"""
def f(path: str, a: int = ..., b: bool = ...) -> typing.List[str]: ...
"""
]

package_name = 'cython_test'
with tempfile.TemporaryDirectory() as tmpdir:
package_dir = os.path.join(tmpdir, package_name)
os.mkdir(package_dir)
pyx = os.path.join(package_dir, '{}.pyx'.format(package_name))
with open(pyx, 'w') as pyx_f:
pyx_f.write(pyx_source)
subprocess.check_output([
'cythonize', '-a', '-i', pyx
])

os.chdir(tmpdir)
outfile = os.path.join(tmpdir, 'out')
generate_stub_for_c_module('{}.{}'.format(package_name, package_name), outfile)
with open(outfile, 'r') as outfile_f:
outfile_txt = outfile_f.read()
for snippet in expected_pyi_snippets:
assert snippet.strip() in outfile_txt, snippet


class ArgSigSuite(unittest.TestCase):
def test_repr(self) -> None:
Expand Down
1 change: 1 addition & 0 deletions test-requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,4 @@ py>=1.5.2
virtualenv<20
setuptools
importlib-metadata==0.20
Cython

0 comments on commit 4d3dd92

Please sign in to comment.