-
-
Notifications
You must be signed in to change notification settings - Fork 3k
Adding minimal support for Cython functions #8631
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
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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, Callable, cast | ||
|
||
from mypy.moduleinspect import is_c_module | ||
from mypy.stubdoc import ( | ||
|
@@ -92,7 +92,9 @@ 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: | ||
|
@@ -139,24 +141,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: | ||
|
@@ -189,6 +179,86 @@ 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 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I wonder what sorts of things Cython might add to the signature. Have you tried that this does something reasonable with all types supported by Cython? It would be good to include all relevant sorts of types in the test case, in case something isn't covered yet. |
||
|
||
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. | ||
|
||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,6 +1,7 @@ | ||
import io | ||
import os.path | ||
import shutil | ||
import subprocess | ||
import sys | ||
import tempfile | ||
import re | ||
|
@@ -19,7 +20,9 @@ | |
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, | ||
|
@@ -183,6 +186,8 @@ def test_find_unique_signatures(self) -> None: | |
('func3', '(arg, arg2)')]) | ||
|
||
def test_infer_sig_from_docstring(self) -> None: | ||
assert_equal(infer_sig_from_docstring(None, 'func'), None) | ||
|
||
assert_equal(infer_sig_from_docstring('\nfunc(x) - y', 'func'), | ||
[FunctionSig(name='func', args=[ArgSig(name='x')], ret_type='Any')]) | ||
|
||
|
@@ -804,6 +809,52 @@ 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 = """ | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Style nit: What about indenting the source and using |
||
#cython: binding=True | ||
|
||
import typing | ||
|
||
def f(path: str, a: int = 0, b: bool = True) -> typing.List[str]: | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I have some questions about how Cython supports PEP 484 types:
(If Cython doesn't properly support optional types, we may not be able to use the types in stubs, at least not without some tweaks.) There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Then it's an
That's ok currently. Not sure if it's going to stay that way forever, given that optional types exist, but it's at least unlikely to change all too soon.
That's currently ok, too.
Cython currently only uses a subset of what PEP-484 allows, specifically: no container types, no optional types. Nothing that requires a Maybe Cython should just implement There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If Cython doesn't yet ensure that optional types are used consistently, we can't easily know whether an argument should allow Also, we don't usually want to use
This would be great, since optional types tend to be used a lot in PEP 484 annotated code. Mypy also didn't properly support optional types in the early days (and we still allow disabling strict None checks, but this is mostly for legacy code). There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
For regular functions I don't think Cython enforces anything about the return type at all. Therefore, from a Cython p
F438
oint of view it should probably always be treated as For There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
You probably mean plain Python I agree that mypy should believe the user annotations, with the above caveat regarding |
||
return [] | ||
|
||
cdef class MyClass(object): | ||
def run(self, action: str) -> None: | ||
pass | ||
""" | ||
|
||
package_name = 'cython_test' | ||
tmpdir = tempfile.gettempdir() | ||
package_dir = os.path.join(tmpdir, package_name) | ||
if not os.path.exists(package_dir): | ||
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() | ||
|
||
assert """ | ||
class MyClass: | ||
@classmethod | ||
def __init__(self, *args, **kwargs) -> None: ... | ||
def run(self, action: str) -> None: ... | ||
""".strip() in outfile_txt | ||
|
||
# The exact format of the resulting .pyi is different depending on Python version | ||
assert """ | ||
def f(path: str, a: int = ..., b: bool = ...) -> typing.List[str]: ... | ||
""".strip() in outfile_txt or """ | ||
def f(path: str, a: int = ..., b: bool = ...) -> List: ... | ||
""".strip() in outfile_txt | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why are there two signatures that we accept? This check was a bit confusing. I think that it would be easier to read if it was written along these lines:
(Also it would look nicer with |
||
|
||
|
||
class ArgSigSuite(unittest.TestCase): | ||
def test_repr(self) -> None: | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -15,3 +15,4 @@ py>=1.5.2 | |
virtualenv<20 | ||
setuptools | ||
importlib-metadata==0.20 | ||
Cython | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'd rather not add Cython as a dependency. Instead, it would be better to have the test automatically skipped if Cython is not installed. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is probably not going to work forever.
Why do these need to be processed as C functions rather than Python functions? I'd just use
inspect
on the object and see if that produces something helpful. That wouldn't only work for Cython functions but also for Python functions in.pyc
files etc. (don't know how mypy handles those currently).There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't know if there's any particular reason. If
inspect
works, we should perhaps use it?As long as Cython doesn't enforce the use of optional types, it would at least be useful to be able to detect which functions or modules were compiled by Cython, and generate wider types (as I described above) for them.