Files
pystubs-generation/generator/parser.py
T

548 lines
16 KiB
Python

# -*- encoding: utf-8 -*-
import inspect
import re
from collections import defaultdict, OrderedDict
from typing import List, Tuple, Optional, Dict, Union
from .register import MobaseRegister
from .mtypes import Type, CType, Class, Enum, Arg, Method, Constant, Property, Function
from . import logger
def magic_split(value: str, sep=",", open="(<", close=")>"):
""" Split the value according to the given separator, but keeps together elements
within the given separator. Useful to split C++ signature function since type names
can contain special characters...
Examples:
- magic_split("a,b,c", sep=",") -> ["a", "b", "c"]
- magic_split("a<b,c>,d(e,<k,c>),p) -> ["a<b,c>", "d(e,<k,c>)", "p"]
Args:
value: String to split.
sep: Separator to use.
open: List of opening characters.
close: List of closing characters. Order must match open.
Returns: The list of split parts from value.
"""
i, j = 0, 0
s: List[str] = []
r = []
while i < len(value):
j = i + 1
while j < len(value):
c = value[j]
# Separator found and the stack is empty:
if c == sep and not s:
break
# Check close/open:
if c in open:
s.append(open.index(c))
elif c in close:
# The stack might be empty if the separator is also an opening element:
if not s and sep in open and j + 1 == len(value):
pass
else:
t = s.pop()
if t != close.index(c):
raise ValueError(
"Found closing element {} for opening element {}.".format(
c, open[t]
)
)
j += 1
r.append(value[i:j])
i = j + 1
return r
def parse_ctype(s: str) -> CType:
""" Parse a C++ type from the given string.
Args:
s: String to parse.
Returns: A C++ type parsed from the given string.
"""
# List of strings that can be removed from the names:
for d in [
"__64",
"__cdecl",
"__ptr64",
"{lvalue}",
"class",
"struct",
"enum",
"unsigned",
]:
s = s.replace(d, "")
# Remove the namespace remaing:
for d in ["MOBase", "boost::python"]:
s = s.replace(d + "::", "")
# Specific replacement:
s = s.replace("__int64", "int")
s = s.replace(" const &", "")
s = s.replace("&", "")
return CType(s.strip())
def parse_carg(s: str, has_default: bool) -> Arg:
""" Parse the given C++ argument.
Args:
s: The string to parse.
has_default: Indicates if this argument as a default.
Returns: An argument parsed from the given string.
"""
v, d = s, None
if s.find("=") != -1:
v, d = [x.strip() for x in s.split("=")]
if d is None and has_default:
d = Arg.DEFAULT_NONE
return Arg(parse_ctype(v), d)
def parse_csig(s, name) -> Tuple[CType, List[Arg]]:
""" Parse a boost::python C++ signature.
Args:
s: The signature to parse.
name: Name of the function, or "" if the signature correspond to a type.
Returns: (RType, Args) where RType is a CType object, and Args is a list of Arg
objects containing CType.
"""
# Remove the [ and ] which specifies default arguments but are useless since
# we already have = to tell us - The replacement is weird because the way boost
# present these is weird, and to avoid breaking default argument such as = []:
c = s.count("[,")
s = s.replace("[,", ",")
s = s.replace("]" * c, "")
# Split return type/arguments:
if name:
rtype_s, args_s = s.split(name)
# Remove the ( and ).
args_s = args_s.strip()[1:-1]
else:
rtype_s, args_s = magic_split(s, "(", "(<", ")>")
# Only remove the last ) because the first one is removed by magic_split:
args_s = args_s.strip()[:-1]
# Parse return type:
rtype = parse_ctype(rtype_s.strip())
# Parse arguments:
# Strip spaces and remove the first and last ():
args_s = args_s.strip()
args_ss = magic_split(args_s, ",", "(<", ")>")
args = [parse_carg(v, i > len(args_ss) - c - 1) for i, v in enumerate(args_ss)]
return rtype, args
def parse_psig(s: str, name: str) -> Tuple[Type, List[Arg]]:
""" Parse a boost::python python signature.
Args:
s: The signature to parse.
name: Name of the function.
Returns: (RType, Args) where RType is a Type object, and Args is a list of Arg
objects containing Type.
"""
c = s.count("[,")
s = s.replace("[,", ",")
s = s.replace("]" * c, "")
# This is pretty brutal way of extracting stuff... But most things can be
# retrieve from the C++ signature, here we are mainly interested in extracting
# the python type if possible:
m: re.Match[str] = re.search(
r"{}\((.*)\)\s*->\s*([^\s]+)\s*:".format(name), s
) # type: ignore
pargs = []
args = list(filter(bool, m.group(1).strip().split(",")))
for i, pa in enumerate(args):
pa = pa.strip()
t = pa[1 : pa.find(")")]
d: Optional[str] = None
if pa.find("=") != -1:
d = pa.split("=")[-1].strip()
elif i > len(args) - c - 1:
d = Arg.DEFAULT_NONE
pargs.append(Arg(Type(t), d))
return Type(m.group(2)), pargs
def find_best_type(ptype: Type, ctype: CType) -> Type:
""" Find the best type from the given python and C++ type.
Args:
ptype: The python type.
ctype: The C++ type.
Returns: The best of the two types.
"""
from .register import MOBASE_REGISTER
if ptype.name == ctype.name:
return ptype
elif ptype.is_none() and ctype.is_none():
return ptype
assert ptype.is_none() == ctype.is_none()
MOBASE_REGISTER.register_type(ptype, ctype)
if ptype.is_object():
if ctype.is_object():
return ptype
return ctype
# Returned pointer are treated differently because they can often be null:
if ctype.is_pointer():
return ctype
return ptype
def find_best_value(pvalue: str, cvalue: str) -> str:
""" Find the best value (default value) from the given python and C++ one.
WARNING: This currently always return pvalue and only warns the user if
the two values are not identical.
Args:
pvalue: Python default value.
cvalue: C++ default value.
Returns: The best of the two values.
"""
if pvalue != cvalue:
logger.warning("Mismatch default value: {} {}.".format(pvalue, cvalue))
return pvalue
def is_enum(e: type) -> bool:
""" Check if the given class is an enumeration.
Args:
e: The class object to check.
Returns: True if the object is an enumeration (boost::python enumeration, not
python) False otherwize.
"""
# Yet to find a better way...
if not isinstance(e, type):
return False
return any(
"{}.{}".format(c.__module__, c.__name__) == "Boost.Python.enum"
for c in inspect.getmro(e)
)
def make_enum(name: str, e: type) -> Enum:
""" Construct a Enum object from the given class.
Args:
name: Fully qualified name of the enumeration.
e: The class representing a boost::python enumeration.
Returns: An Enum object representing the given enumeration.
"""
# All boost enums have a .values attributes:
values = e.values # type: ignore
return Enum(
e.__name__, OrderedDict((values[k].name, k) for k in sorted(values.keys()))
)
class Overload:
""" Small class to avoid mypy issues... """
rtype: Type
args: List[Arg]
def __init__(self, rtype, args):
self.rtype = rtype
self.args = args
def parse_bpy_function_docstring(e) -> List[Overload]:
""" Parse the docstring of the given element.
Args:
e: The function to "parse".
Returns: A list of overloads for the given function, where each overload is
a dictionary with a "rtype" entry containing the return type and a "args"
entry containing the list of arguments.
"""
lines = e.__doc__.split("\n")
# Find the various overloads:
so = [i for i, line in enumerate(lines) if line.strip().startswith(e.__name__)]
so.append(len(lines))
# We are going to parse the python and C++ signature, and try to merge
# them...
overloads: List[Overload] = []
for i, j in zip(so[:-1], so[1:]):
psig = lines[i].strip()
for k in range(i, j):
if lines[k].strip().startswith("C++ signature"):
csig = lines[k + 1].strip()
prtype, pargs = parse_psig(psig, e.__name__)
crtype, cargs = parse_csig(csig, e.__name__)
# Currently there is no way to automatically check so we add [optional]
# in the doc:
if e.__doc__.find("[optional]") != -1:
crtype._optional = True
assert len(pargs) == len(cargs)
# Now we need to find the "best" type from both signatures:
rtype = find_best_type(prtype, crtype)
args = []
for parg, carg in zip(pargs, cargs):
args.append(
Arg(
find_best_type(parg.type, carg.type), # type: ignore
find_best_value(parg.value, carg.value), # type: ignore
)
) # type: ignore
overloads.append(Overload(rtype=rtype, args=args))
return overloads
def make_functions(name: str, e) -> List[Function]:
overloads = parse_bpy_function_docstring(e)
return [
Function(e.__name__, ovld.rtype, ovld.args, has_overloads=len(overloads) > 1,)
for ovld in overloads
]
def make_class(fullname: str, e: type, register: MobaseRegister) -> Class:
""" Constructs a Class objecgt from the given python class.
Args:
fullname: Name of the class (might be different from __name__ for inner
classes).
e: The python class (created from boost) to construct an object for.
class_register:
Returns: A Class object corresponding to the given class.
"""
base_classes_s: List[str] = []
# Kind of ugly, but...:
for c in inspect.getmro(e):
if c != e and c.__module__ == "mobase":
base_classes_s.append(c.__name__)
if c.__module__ == "Boost.Python":
break
# Keep as a comment but this is/should be fixed in the actual C++ code:
# Lots of class exposed do not inherit IPlugin while they should:
# if "IPlugin" not in base_classes_s and e.__name__.startswith("IPlugin") \
# and e.__name__ != "IPlugin":
# base_classes_s.append("IPlugin")
# This contains ALL the parent classes, not the direct ones:
base_classes = [register.make_object(name) for name in base_classes_s]
# Retrieve all the attributes... The hasattr is required but I don't know why:
all_attrs = [(n, getattr(e, n)) for n in dir(e) if hasattr(e, n)]
# Some exclusions:
EXCLUDED_MEMBERS = [
"__weakref__",
"__dict__",
"__doc__",
"__instance_size__",
"__module__",
"__getattr__",
]
all_attrs = [
a
for a in all_attrs
# Using getattr() here since some attribute do not have name (e.g. constants):
if a[0] not in EXCLUDED_MEMBERS
]
# Fetch all attributes from the base classes:
base_attrs: Dict[str, List[Union[Constant, Property, Method, Class]]] = defaultdict(
list
)
for bc in base_classes:
# Thanks mypy for the naming...
for a1 in bc.constants:
base_attrs[a1.name].append(a1)
for a2 in bc.methods:
base_attrs[a2.name].append(a2)
for a3 in bc.properties:
base_attrs[a3.name].append(a3)
for a4 in bc.inner_classes:
base_attrs[a4.name].append(a4)
# Find the methods:
methods = [m[1] for m in all_attrs if callable(m[1])]
methods = sorted(methods, key=lambda m: m.__name__)
# Filter out methods not provided or implemented:
methods = [
m
for m in methods
if m.__doc__ is not None and m.__doc__.find("C++ signature") != -1
]
# List of methods that must return bool:
BOOL_METHODS = ["__eq__", "__lt__", "__le__", "__ne__", "__gt__", "__ge__"]
pmethods = []
for method in methods:
if method.__doc__ is None:
continue
overloads = parse_bpy_function_docstring(method)
# __eq__ must accept an object in python, so we need to add an overload:
if method.__name__ in ["__eq__", "__ne__"]:
overloads.append(
Overload(
rtype=Type("bool"),
args=[Arg(Type(e.__name__)), Arg(Type("object"))],
)
)
cmethods = []
for ovld in overloads:
args = ovld.args
# This is a very heuristic way of checking if the method is static but I did
# not find anything better yet...
static = False
if len(args) == 0:
static = True
elif method.__name__.startswith("__"): # Special method cannot be static
static = False
else:
arg0_name = args[0].type.name
if arg0_name in register.cpp2py:
arg0_name = register.cpp2py[arg0_name].name
arg0_name = (
arg0_name.replace("*", "")
.replace("&", "")
.replace("const", "")
.strip()
)
static = (
arg0_name
not in [e.__name__, e.__name__ + "Wrapper"] + base_classes_s
)
pmethod = Method(
e.__name__,
method.__name__,
ovld.rtype,
ovld.args,
static=static,
has_overloads=len(overloads) > 1,
)
if method.__name__ in BOOL_METHODS:
pmethod.rtype = Type("bool")
cmethods.append(pmethod)
pmethods.extend(cmethods)
# Retrieve the enumerations and classes:
inner_classes = [
ic[1]
for ic in all_attrs
if isinstance(ic[1], type)
and ic[1].__name__ != "class"
and ic[0] not in base_attrs
]
pinner_classes = [
register.make_object("{}.{}".format(fullname, ic.__name__), ic)
for ic in inner_classes
]
# Retrieve the attributes:
constants = []
properties = []
for name, attr in all_attrs:
if callable(attr) or isinstance(attr, type):
continue
# Maybe we should check an override here (e.g., different value for a constant):
if name in base_attrs:
continue
if isinstance(attr, property):
properties.append(Property(name, Type("Any"), attr.fset is None))
elif not hasattr(attr, "__name__"):
constants.append(Constant(name, Type(type(attr).__name__), attr))
direct_bases_s = []
for c in e.__bases__:
if c.__module__ != "Boost.Python":
direct_bases_s.append(c.__name__)
# Forcing QWidget base for XWidget classes since these do not show up
# and we use a trick:
if e.__name__.endswith("Widget"):
logger.info(
"Forcing base {} for class {}.".format(
"PyQt5.QtWidgets.QWidget", e.__name__
)
)
direct_bases_s.append("PyQt5.QtWidgets.QWidget")
# Check if this is an inner class:
parts = fullname.split(".")
outer_class: Optional[str] = None
if len(parts) > 1:
outer_class = parts[-2]
return Class(
e.__name__,
direct_bases_s,
pmethods,
inner_classes=pinner_classes,
outer_class=outer_class,
properties=properties,
constants=constants,
)