# cython: c_string_type=unicode, c_string_encoding=utf8 import importlib from cpython.ref cimport PyObject from pandemonium._hazmat.gdnative_api_struct cimport ( pandemonium_pluginscript_language_data, pandemonium_string, pandemonium_bool, pandemonium_array, pandemonium_pool_string_array, pandemonium_object, pandemonium_variant, pandemonium_error, pandemonium_string_name, pandemonium_pluginscript_script_data, pandemonium_pluginscript_script_manifest, PANDEMONIUM_OK, PANDEMONIUM_ERR_UNAVAILABLE, PANDEMONIUM_ERR_FILE_BAD_PATH, PANDEMONIUM_ERR_PARSE_ERROR, PANDEMONIUM_METHOD_FLAG_FROM_SCRIPT, PANDEMONIUM_METHOD_RPC_MODE_DISABLED, ) from pandemonium._hazmat.gdapi cimport pythonscript_gdapi10 as gdapi10 from pandemonium._hazmat.conversion cimport ( pandemonium_string_to_pyobj, pyobj_to_pandemonium_string, pyobj_to_pandemonium_string_name, pytype_to_pandemonium_type, ) from pandemonium._hazmat.internal cimport ( get_pythonscript_verbose, get_exposed_class, set_exposed_class, destroy_exposed_class, ) from pandemonium.bindings cimport _initialize_bindings, Object from pandemonium.builtins cimport Array, Dictionary import inspect import traceback from pandemonium.tags import ExportedField, SignalField cdef inline pandemonium_pluginscript_script_manifest _build_empty_script_manifest(): cdef pandemonium_pluginscript_script_manifest manifest manifest.data = NULL gdapi10.pandemonium_string_name_new_data_char(&manifest.name, "") manifest.is_tool = False gdapi10.pandemonium_string_name_new_data_char(&manifest.base, "") gdapi10.pandemonium_dictionary_new(&manifest.member_lines) gdapi10.pandemonium_array_new(&manifest.methods) gdapi10.pandemonium_array_new(&manifest.signals) gdapi10.pandemonium_array_new(&manifest.properties) return manifest cdef Dictionary _build_signal_info(object signal): cdef Dictionary methinfo = Dictionary() methinfo["name"] = signal.name # Dummy data, only name is important here methinfo["args"] = Array() methinfo["default_args"] = Array() methinfo["return"] = None methinfo["flags"] = PANDEMONIUM_METHOD_FLAG_FROM_SCRIPT return methinfo cdef Dictionary _build_method_info(object meth, object methname): cdef Dictionary methinfo = Dictionary() spec = inspect.getfullargspec(meth) methinfo["name"] = methname # TODO: Handle classmethod/staticmethod methinfo["args"] = Array(spec.args) methinfo["default_args"] = Array() # TODO # TODO: use annotation to determine return type ? methinfo["return"] = None methinfo["flags"] = PANDEMONIUM_METHOD_FLAG_FROM_SCRIPT return methinfo cdef Dictionary _build_property_info(object prop): cdef Dictionary propinfo = Dictionary() propinfo["name"] = prop.name propinfo["type"] = pytype_to_pandemonium_type(prop.type) propinfo["hint"] = prop.hint propinfo["hint_string"] = prop.hint_string propinfo["usage"] = prop.usage propinfo["default_value"] = prop.default return propinfo cdef inline object is_method(object meth): if inspect.isfunction(meth): return True if 'cython_function' in type(meth).__name__: return True return False cdef pandemonium_pluginscript_script_manifest _build_script_manifest(object cls): cdef pandemonium_pluginscript_script_manifest manifest # No need to increase refcount here given `cls` is guaranteed to be kept # until we call `destroy_exposed_class` manifest.data = cls pyobj_to_pandemonium_string_name(cls.__name__, &manifest.name) manifest.is_tool = cls.__tool gdapi10.pandemonium_dictionary_new(&manifest.member_lines) if cls.__bases__: # Only one Pandemonium parent class (checked at class definition time) pandemonium_parent_class = next( (b for b in cls.__bases__ if issubclass(b, Object)) ) if not pandemonium_parent_class.__dict__.get("__exposed_python_class"): base = pandemonium_parent_class.__name__ else: # Pluginscript wants us to return the parent as a path base = f"res://{pandemonium_parent_class.__module__.replace('.', '/')}.py" pyobj_to_pandemonium_string_name(base, &manifest.base) methods = Array() signals = Array() properties = Array() for k, v in cls.__exported.items(): if isinstance(v, ExportedField): properties.append(_build_property_info(v)) elif isinstance(v, SignalField): signals.append(_build_signal_info(v)) else: assert is_method(v) methods.append(_build_method_info(v, k)) gdapi10.pandemonium_array_new_copy(&manifest.methods, &methods._gd_data) gdapi10.pandemonium_array_new_copy(&manifest.signals, &signals._gd_data) gdapi10.pandemonium_array_new_copy(&manifest.properties, &properties._gd_data) return manifest cdef api pandemonium_pluginscript_script_manifest pythonscript_script_init( pandemonium_pluginscript_language_data *p_data, const pandemonium_string *p_path, const pandemonium_string *p_source, pandemonium_error *r_error ) with gil: # Pandemonium class&singleton are not all available at Pythonscript bootstrap. # Hence we wait until the Pythonscript start being actually used (i.e. until # the first Python script is loaded) before initializing the bindings. _initialize_bindings() cdef object path = pandemonium_string_to_pyobj(p_path) if get_pythonscript_verbose(): print(f"Loading python script from {path}") if not path.startswith("res://") or not path.rsplit(".", 1)[-1] in ( "py", "pyc", "pyo", "pyd", ): print( f"Bad python script path `{path}`, must starts by `res://` and ends with `.py/pyc/pyo/pyd`" ) r_error[0] = PANDEMONIUM_ERR_FILE_BAD_PATH return _build_empty_script_manifest() # TODO: possible bug if res:// is not part of PYTHONPATH # Remove `res://`, `.py` and replace / by . modname = path[6:].rsplit(".", 1)[0].replace("/", ".") is_reload = modname in sys.modules if is_reload: # Reloading is done in two steps: remove the exported class, # then do module reloading through importlib. cls = get_exposed_class(modname) # If the module has no exported class, it has no real connection with # Pandemonium and doesn't need to be reloaded if cls: if get_pythonscript_verbose(): print(f"Reloading python script from {path} ({modname})") destroy_exposed_class(cls) importlib.reload(sys.modules[modname]) try: importlib.import_module(modname) # Force lazy loading of the module cls = get_exposed_class(modname) except BaseException: # If we are here it could be because the file doesn't exists # or (more possibly) the file content is not valid python (or # doesn't provide an exposed class) print( f"Got exception loading {path} ({modname}): {traceback.format_exc()}" ) r_error[0] = PANDEMONIUM_ERR_PARSE_ERROR return _build_empty_script_manifest() if cls is None: print( f"Cannot load {path} ({modname}) because it doesn't expose any class to Pandemonium" ) r_error[0] = PANDEMONIUM_ERR_PARSE_ERROR return _build_empty_script_manifest() if is_reload: # During reloading, Pandemonium calls the new class init before the old class finish (so # `pythonscript_script_finish` is going to be called after this function returns). # Hence we must manually increase the refcount to prevent finish to remove # the class. # Apparently multiple PluginScript instances can exist at the same time for the same script. set_exposed_class(cls) r_error[0] = PANDEMONIUM_OK return _build_script_manifest(cls) cdef api void pythonscript_script_finish( pandemonium_pluginscript_script_data *p_data ) with gil: cdef object cls = p_data if get_pythonscript_verbose(): # Using print here will cause a crash on editor/game shutdown sys.__stdout__.write(f"Destroying python script {cls.__name__}\n") destroy_exposed_class(cls)