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
59 changes: 45 additions & 14 deletions astroid/raw_building.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
import sys
import types
import warnings
from typing import Iterable, List, Optional
from typing import Iterable, Callable, List, Optional, Tuple

from astroid import bases, nodes
from astroid.manager import AstroidManager
Expand Down Expand Up @@ -179,13 +179,29 @@ def object_build_class(node, member, localname):
return _base_class_object_build(node, member, basenames, localname=localname)


def object_build_function(node, member, localname):
"""create astroid for a living function object"""
signature = inspect.signature(member)
def _get_args_info_from_callable(
member: Callable,
Copy link
Collaborator

Choose a reason for hiding this comment

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

Do we know more about this Callable?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

No, it can be any python callable.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Okay, so I finally got some time to review this after having spent some time in the module myself (see #1589).

I don't think this typing is correct as normally all members are either type or narrowed down types from the types packages.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Can you more explicit and maybe suggest another typing for the function ?

) -> Tuple[List[str], List[str], List[str], List[str]]:
"""
Returns args, posonlyargs, defaults, kwonlyargs.

: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 @@ -199,13 +215,23 @@ 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 @@ -216,13 +242,18 @@ 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)

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

58 changes: 56 additions & 2 deletions tests/unittest_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,22 @@

import collections
import importlib
import importlib.abc
import importlib.machinery
import importlib.util
import os
import pathlib
import py_compile
import socket
import subprocess
import sys
import tempfile
import textwrap
import unittest
import unittest.mock
import types
from pathlib import Path
from typing import Iterator

import unittest.mock
import pytest

from astroid import Instance, builder, nodes, test_utils, util
Expand Down Expand Up @@ -948,6 +954,54 @@ 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
Comment on lines +958 to +973
Copy link
Contributor Author

Choose a reason for hiding this comment

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

There is probably a utility in astroid to do 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.

I assume it's OK to include these private helpers, it's only going to be used in the tests. And I didn't see anything the does such imports in astroid, but maybe I did not look at the right place.


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


class HermeticInterpreterTest(unittest.TestCase):
"""Modeled on https://github.com/PyCQA/astroid/pull/1207#issuecomment-951455588"""

Expand Down