import os import argparse import json import re from warnings import warn from keyword import iskeyword from collections import defaultdict from jinja2 import Environment, FileSystemLoader from dataclasses import dataclass, replace from typing import Optional, Dict, List, Tuple from type_specs import TypeSpec, ALL_TYPES_EXCEPT_OBJECTS BASEDIR = os.path.dirname(__file__) env = Environment( loader=FileSystemLoader(f"{BASEDIR}/bindings_templates"), trim_blocks=True, lstrip_blocks=True ) @dataclass class PropertyInfo: name: str type: TypeSpec getter: str setter: str index: Optional[int] # If using feature we don't support yet unsupported_reason: Optional[str] = None @property def is_supported(self) -> bool: return self.unsupported_reason is None @dataclass class ArgumentInfo: name: str type: TypeSpec default_value: Optional[str] @property def has_default_value(self): return self.default_value is not None @dataclass class SignalInfo: name: str arguments: List[ArgumentInfo] # If using feature we don't support yet unsupported_reason: Optional[str] = None @property def is_supported(self) -> bool: return self.unsupported_reason is None @dataclass class MethodInfo: name: str return_type: TypeSpec is_editor: bool is_noscript: bool is_const: bool is_reverse: bool is_virtual: bool has_varargs: bool is_from_script: bool arguments: List[ArgumentInfo] # If using feature we don't support yet unsupported_reason: Optional[str] = None @property def is_supported(self) -> bool: return self.unsupported_reason is None @dataclass class EnumInfo: name: str values: Dict[str, int] @dataclass class ClassInfo: # Cleaned up name (mainly ensure singleton classes have a leading underscore) name: str # Name as provided in api.json (needed to use GDNative's ClassDB) bind_register_name: str # Parent class name (also cleaned up) base_class: str singleton: Optional[str] instantiable: bool is_reference: bool constants: Dict[str, int] properties: List[PropertyInfo] signals: List[SignalInfo] methods: List[MethodInfo] enums: List[EnumInfo] TYPES = {t.gdapi_type: t for t in ALL_TYPES_EXCEPT_OBJECTS} # Basically provide enough to run the tests and the pong demo SAMPLE_CLASSES = { "Object", "_ProjectSettings", "_Input", "_InputMap", "MainLoop", "SceneTree", "Node", "CanvasItem", "Node2D", "Reference", "Resource", "OpenSimplexNoise", "CollisionObject2D", "Area2D", "ARVRInterface", "ARVRInterfaceGDNative", "Environment", "Viewport", "Script", "PluginScript", "GDScript", "Control", "Label", # "_ClassDB", # "_Engine", # "_Geometry", # "_JSON", "_OS", "_ResourceLoader", # "_ResourceSaver", # "_VisualScriptEditor", "SurfaceTool", "Mesh", "ArrayMesh", "Spatial", "VisualInstance", "GeometryInstance", "MeshInstance", # For REPL editor plugin "GlobalConstants", "EditorPlugin", "PackedScene", "BaseButton", "Button", "ToolButton", "Panel", "Container", "BoxContainer", "VBoxContainer", "HBoxContainer", "RichTextLabel", "LineEdit", "Font", "BitmapFont", "DynamicFont", "DynamicFontData", # Input event & friends stuff "InputEvent", "InputEventAction", "InputEventJoypadButton", "InputEventJoypadMotion", "InputEventMIDI", "InputEventScreenDrag", "InputEventScreenTouch", "InputEventWithModifiers", "InputEventGesture", "InputEventMagnifyGesture", "InputEventPanGesture", "InputEventKey", "InputEventMouse", "InputEventMouseButton", "InputEventMouseMotion", } SUPPORTED_TYPES = { "void", "pandemonium_bool", "pandemonium_int", "pandemonium_real", "pandemonium_string", "pandemonium_variant", "pandemonium_object", "pandemonium_aabb", "pandemonium_array", "pandemonium_basis", "pandemonium_color", "pandemonium_dictionary", "pandemonium_node_path", "pandemonium_string_name", "pandemonium_plane", "pandemonium_quaternion", "pandemonium_rect2", "pandemonium_rect2i", "pandemonium_rid", "pandemonium_transform", "pandemonium_transform2d", "pandemonium_projection", "pandemonium_vector2", "pandemonium_vector2i", "pandemonium_vector3", "pandemonium_vector3i", "pandemonium_vector4", "pandemonium_vector4i", "pandemonium_pool_byte_array", "pandemonium_pool_int_array", "pandemonium_pool_real_array", "pandemonium_pool_string_array", "pandemonium_pool_vector2_array", "pandemonium_pool_vector2i_array", "pandemonium_pool_vector3_array", "pandemonium_pool_vector3i_array", "pandemonium_pool_vector4_array", "pandemonium_pool_vector4i_array", "pandemonium_pool_color_array", } def pre_cook_patch_stuff(raw_data): for klass in raw_data: # see https://github.com/pandemoniumengine/pandemonium/pull/40386 if klass["name"] == "Reference": klass["is_reference"] = True for prop in klass["properties"]: prop["name"] = prop["name"].replace("/", "_") # see https://github.com/pandemoniumengine/pandemonium/pull/40383 if prop["type"] == "17/17:RichTextEffect": prop["type"] = "Array" for meth in klass["methods"]: if meth["is_noscript"]: warn( f"`{klass['name']}.{meth['name']}` has `is_noscript=True`" " (should never be the case...)" ) if meth["is_from_script"]: warn( f"`{klass['name']}.{meth['name']}` has `is_from_script=True`" " (should never be the case...)" ) def post_cook_patch_stuff(classes): for klass in classes: # See https://github.com/pandemoniumengine/pandemonium/issues/34254 if klass.name == "_OS": for meth in klass.methods: if meth.name in ( "get_static_memory_usage", "get_static_memory_peak_usage", "get_dynamic_memory_usage", ): meth.return_type.c_type = "uint64_t" def strip_unsupported_stuff(classes): supported_classes = {k.name for k in classes} def _is_supported_type(specs): if specs.is_builtin: return specs.c_type in SUPPORTED_TYPES elif specs.is_object: return specs.cy_type in supported_classes else: return True for klass in classes: for meth in klass.methods: unsupported_reason = None # TODO: handle default param values # TODO: handle those flags if meth.is_editor: unsupported_reason = "attribute `is_editor=True` not supported" if meth.is_reverse: unsupported_reason = "attribute `is_reverse=True` not supported" if meth.has_varargs: unsupported_reason = "attribute `has_varargs=True` not supported" if not _is_supported_type(meth.return_type): unsupported_reason = f"return type {meth.return_type} not supported" bad_arg = next( (arg for arg in meth.arguments if not _is_supported_type(arg.type)), None ) if bad_arg: unsupported_reason = f"argument type {bad_arg} not supported" if unsupported_reason: warn(f"Ignoring `{klass.name}.{meth.name}` ({unsupported_reason})") meth.unsupported_reason = unsupported_reason for prop in klass.properties: if not _is_supported_type(prop.type): unsupported_reason = f"property type {prop.type} not supported" warn(f"Ignoring property `{klass.name}.{prop.name}` ({unsupported_reason})") prop.unsupported_reason = unsupported_reason for signal in klass.signals: bad_arg = next( (arg for arg in signal.arguments if not _is_supported_type(arg.type)), None ) if bad_arg: unsupported_reason = f"argument type {bad_arg} not supported" warn(f"Ignoring signal `{klass.name}.{signal.name}` ({unsupported_reason})") signal.unsupported_reason = unsupported_reason def strip_sample_stuff(classes): def _is_supported(type): return not type.is_object or type.cy_type in SAMPLE_CLASSES classes2 = [klass for klass in classes if klass.name in SAMPLE_CLASSES] for klass in classes2: klass.methods = [ meth for meth in klass.methods if all(_is_supported(arg.type) for arg in meth.arguments) and _is_supported(meth.return_type) ] klass.signals = [ signal for signal in klass.signals if all(_is_supported(arg.type) for arg in signal.arguments) ] klass.properties = [prop for prop in klass.properties if _is_supported(prop.type)] classes[:] = classes2 def camel_to_snake(name): s1 = re.sub("(.)([A-Z][a-z]+)", r"\1_\2", name) return re.sub("([a-z0-9])([A-Z])", r"\1_\2", s1).lower() def build_class_renames(data): renames = {"": ""} for item in data: old_name = item["name"] # In api.json, some singletons have underscore and others don't ( # e.g. ARVRServer vs _OS). But to access them with `get_singleton_object` # we always need the name without underscore... if item["singleton"] and not old_name.startswith("_"): new_name = f"_{old_name}" else: new_name = old_name renames[old_name] = new_name return renames def cook_data(data): classes = [] constants = {} class_renames = build_class_renames(data) def _cook_type(type_): try: return TYPES[type_] except KeyError: if type_.startswith("enum."): # typically somethin like ``enum.AnimationTree::AnimationProcessMode`` pcls, ecls = re.match(r"enum.(\w+)::(\w+)", type_).groups() return TypeSpec( gdapi_type=type_, c_type="pandemonium_int", cy_type="pandemonium_int", py_type=f"{class_renames[pcls]}.{ecls}", is_base_type=True, is_stack_only=True, is_enum=True, ) # TODO: improve handling of resources if "," in type_: return TypeSpec( gdapi_type=type_, c_type="pandemonium_object", cy_type="Resource", py_type=f"Union[{','.join([class_renames[x] for x in type_.split(',')])}]", is_object=True, ) else: return TypeSpec( gdapi_type=type_, c_type="pandemonium_object", cy_type=class_renames[type_], is_object=True, ) def _cook_name(name): if iskeyword(name) or name in ("char", "bool", "int", "float", "short", "type"): return f"{name}_" else: return name def _cook_default_value(type, value, has_default_value): if not has_default_value: return None # Mostly ad-hoc stuff given default values format in api.json is broken if type in ("pandemonium_bool", "pandemonium_int", "pandemonium_real", "pandemonium_variant"): if value == "Null": return "None" else: return value.replace("inf", "float('inf')") elif type == "pandemonium_string": return f'"{value}"' #elif type == "pandemonium_node_path": # return f'"{value}"' #elif type == "pandemonium_string_name": # return f'"{value}"' elif type == "pandemonium_object" and value in ("[Object:null]", "Null"): return "None" elif type == "pandemonium_dictionary" and value == "{}": return "Dictionary()" elif type == "pandemonium_vector2": return f"Vector2{value}" elif type == "pandemonium_vector2i": return f"Vector2i{value}" elif type == "pandemonium_rect2": return f"Rect2{value}".replace("[P: (", "(").replace("), S: (", ", ").replace(")]", ")") elif type == "pandemonium_rect2i": return f"Rect2i{value}".replace("[P: (", "(").replace("), S: (", ", ").replace(")]", ")") elif type == "pandemonium_vector3": return f"Vector3{value}" elif type == "pandemonium_vector3i": return f"Vector3i{value}" elif type == "pandemonium_vector4": return f"Vector4{value}" elif type == "pandemonium_vector4i": return f"Vector4i{value}" elif type == "pandemonium_transform" and value == "1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0": # TODO return ( "Transform(Vector3(1, 0, 0), Vector3(0, 1, 0), Vector3(0, 0, 1), Vector3(0, 0, 0))" ) elif type == "pandemonium_transform2d" and value == "((1, 0), (0, 1), (0, 0))": # TODO return "Transform2D(Vector2(1, 0), Vector2(0, 1), Vector2(0, 0))" elif type == "pandemonium_projection": # TODO return "Projection()" elif value == "[RID]": return "RID()" elif type == "pandemonium_color": return f"Color{value}" elif type == "pandemonium_pool_color_array" and value == "[PoolColorArray]": return "PoolColorArray()" elif type == "pandemonium_array" and value == "[]": return f"Array()" elif type == "pandemonium_pool_vector2_array" and value == "[]": return f"PoolVector2Array()" elif type == "pandemonium_pool_vector2i_array" and value == "[]": return f"PoolVector2iArray()" elif type == "pandemonium_pool_vector3_array" and value == "[]": return f"PoolVector3Array()" elif type == "pandemonium_pool_vector3i_array" and value == "[]": return f"PoolVector3iArray()" elif type == "pandemonium_pool_vector4_array" and value == "[]": return f"PoolVector4Array()" elif type == "pandemonium_pool_vector4i_array" and value == "[]": return f"PoolVector4iArray()" elif type == "pandemonium_pool_int_array" and value == "[]": return f"PoolIntArray()" elif type == "pandemonium_pool_real_array" and value == "[]": return f"PoolRealArray()" elif type == "pandemonium_pool_string_array" and value == "[]": return f"PoolStringArray()" elif value == "Null": return "None" else: warn(f"Unknown default arg value: type=`{type}`, value=`{value}`") return "None" for cls_data in data: if cls_data["name"] == "GlobalConstants": constants = cls_data["constants"] continue cls_info = { "bind_register_name": cls_data["name"], "name": class_renames[cls_data["name"]], "base_class": class_renames[cls_data["base_class"]], "instantiable": cls_data["instanciable"], "is_reference": cls_data["is_reference"], "constants": cls_data["constants"], "properties": [], "signals": [], "methods": [], "enums": [], } if cls_data["singleton"]: # Strip the leading underscore cls_info["singleton"] = cls_info["name"][1:] else: cls_info["singleton"] = None for prop_data in cls_data["properties"]: cls_info["properties"].append( PropertyInfo( name=_cook_name(prop_data["name"]), type=_cook_type(prop_data["type"]), getter=prop_data["getter"], setter=prop_data["setter"], index=prop_data["index"] if prop_data["index"] != -1 else None, ) ) for signal_data in cls_data["signals"]: args_info = [ ArgumentInfo( name=_cook_name(arg_data["name"]), type=_cook_type(arg_data["type"]), default_value=None, ) for arg_data in signal_data["arguments"] ] if any(arg_data["default_value"] != "" for arg_data in signal_data["arguments"]): warn( f"{cls_info['name']}.{signal_data['name']}: default value are not supported for signals" ) cls_info["signals"].append( SignalInfo(name=_cook_name(signal_data["name"]), arguments=args_info) ) for meth_data in cls_data["methods"]: args_info = [ ArgumentInfo( name=_cook_name(arg_data["name"]), type=_cook_type(arg_data["type"]), default_value=_cook_default_value( _cook_type(arg_data["type"]).c_type, arg_data["default_value"], arg_data["has_default_value"], ), ) for arg_data in meth_data["arguments"] ] meth_info = { "name": _cook_name(meth_data["name"]), "return_type": _cook_type(meth_data["return_type"]), "is_editor": meth_data["is_editor"], "is_noscript": meth_data["is_noscript"], "is_const": meth_data["is_const"], "is_reverse": meth_data["is_reverse"], "is_virtual": meth_data["is_virtual"], "has_varargs": meth_data["has_varargs"], "is_from_script": meth_data["is_from_script"], "arguments": args_info, } cls_info["methods"].append(MethodInfo(**meth_info)) for enum_data in cls_data["enums"]: cls_info["enums"].append( EnumInfo(name=_cook_name(enum_data["name"]), values=enum_data["values"]) ) classes.append(ClassInfo(**cls_info)) # Order classes by inheritance inheritances = defaultdict(list) for klass in classes: inheritances[klass.base_class].append(klass) sorted_classes = [*inheritances[""]] todo_base_classes = [*inheritances[""]] while todo_base_classes: base_class = todo_base_classes.pop() children_classes = inheritances[base_class.name] todo_base_classes += children_classes sorted_classes += children_classes return sorted_classes, constants def load_bindings_specs_from_api_json( api_json: dict, sample: bool ) -> Tuple[List[ClassInfo], Dict[str, int]]: pre_cook_patch_stuff(api_json) classes, constants = cook_data(api_json) if sample: strip_sample_stuff(classes) strip_unsupported_stuff(classes) post_cook_patch_stuff(classes) return classes, constants def generate_bindings( no_suffix_output_path: str, classes_specs: List[ClassInfo], constants_specs: Dict[str, int] ): pyx_output_path = f"{no_suffix_output_path}.pyx" print(f"Generating {pyx_output_path}") template = env.get_template("bindings.tmpl.pyx") out = template.render(classes=classes_specs, constants=constants_specs) with open(pyx_output_path, "w") as fd: fd.write(out) pyi_output_path = f"{no_suffix_output_path}.pyi" print(f"Generating {pyi_output_path}") template = env.get_template("bindings.tmpl.pyi") out = template.render(classes=classes_specs, constants=constants_specs) with open(pyi_output_path, "w") as fd: fd.write(out) pxd_output_path = f"{no_suffix_output_path}.pxd" print(f"Generating {pxd_output_path}") template = env.get_template("bindings.tmpl.pxd") out = template.render(classes=classes_specs, constants=constants_specs) with open(pxd_output_path, "w") as fd: fd.write(out) if __name__ == "__main__": def _parse_output(val): suffix = ".pyx" if not val.endswith(suffix): raise argparse.ArgumentTypeError(f"Must have a `{suffix}` suffix") return val[: -len(suffix)] parser = argparse.ArgumentParser(description="Generate pandemonium api bindings bindings files") parser.add_argument( "--input", "-i", required=True, metavar="API_PATH", type=argparse.FileType("r", encoding="utf8"), help="Path to Pandemonium api.json file", ) parser.add_argument( "--output", "-o", required=True, metavar="BINDINGS_PYX", type=_parse_output, help="Path to store the generated bindings.pyx (also used to determine .pxd/.pyi output path)", ) parser.add_argument( "--sample", action="store_true", help="Generate a subset of the bindings (faster to build, useful for dev)", ) args = parser.parse_args() api_json = json.load(args.input) classes_specs, constants_specs = load_bindings_specs_from_api_json(api_json, args.sample) generate_bindings(args.output, classes_specs, constants_specs)