gdnative_python/pythonscript/_pandemonium_script.pxi

229 lines
8.2 KiB
Cython

# 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 = <PyObject*>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 = <object>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)