Skip to content
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

Get argument infos from method descriptors #1368

Closed
wants to merge 8 commits into from
56 changes: 41 additions & 15 deletions astroid/raw_building.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
import sys
import types
import warnings
from typing import List, Optional
from typing import Any, Callable, List, Optional, Tuple

from astroid import bases, nodes
from astroid.manager import AstroidManager
Expand Down Expand Up @@ -186,14 +186,27 @@ def object_build_class(node, member, localname):
basenames = [base.__name__ for base in member.__bases__]
return _base_class_object_build(node, member, basenames, localname=localname)

def _get_args_info_from_callable(member: Callable) -> Tuple[List[str], List[str], List[str], List[str]]:
"""
Returns args, posonlyargs, defaults, kwonlyargs.

def object_build_function(node, member, localname):
"""create astroid for a living function object"""
signature = inspect.signature(member)
:note: currently ignores the return annotation.
"""
args = []
defaults = []
posonlyargs = []
kwonlyargs = []

try:
signature = inspect.signature(member)
except ValueError:
# function either has an invalid text signature or no signature
# at all. We distinguish between the two by looking at the
# __text_signature__ attribute
if getattr(member, "__text_signature__", None) is not None:
f"Cannot parse __text_signature__ signature of {member}"
signature = inspect.Signature()
Copy link
Collaborator

Choose a reason for hiding this comment

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

This is a change in behaviour, at least for ClassDefs. Instead of setting the arguments to None we now set it to empty lists, implying that there are no arguments. That should be reverted to mimic the current situation.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yes you are right, this was part of the stuff that I needed to change.

Copy link
Collaborator

Choose a reason for hiding this comment

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

This is a change in behaviour, at least for ClassDefs. Instead of setting the arguments to None we now set it to empty lists, implying that there are no arguments. That should be reverted to mimic the current situation.


for param_name, param in signature.parameters.items():
if param.kind == inspect.Parameter.POSITIONAL_ONLY:
posonlyargs.append(param_name)
Expand All @@ -207,13 +220,22 @@ def object_build_function(node, member, localname):
kwonlyargs.append(param_name)
if param.default is not inspect._empty:
defaults.append(param.default)

return args, posonlyargs, defaults, kwonlyargs

def object_build_function(node, member, localname):
"""create astroid for a living function object"""
args, posonlyargs, defaults, kwonlyargs = _get_args_info_from_callable(member)

func = build_function(
getattr(member, "__name__", None) or localname,
args,
posonlyargs,
defaults,
member.__doc__,
name=getattr(member, "__name__", None) or localname,
args=args,
posonlyargs=posonlyargs,
defaults=defaults,
doc=member.__doc__,
kwonlyargs=kwonlyargs
)

node.add_local_node(func, localname)


Expand All @@ -224,17 +246,21 @@ def object_build_datadescriptor(node, member, name):

def object_build_methoddescriptor(node, member, localname):
"""create astroid for a living method descriptor object"""
# FIXME get arguments ?
# get arguments infos
args, posonlyargs, defaults, kwonlyargs = _get_args_info_from_callable(member)

func = build_function(
getattr(member, "__name__", None) or localname, doc=member.__doc__
name=getattr(member, "__name__", None) or localname,
args=args,
posonlyargs=posonlyargs,
defaults=defaults,
doc=member.__doc__,
kwonlyargs=kwonlyargs,
)
# set node's arguments to None to notice that we have no information, not
# and empty argument list
Comment on lines -223 to -224
Copy link
Contributor Author

Choose a reason for hiding this comment

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

To still be inline with this, I should set None to args if we get a ValueError calling signature.signature().

Copy link
Collaborator

Choose a reason for hiding this comment

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

Yes! Let's do that!

func.args.args = None

node.add_local_node(func, localname)
_add_dunder_class(func, member)


def _base_class_object_build(node, member, basenames, name=None, localname=None):
"""create astroid for a living class object, with a given set of base names
(e.g. ancestors)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
/* Example of Python c module with an invalid __text_signature__ */

#include "Python.h"

static PyObject* base_valid(PyObject *self, PyObject* args)
{
printf("Hello World\n");
return Py_None;
}

static PyObject* base_invalid(PyObject *self, PyObject* args)
{
printf("Hello World\n");
return Py_None;
}

static PyMethodDef base_methods[] = {
{"valid_text_signature", base_valid, METH_VARARGS, "valid_text_signature($self, a='r', b=-3.14)\n"
"--\n"
"\n"
"Function demonstrating a valid __text_signature__ from C code."},

{"invalid_text_signature", base_invalid, METH_VARARGS, "invalid_text_signature(!invalid) -> NotSupported\n"
"--\n"
"\n"
"Function demonstrating an invalid __text_signature__ from C code."},

{NULL, NULL, 0, NULL} /* sentinel */
};

static PyModuleDef base_definition = {
PyModuleDef_HEAD_INIT,
"base",
"A Python module demonstrating valid and invalid __text_signature__ from C code.",
-1,
base_methods
};

PyObject* PyInit_base(void) {
Py_Initialize();
return PyModule_Create(&base_definition);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
from setuptools import setup, Extension

cmodule = Extension("mymod.base", sources=["mymod/base.c"],)
setup(
name="mymod",
ext_modules=[cmodule],
packages=['mymod'],
)
Copy link
Collaborator

Choose a reason for hiding this comment

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

Suggested change
)
)

48 changes: 47 additions & 1 deletion tests/unittest_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,14 +26,19 @@
"""tests for the astroid builder and rebuilder module"""

import collections
import importlib.util, importlib.abc, importlib.machinery
import os
from pathlib import Path
import socket
import subprocess
import sys
import types
from typing import Iterator
import unittest

import pytest

from astroid import Instance, builder, nodes, test_utils, util
from astroid import Instance, builder, modutils, nodes, test_utils, util
from astroid.const import PY38_PLUS
from astroid.exceptions import (
AstroidBuildingError,
Expand Down Expand Up @@ -789,6 +794,47 @@ def test_parse_module_with_invalid_type_comments_does_not_crash():
)
assert isinstance(node, nodes.Module)

def test_c_module_text_signature():
Copy link
Contributor Author

Choose a reason for hiding this comment

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

I think this test cannot pass on pypy, since they do not support CPython extensions natively.

Copy link
Collaborator

Choose a reason for hiding this comment

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

We should be able to tell pytest to skip it right?

Although I must confess I don't know if this is actually true. Did you find any documentation about this?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

We should be able to tell pytest to skip it right?

Yes we can

Although I must confess I don't know if this is actually true. Did you find any documentation about this?

Yes I've read that from pypy's documentation, but I can't find the link now. But here's what the pypy wiki says: "There is a compatibility layer for CPython C API extensions called CPyExt, but it is incomplete and experimental.". So I'm positive that, by default, pypy has no support for c-modules built for cpython.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Let's skip then!


def _find_dot_so_files(path:Path) -> Iterator[Path]:
for entry in path.iterdir():
if entry.suffix in importlib.machinery.EXTENSION_SUFFIXES:
yield entry

def _import_module(path: Path, module_full_name:str) -> types.ModuleType:
spec = importlib.util.spec_from_file_location(module_full_name, path)
if spec is None:
raise RuntimeError(f"Cannot find spec for module {module_full_name} at {path}")
py_mod = importlib.util.module_from_spec(spec)
loader = spec.loader
assert isinstance(loader, importlib.abc.Loader), loader
loader.exec_module(py_mod)
return py_mod

c_module_invalid_text_signature = Path(resources.RESOURCE_PATH) / 'c_module_invalid_text_signature'
package_path = c_module_invalid_text_signature / 'mymod'

# build extension
try:
cwd = os.getcwd()
code, outstr = subprocess.getstatusoutput(f'cd {c_module_invalid_text_signature} && python3 setup.py build_ext --inplace')
os.chdir(cwd)

assert code==0, outstr

for p in _find_dot_so_files(package_path):
py_mod = _import_module(p, 'mymod.base')
break

mod = builder.AstroidBuilder().module_build(py_mod, 'mymod.base')
as_python_string = mod.as_string()

assert "def invalid_text_signature():" in as_python_string
assert "def valid_text_signature(a='r', b=-3.14):" in as_python_string

finally:
# cleanup
subprocess.getoutput(f'rm -f {package_path}/*.so')

if __name__ == "__main__":
unittest.main()