8000 Adding minimal support for Cython functions by pbotros · Pull Request #8631 · python/mypy · GitHub
[go: up one dir, main page]

Skip to content

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

Closed
wants to merge 1 commit into from
Closed
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
2 changes: 1 addition & 1 deletion mypy/stubdoc.py
Original file line number Diff line number Diff line change
Expand Up @@ -202,7 +202,7 @@ def args_kwargs(signature: FunctionSig) -> bool:
return list(sorted(self.signatures, key=lambda x: 1 if args_kwargs(x) else 0))


def infer_sig_from_docstring(docstr: str, name: str) -> Optional[List[FunctionSig]]:
def infer_sig_from_docstring(docstr: Optional[str], name: str) -> Optional[List[FunctionSig]]:
"""Convert function signature to list of TypedFunctionSig

Look for function signatures of function in docstring. Signature is a string of
Expand Down
110 changes: 90 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, Callable, cast

from mypy.moduleinspect import is_c_module
from mypy.stubdoc import (
Expand Down Expand Up @@ -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'
Copy link

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).

Copy link
Collaborator

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.



def is_c_method(obj: object) -> bool:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Copy link
Collaborator

Choose a reason for hiding this comment

The 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.

Expand Down
53 changes: 52 additions & 1 deletion mypy/test/teststubgen.py
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
Expand All @@ -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,
Expand Down Expand Up @@ -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')])

Expand Down Expand Up @@ -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 = """
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Style nit: What about indenting the source and using textwrap.dedent() to dedent it?

#cython: binding=True

import typing

def f(path: str, a: int = 0, b: bool = True) -> typing.List[str]:
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have some questions about how Cython supports PEP 484 types:

  • What if some argument has no type annotation?
  • Do you know how Cython supports optional types?
    • What would happen if this function would return None (with a list return type)?
    • What if f is called with a None argument for path, for example?

(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.)

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What if some argument has no type annotation?

Then it's an object.

What would happen if this function would return None (with a list return type)?

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.

What if f is called with a None argument for path, for example?

That's currently ok, too. None is generally allowed for builtin and extension types, unless explicitly excluded. That's probably also going to change at some point.

If Cython doesn't properly support optional types

Cython currently only uses a subset of what PEP-484 allows, specifically: no container types, no optional types. Nothing that requires a [] or the typing module, basically. That's a missing feature, though, and will change at some point.

Maybe Cython should just implement Optional[] and apply not None semantics to types that use PEP-484 annotations. Probably just a little change in the type parser (if it wasn't for the typing import tracking, but we could ignore that, I guess).

Copy link
Collaborator

Choose a reason for hiding this comment

The 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 None values in a stub, and whether a function might return None. A conservative approximation would be to always use an optional type for an argument, and use Union[<type>, Any] for the return type. The latter is the best way we have of saying that a function returns <type> and potentially None (but we don't know for sure). These are still better than using plain Any or object as the types.

Also, we don't usually want to use object as a return type, and it will usually result in many false positives. Instead, it should probably be replaced with Any.

Maybe Cython should just implement Optional[] and apply not None semantics to types that use PEP-484 annotations.

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).

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, we don't usually want to use object as a return type, and it will usually result in many false positives. Instead, it should probably be replaced with Any.

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 Any (although I guess you might chose to trust the user and use their annotation?).

For cdef functions is does have specified return types, but those are only visible from C anyway so probably not relevant to mypy.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For regular functions …

You probably mean plain Python def functions here. For cpdef / ccall functions (and cdef / cfunc, obviously), i.e. anything that has a C visible return type, the return types are enforced and checked like for an assignment.

I agree that mypy should believe the user annotations, with the above caveat regarding None.

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
Copy link
Collaborator

Choose a reason for hiding this comment

The 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:

    sig1 = """..."""
    sig2 = """..."""
    assert sig1.strip() in outfile_txt or sig2.strip() in outfile_txt

(Also it would look nicer with textwrap.dedent().)



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
Copy link
Collaborator

Choose a reason for hiding this comment

The 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.

0