Compare commits
8 Commits
f73902bef2
...
c9e753d289
Author | SHA1 | Date | |
---|---|---|---|
c9e753d289 | |||
39282b1a38 | |||
f0cb6f7c1f | |||
08b8cdcf14 | |||
53e4be0439 | |||
4cad6a574f | |||
bdd56c30d1 | |||
5d3b74d798 |
@ -43,7 +43,7 @@ func _process(_delta):
|
|||||||
wrapper_instance.poll()
|
wrapper_instance.poll()
|
||||||
#print(wrapper_instance.get_status())
|
#print(wrapper_instance.get_status())
|
||||||
|
|
||||||
wrapper_instance.call_rpc_async("func_to_call", [12345], func(stuff):
|
wrapper_instance.call_rpc_callback("func_to_call", [12345], func(stuff):
|
||||||
print(stuff.response)
|
print(stuff.response)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -1,3 +1,6 @@
|
|||||||
|
# FIXME: Remove this. I think we can nuke this entire class because we don't
|
||||||
|
# need it anymore.
|
||||||
|
|
||||||
# KiriJSONRPC
|
# KiriJSONRPC
|
||||||
#
|
#
|
||||||
# This just wraps JSONRPC and adds a little more sanity-checking, like
|
# This just wraps JSONRPC and adds a little more sanity-checking, like
|
||||||
|
0
SomePythonThingy/.kiri_export_python
Normal file
0
SomePythonThingy/.kiri_export_python
Normal file
5
SomePythonThingy/requirements.txt
Normal file
5
SomePythonThingy/requirements.txt
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
mediapipe == 0.10.14
|
||||||
|
numpy == 1.26.0
|
||||||
|
psutil == 5.9.7
|
||||||
|
cv2-enumerate-cameras == 1.1.10
|
||||||
|
|
10
SomePythonThingy/some_utility_script.py
Normal file
10
SomePythonThingy/some_utility_script.py
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
#!/usr/bin/python3
|
||||||
|
|
||||||
|
import time
|
||||||
|
|
||||||
|
a = 10
|
||||||
|
while a > 0:
|
||||||
|
print("asdf: ", a)
|
||||||
|
a -= 1
|
||||||
|
time.sleep(1)
|
||||||
|
|
13
SomePythonThingy/test_a_thing.py
Normal file
13
SomePythonThingy/test_a_thing.py
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
#!/usr/bin/python3
|
||||||
|
|
||||||
|
import time
|
||||||
|
|
||||||
|
def some_function_to_call():
|
||||||
|
print("butts")
|
||||||
|
|
||||||
|
a = 5
|
||||||
|
while a > 0:
|
||||||
|
a -= 1
|
||||||
|
print(a)
|
||||||
|
time.sleep(1.0)
|
||||||
|
|
6
TarTest.tscn
Normal file
6
TarTest.tscn
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
[gd_scene load_steps=2 format=3 uid="uid://dfgfueotq2kt6"]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="res://TestPythonInExport.gd" id="1_o4sdc"]
|
||||||
|
|
||||||
|
[node name="TarTest" type="Node"]
|
||||||
|
script = ExtResource("1_o4sdc")
|
67
TestPythonInExport.gd
Normal file
67
TestPythonInExport.gd
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
extends Node
|
||||||
|
|
||||||
|
var pw : KiriPythonWrapperInstance
|
||||||
|
|
||||||
|
|
||||||
|
func _ready():
|
||||||
|
#pw = KiriPythonWrapperInstance.new(
|
||||||
|
#"/storage/git2/GodotJSONRPCTest/addons/KiriPythonRPCWrapper/KiriPythonRPCWrapper/test_module/__init__.py")
|
||||||
|
#pw.setup_python()
|
||||||
|
#pw.start_process()
|
||||||
|
#var ret = pw.call_rpc_sync("func_to_call", ["test string whatever blah"])
|
||||||
|
#print(ret)
|
||||||
|
#print("Calling the thing")
|
||||||
|
#butt("nkc sckccsd scaskcmasklcms")
|
||||||
|
#print("Done calling the thing:" )
|
||||||
|
#asdfblah.emit()
|
||||||
|
#pw.stop_process()
|
||||||
|
#
|
||||||
|
|
||||||
|
pw = KiriPythonWrapperInstance.new(
|
||||||
|
"res://SomePythonThingy/test_a_thing.py")
|
||||||
|
pw.setup_python()
|
||||||
|
|
||||||
|
#print("running command...")
|
||||||
|
#var utility_script_path = pw.convert_cache_item_to_real_path(
|
||||||
|
#"res://SomePythonThingy/some_utility_script.py")
|
||||||
|
#var r1 = pw.run_python_command([utility_script_path], [], true)
|
||||||
|
#print("done running command... ", r1)
|
||||||
|
|
||||||
|
|
||||||
|
print("running command...")
|
||||||
|
var requirements_txt_path = pw.convert_cache_item_to_real_path(
|
||||||
|
"res://SomePythonThingy/requirements.txt")
|
||||||
|
var output_array = []
|
||||||
|
var r1 = pw.run_python_command(
|
||||||
|
["-m", "pip", "install", "-r", requirements_txt_path],
|
||||||
|
output_array, true, true)
|
||||||
|
print("done running command... ", r1)
|
||||||
|
print("OUTPUT WAS:", output_array[0])
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
pw.start_process(true)
|
||||||
|
print("Call thingy...")
|
||||||
|
var ret = await pw.call_rpc_async("some_function_to_call", [])
|
||||||
|
print("Done thingy...")
|
||||||
|
print(ret)
|
||||||
|
|
||||||
|
func _process(delta):
|
||||||
|
#if pw.get_status() == \
|
||||||
|
#KiriPythonWrapperInstance.KiriPythonWrapperStatus.STATUS_RUNNING:
|
||||||
|
|
||||||
|
#print("poll")
|
||||||
|
pw.poll()
|
||||||
|
|
||||||
|
#if pw.get_status() == \
|
||||||
|
#KiriPythonWrapperInstance.KiriPythonWrapperStatus.STATUS_STOPPED:
|
||||||
|
#print("IT STOPPED")
|
||||||
|
|
||||||
|
signal asdfblah
|
||||||
|
func butt(asdf):
|
||||||
|
print("Butt1: ", asdf)
|
||||||
|
await asdfblah
|
||||||
|
print("Butt2: ", asdf)
|
||||||
|
|
||||||
|
|
58
addons/KiriPythonRPCWrapper/KiriPythonBuildExportPlugin.gd
Normal file
58
addons/KiriPythonRPCWrapper/KiriPythonBuildExportPlugin.gd
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
# Python build export plugin
|
||||||
|
#
|
||||||
|
# This just makes sure that the specific Python build for whatever platform we
|
||||||
|
# need gets bundled into the build for that platform, so that it can be unpacked
|
||||||
|
# and used later by KiriPythonBuildWrangler.
|
||||||
|
|
||||||
|
@tool
|
||||||
|
extends EditorExportPlugin
|
||||||
|
class_name KiriPythonBuildExportPlugin
|
||||||
|
|
||||||
|
func _get_name() -> String:
|
||||||
|
return "KiriPythonBuildExportPlugin"
|
||||||
|
|
||||||
|
func _export_begin(
|
||||||
|
features : PackedStringArray, is_debug : bool,
|
||||||
|
path : String, flags : int):
|
||||||
|
|
||||||
|
var build_wrangler : KiriPythonBuildWrangler = KiriPythonBuildWrangler.new()
|
||||||
|
|
||||||
|
var platform_list = []
|
||||||
|
var arch_list = []
|
||||||
|
|
||||||
|
if "linux" in features:
|
||||||
|
platform_list.append("Linux")
|
||||||
|
if "windows" in features:
|
||||||
|
platform_list.append("Windows")
|
||||||
|
if "x86_64" in features:
|
||||||
|
arch_list.append("x86_64")
|
||||||
|
|
||||||
|
# TODO: Other platforms (macos)
|
||||||
|
|
||||||
|
for platform in platform_list:
|
||||||
|
for arch in arch_list:
|
||||||
|
var archive_to_export = build_wrangler._detect_archive_for_build(platform, arch)
|
||||||
|
var file_contents : PackedByteArray = FileAccess.get_file_as_bytes(archive_to_export)
|
||||||
|
add_file(archive_to_export, file_contents, false)
|
||||||
|
|
||||||
|
# Make sure all the RPC wrapper scripts make it in.
|
||||||
|
var script_path : String = get_script().resource_path
|
||||||
|
var script_dir : String = script_path.get_base_dir()
|
||||||
|
|
||||||
|
# Actually add all the files.
|
||||||
|
var extra_python_files = build_wrangler.get_extra_scripts_list()
|
||||||
|
for extra_python_file : String in extra_python_files:
|
||||||
|
var file_bytes : PackedByteArray = FileAccess.get_file_as_bytes(extra_python_file)
|
||||||
|
add_file(extra_python_file, file_bytes, false)
|
||||||
|
|
||||||
|
# Add the list of Python files as its own file so we know what to extract so
|
||||||
|
# it's visible to Python.
|
||||||
|
var python_wrapper_manifest_str : String = JSON.stringify(extra_python_files, " ")
|
||||||
|
var python_wrapper_manifest_bytes : PackedByteArray = \
|
||||||
|
python_wrapper_manifest_str.to_utf8_buffer()
|
||||||
|
var python_wrapper_manifset_path = script_dir.path_join(
|
||||||
|
"KiriPythonWrapperPythonFiles.json")
|
||||||
|
add_file(python_wrapper_manifset_path, python_wrapper_manifest_bytes, false)
|
||||||
|
|
||||||
|
|
||||||
|
|
324
addons/KiriPythonRPCWrapper/KiriPythonBuildWrangler.gd
Normal file
324
addons/KiriPythonRPCWrapper/KiriPythonBuildWrangler.gd
Normal file
@ -0,0 +1,324 @@
|
|||||||
|
# Python build wrangler
|
||||||
|
#
|
||||||
|
# This handles extracting and juggling standalone Python builds per-platform.
|
||||||
|
|
||||||
|
extends RefCounted
|
||||||
|
class_name KiriPythonBuildWrangler
|
||||||
|
|
||||||
|
# Cached release info so we don't have to constantly reload the .json file.
|
||||||
|
var _python_release_info : Dictionary = {}
|
||||||
|
|
||||||
|
#region releaseinfo file interactions
|
||||||
|
|
||||||
|
func _get_python_release_info():
|
||||||
|
|
||||||
|
if _python_release_info == {}:
|
||||||
|
|
||||||
|
var this_script_path = get_script().resource_path
|
||||||
|
var this_script_dir = this_script_path.get_base_dir()
|
||||||
|
var release_info_path = this_script_dir.path_join("StandalonePythonBuilds/python_release_info.json")
|
||||||
|
_python_release_info = load(release_info_path).data
|
||||||
|
|
||||||
|
# If you hit this assert, your python_release_info.json file is probably
|
||||||
|
# missing and you missed a setup step. Check the README.
|
||||||
|
assert(_python_release_info != null)
|
||||||
|
|
||||||
|
return _python_release_info
|
||||||
|
|
||||||
|
func _get_python_version():
|
||||||
|
|
||||||
|
var info = _get_python_release_info()
|
||||||
|
var versions : Array = info["versions"]
|
||||||
|
|
||||||
|
# Sort version numbers so that the highest version is the first element.
|
||||||
|
versions.sort_custom(func(a : String, b : String):
|
||||||
|
var version_parts_a : PackedStringArray = a.split(".")
|
||||||
|
var version_parts_b : PackedStringArray = b.split(".")
|
||||||
|
for i in range(0, 3):
|
||||||
|
if int(version_parts_a[i]) > int(version_parts_b[i]):
|
||||||
|
return true
|
||||||
|
if int(version_parts_a[i]) < int(version_parts_b[i]):
|
||||||
|
return false
|
||||||
|
return false)
|
||||||
|
|
||||||
|
return versions[0]
|
||||||
|
|
||||||
|
func _get_python_release() -> String:
|
||||||
|
var info = _get_python_release_info()
|
||||||
|
return info["release"]
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Python archive filename wrangling
|
||||||
|
|
||||||
|
# Generate the archive filename based on what we've figured out from the release
|
||||||
|
# info, the platform, architecture, optimizations, and so on. This is just the
|
||||||
|
# filename, not including the full path.
|
||||||
|
#
|
||||||
|
# Use _generate_python_archive_full_path() to generate the full path (as a
|
||||||
|
# res:// path).
|
||||||
|
func _generate_python_archive_string(
|
||||||
|
python_version : String,
|
||||||
|
python_release : String,
|
||||||
|
arch : String,
|
||||||
|
os : String,
|
||||||
|
opt : String) -> String:
|
||||||
|
|
||||||
|
return "cpython-{python_version}+{python_release}-{python_arch}-{python_os}-{python_opt}-full.tar.zip".format({
|
||||||
|
"python_version" : python_version,
|
||||||
|
"python_release" : python_release,
|
||||||
|
"python_arch" : arch,
|
||||||
|
"python_os" : os,
|
||||||
|
"python_opt" : opt
|
||||||
|
})
|
||||||
|
|
||||||
|
# Get full path (in Godot) to the archive for a given Python build.
|
||||||
|
func _generate_python_archive_full_path(
|
||||||
|
python_version : String,
|
||||||
|
python_release : String,
|
||||||
|
arch : String,
|
||||||
|
os : String,
|
||||||
|
opt : String) -> String:
|
||||||
|
|
||||||
|
var just_the_archive_filename = _generate_python_archive_string(
|
||||||
|
python_version, python_release, arch, os, opt)
|
||||||
|
|
||||||
|
var this_script_path = get_script().resource_path
|
||||||
|
var this_script_dir = this_script_path.get_base_dir()
|
||||||
|
var python_archive_path = this_script_dir.path_join(
|
||||||
|
"StandalonePythonBuilds").path_join(just_the_archive_filename)
|
||||||
|
|
||||||
|
return python_archive_path
|
||||||
|
|
||||||
|
# os_name as it appears in the Python archive filename.
|
||||||
|
func _get_python_opt_for_os(os_name : String) -> String:
|
||||||
|
if os_name == "pc-windows-msvc-shared":
|
||||||
|
return "pgo"
|
||||||
|
|
||||||
|
# TODO: (macos)
|
||||||
|
|
||||||
|
# Linux default.
|
||||||
|
return "pgo+lto"
|
||||||
|
|
||||||
|
# Note: arch variable is output of _get_python_architecture, not whatever Godot
|
||||||
|
# returns. os_name IS what Godot returns from OS.get_name().
|
||||||
|
func _get_python_platform(os_name : String, arch : String) -> String:
|
||||||
|
var os_name_mappings : Dictionary = {
|
||||||
|
"Linux" : "unknown-linux-gnu",
|
||||||
|
"macOS" : "apple-darwin", # TODO: Test this. (macos)
|
||||||
|
"Windows" : "pc-windows-msvc-shared"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Special case for armv7 Linux:
|
||||||
|
if arch == "armv7" and os_name == "Linux":
|
||||||
|
return "linux-gnueabi"
|
||||||
|
|
||||||
|
assert(os_name_mappings.has(os_name))
|
||||||
|
return os_name_mappings[os_name]
|
||||||
|
|
||||||
|
func _get_python_architecture(engine_arch : String) -> String:
|
||||||
|
var arch_name_mappings : Dictionary = {
|
||||||
|
"x86_64" : "x86_64",
|
||||||
|
"x86_32" : "i686",
|
||||||
|
"arm64" : "aarch64", # FIXME: I dunno if this is correct.
|
||||||
|
"arm32" : "armv7", # FIXME: I dunno if this is correct.
|
||||||
|
}
|
||||||
|
assert(arch_name_mappings.has(engine_arch))
|
||||||
|
return arch_name_mappings[engine_arch]
|
||||||
|
|
||||||
|
func _detect_archive_for_runtime() -> String:
|
||||||
|
var python_version : String = _get_python_version()
|
||||||
|
var python_release : String = _get_python_release()
|
||||||
|
var arch : String = _get_python_architecture(Engine.get_architecture_name())
|
||||||
|
var os_name : String = _get_python_platform(OS.get_name(), arch)
|
||||||
|
var opt = _get_python_opt_for_os(os_name)
|
||||||
|
|
||||||
|
return _generate_python_archive_full_path(
|
||||||
|
python_version, python_release,
|
||||||
|
arch, os_name, opt)
|
||||||
|
|
||||||
|
# Params are Godot's names for OSes and architectures (eg "Windows", "Linux",
|
||||||
|
# etc), not Python archive filename fields. Use things like OS.get_name().
|
||||||
|
func _detect_archive_for_build(
|
||||||
|
os_name_from_godot : String,
|
||||||
|
arch_from_godot : String) -> String:
|
||||||
|
|
||||||
|
var python_version : String = _get_python_version()
|
||||||
|
var python_release : String = _get_python_release()
|
||||||
|
|
||||||
|
var arch : String = _get_python_architecture(arch_from_godot)
|
||||||
|
var os_name : String = _get_python_platform(os_name_from_godot, arch)
|
||||||
|
|
||||||
|
var opt = _get_python_opt_for_os(os_name)
|
||||||
|
|
||||||
|
return _generate_python_archive_full_path(
|
||||||
|
python_version, python_release,
|
||||||
|
arch, os_name, opt)
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Cache path wrangling
|
||||||
|
# Get the cache path, relative to the user data dir.
|
||||||
|
# Example return value:
|
||||||
|
# "_python_dist/20240415/3.12.3"
|
||||||
|
func _get_cache_path_relative():
|
||||||
|
return "_python_dist".path_join(_get_python_release()).path_join(_get_python_version())
|
||||||
|
|
||||||
|
# Get the full cache path, as understood by the OS.
|
||||||
|
# Example return value:
|
||||||
|
# "/home/kiri/.local/share/godot/app_userdata/GodotJSONRPCTest/_python_dist/20240415/3.12.3/packaged_scripts"
|
||||||
|
func _get_script_cache_path_system() -> String:
|
||||||
|
return OS.get_user_data_dir().path_join(_get_cache_path_relative()).path_join("packaged_scripts")
|
||||||
|
|
||||||
|
# Get the full cache path, as understood by Godot.
|
||||||
|
# Example return value:
|
||||||
|
# "user://_python_dist/20240415/3.12.3"
|
||||||
|
func _get_cache_path_godot() -> String:
|
||||||
|
return "user://".path_join(_get_cache_path_relative())
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Public API
|
||||||
|
|
||||||
|
# Get the expected path to the Python executable. This is where we think it'll
|
||||||
|
# end up, not where it actually did end up. This can be called without actually
|
||||||
|
# extracting the archive. In fact, we need it to act that way because we use it
|
||||||
|
# to determine if there's already a Python install in-place.
|
||||||
|
#
|
||||||
|
# Path is a Godot path. Use ProjectSettings.globalize_path() to conver to a
|
||||||
|
# system path.
|
||||||
|
#
|
||||||
|
# Example return:
|
||||||
|
# "user://_python_dist/20240415/3.12.3/python/install/bin/python3"
|
||||||
|
func get_runtime_python_executable_godot_path() -> String:
|
||||||
|
var base_dir = _get_cache_path_godot().path_join("python/install")
|
||||||
|
if OS.get_name() == "Windows":
|
||||||
|
return base_dir.path_join("python.exe")
|
||||||
|
else:
|
||||||
|
return base_dir.path_join("bin/python3")
|
||||||
|
|
||||||
|
# TODO: Other platforms (macos).
|
||||||
|
|
||||||
|
# Get system path for the Python executable, which is what we actually need to
|
||||||
|
# use to execute it in most cases.
|
||||||
|
#
|
||||||
|
# Example return:
|
||||||
|
# "home/<user>/.local/share/godot/app_userdata/<project>/_python_dist/20240415/3.12.3/python/install/bin/python3"
|
||||||
|
func get_runtime_python_executable_system_path() -> String:
|
||||||
|
return ProjectSettings.globalize_path(get_runtime_python_executable_godot_path())
|
||||||
|
|
||||||
|
func get_cache_status() -> Dictionary:
|
||||||
|
var cache_status = {}
|
||||||
|
var cache_path_godot : String = _get_cache_path_godot()
|
||||||
|
var cache_status_filename : String = cache_path_godot.path_join(".completed_unpack")
|
||||||
|
if FileAccess.file_exists(cache_status_filename):
|
||||||
|
var cache_status_json : String = FileAccess.get_file_as_string(cache_status_filename)
|
||||||
|
cache_status = JSON.parse_string(cache_status_json)
|
||||||
|
return cache_status
|
||||||
|
|
||||||
|
func write_cache_status(cache_status : Dictionary):
|
||||||
|
var cache_path_godot : String = _get_cache_path_godot()
|
||||||
|
var cache_status_filename : String = cache_path_godot.path_join(".completed_unpack")
|
||||||
|
var cache_status_json = JSON.stringify(cache_status)
|
||||||
|
var cache_status_file : FileAccess = FileAccess.open(cache_status_filename, FileAccess.WRITE)
|
||||||
|
cache_status_file.store_string(cache_status_json)
|
||||||
|
cache_status_file.close()
|
||||||
|
|
||||||
|
func unpack_python(overwrite : bool = false):
|
||||||
|
|
||||||
|
var cache_path_godot : String = _get_cache_path_godot()
|
||||||
|
|
||||||
|
# Open archive.
|
||||||
|
var python_archive_path : String = _detect_archive_for_runtime()
|
||||||
|
var reader : KiriTARReader = KiriTARReader.new()
|
||||||
|
var err : Error = reader.open(python_archive_path)
|
||||||
|
assert(err == OK)
|
||||||
|
|
||||||
|
var cache_status_filename : String = cache_path_godot.path_join(".completed_unpack")
|
||||||
|
|
||||||
|
# Check to see if we've marked this as completely unpacked.
|
||||||
|
var tar_hash : String = reader.get_tar_hash()
|
||||||
|
var cache_status : Dictionary = get_cache_status()
|
||||||
|
if not overwrite:
|
||||||
|
if cache_status.has("completed_install_hash"):
|
||||||
|
if cache_status["completed_install_hash"] == tar_hash:
|
||||||
|
# This appears to already be completely unpacked.
|
||||||
|
return
|
||||||
|
|
||||||
|
# Get files.
|
||||||
|
var file_list : PackedStringArray = reader.get_files()
|
||||||
|
|
||||||
|
# Extract files.
|
||||||
|
for relative_filename : String in file_list:
|
||||||
|
reader.unpack_file(cache_path_godot, relative_filename)
|
||||||
|
|
||||||
|
# Mark this as completely unpacked.
|
||||||
|
print("Writing unpacked marker.")
|
||||||
|
cache_status["completed_install_hash"] = tar_hash
|
||||||
|
write_cache_status(cache_status)
|
||||||
|
|
||||||
|
# TODO: Clear cache function. Uninstall Python, etc.
|
||||||
|
|
||||||
|
func get_extra_scripts_list() -> Array:
|
||||||
|
|
||||||
|
var script_path : String = get_script().resource_path
|
||||||
|
var script_dir : String = script_path.get_base_dir()
|
||||||
|
var python_wrapper_manifset_path = script_dir.path_join(
|
||||||
|
"KiriPythonWrapperPythonFiles.json")
|
||||||
|
|
||||||
|
# If this is running an actual build, we'll just return the manifest here.
|
||||||
|
if FileAccess.file_exists(python_wrapper_manifset_path):
|
||||||
|
return load(python_wrapper_manifset_path).data
|
||||||
|
|
||||||
|
# If it's not running an actual build, we need to scan for extra Python
|
||||||
|
# files.
|
||||||
|
|
||||||
|
# First pass: Find all the .kiri_export_python markers in the entire project
|
||||||
|
# tree.
|
||||||
|
var extra_python_files : Array = []
|
||||||
|
var scan_dir_list = ["res://"]
|
||||||
|
var verified_script_bundles = []
|
||||||
|
while len(scan_dir_list):
|
||||||
|
var current_dir : String = scan_dir_list.pop_front()
|
||||||
|
var da : DirAccess = DirAccess.open(current_dir)
|
||||||
|
|
||||||
|
if da.file_exists(".kiri_export_python"):
|
||||||
|
verified_script_bundles.append(current_dir)
|
||||||
|
else:
|
||||||
|
|
||||||
|
# Add all directories to the scan list.
|
||||||
|
da.include_navigational = false
|
||||||
|
var dir_list = da.get_directories()
|
||||||
|
for dir in dir_list:
|
||||||
|
if dir == "__pycache__":
|
||||||
|
continue
|
||||||
|
scan_dir_list.append(current_dir.path_join(dir))
|
||||||
|
|
||||||
|
# Second pass: Add everything under a directory containing a
|
||||||
|
# .kiri_export_python marker.
|
||||||
|
scan_dir_list = verified_script_bundles
|
||||||
|
while len(scan_dir_list):
|
||||||
|
var current_dir : String = scan_dir_list.pop_front()
|
||||||
|
var da : DirAccess = DirAccess.open(current_dir)
|
||||||
|
|
||||||
|
# Add all directories to the scan list.
|
||||||
|
da.include_navigational = false
|
||||||
|
var dir_list = da.get_directories()
|
||||||
|
for dir in dir_list:
|
||||||
|
if dir == "__pycache__":
|
||||||
|
continue
|
||||||
|
scan_dir_list.append(current_dir.path_join(dir))
|
||||||
|
|
||||||
|
# Add all Python files.
|
||||||
|
var file_list = da.get_files()
|
||||||
|
for file in file_list:
|
||||||
|
var full_file = current_dir.path_join(file)
|
||||||
|
extra_python_files.append(full_file)
|
||||||
|
|
||||||
|
## FIXME: Remove this.
|
||||||
|
#for f in extra_python_files:
|
||||||
|
#print("Extra file: ", f)
|
||||||
|
|
||||||
|
return extra_python_files
|
||||||
|
|
||||||
|
#endregion
|
15
addons/KiriPythonRPCWrapper/KiriPythonRPCWrapper.gd
Normal file
15
addons/KiriPythonRPCWrapper/KiriPythonRPCWrapper.gd
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
@tool
|
||||||
|
extends EditorPlugin
|
||||||
|
|
||||||
|
var python_build_export_plugin = null
|
||||||
|
|
||||||
|
func _enter_tree():
|
||||||
|
assert(not python_build_export_plugin)
|
||||||
|
python_build_export_plugin = KiriPythonBuildExportPlugin.new()
|
||||||
|
add_export_plugin(python_build_export_plugin)
|
||||||
|
|
||||||
|
func _exit_tree():
|
||||||
|
assert(python_build_export_plugin)
|
||||||
|
remove_export_plugin(python_build_export_plugin)
|
||||||
|
python_build_export_plugin = null
|
||||||
|
|
@ -1,3 +1,10 @@
|
|||||||
|
# KiriPacketSocket
|
||||||
|
#
|
||||||
|
# GDScript version of the KiriPacketSocket Python module. Basically just copied
|
||||||
|
# the code over and reformatted it. Error handling and some other behaviors are
|
||||||
|
# different due to differences in how Python and GDScript handle exceptions and
|
||||||
|
# others.
|
||||||
|
|
||||||
extends RefCounted
|
extends RefCounted
|
||||||
class_name KiriPacketSocket
|
class_name KiriPacketSocket
|
||||||
|
|
||||||
@ -6,8 +13,8 @@ var _packet_buffer : KiriPacketBuffer = KiriPacketBuffer.new()
|
|||||||
var _state : KiriSocketState = KiriSocketState.DISCONNECTED
|
var _state : KiriSocketState = KiriSocketState.DISCONNECTED
|
||||||
var _outgoing_packet_queue : Array = []
|
var _outgoing_packet_queue : Array = []
|
||||||
|
|
||||||
var _state_lock : Mutex = Mutex.new()
|
var _worker_thread : bool = false
|
||||||
var _worker_thread : Thread = null
|
signal _worker_thread_should_continue
|
||||||
|
|
||||||
var _new_connections_to_server : Array = []
|
var _new_connections_to_server : Array = []
|
||||||
var _error_string : String = ""
|
var _error_string : String = ""
|
||||||
@ -58,38 +65,30 @@ enum KiriSocketState {
|
|||||||
ERROR = 5
|
ERROR = 5
|
||||||
}
|
}
|
||||||
|
|
||||||
func _notification(what):
|
|
||||||
if what == NOTIFICATION_PREDELETE:
|
|
||||||
assert(not _worker_thread)
|
|
||||||
|
|
||||||
func send_packet(packet_bytes : PackedByteArray):
|
func send_packet(packet_bytes : PackedByteArray):
|
||||||
assert(packet_bytes)
|
assert(packet_bytes)
|
||||||
_state_lock.lock()
|
|
||||||
_outgoing_packet_queue.append(packet_bytes)
|
_outgoing_packet_queue.append(packet_bytes)
|
||||||
_state_lock.unlock()
|
|
||||||
|
func poll():
|
||||||
|
_worker_thread_should_continue.emit()
|
||||||
|
|
||||||
func get_next_packet():
|
func get_next_packet():
|
||||||
_state_lock.lock()
|
poll()
|
||||||
var ret = _packet_buffer.get_next_packet()
|
var ret = _packet_buffer.get_next_packet()
|
||||||
_state_lock.unlock()
|
|
||||||
return ret
|
return ret
|
||||||
|
|
||||||
func get_next_server_connection():
|
func get_next_server_connection():
|
||||||
_state_lock.lock()
|
poll()
|
||||||
var ret = null
|
var ret = null
|
||||||
if len(_new_connections_to_server) > 0:
|
if len(_new_connections_to_server) > 0:
|
||||||
ret = _new_connections_to_server.pop_front()
|
ret = _new_connections_to_server.pop_front()
|
||||||
_state_lock.unlock()
|
|
||||||
return ret
|
return ret
|
||||||
|
|
||||||
func get_last_error():
|
func get_last_error():
|
||||||
_state_lock.lock()
|
|
||||||
var ret = _error_string
|
var ret = _error_string
|
||||||
_state_lock.unlock()
|
|
||||||
return ret
|
return ret
|
||||||
|
|
||||||
func is_disconnected_or_error():
|
func is_disconnected_or_error():
|
||||||
_state_lock.lock()
|
|
||||||
var bad_states = [
|
var bad_states = [
|
||||||
KiriSocketState.DISCONNECTED,
|
KiriSocketState.DISCONNECTED,
|
||||||
KiriSocketState.ERROR
|
KiriSocketState.ERROR
|
||||||
@ -99,14 +98,10 @@ func is_disconnected_or_error():
|
|||||||
if _state in bad_states:
|
if _state in bad_states:
|
||||||
ret = true
|
ret = true
|
||||||
|
|
||||||
_state_lock.unlock()
|
|
||||||
|
|
||||||
return ret
|
return ret
|
||||||
|
|
||||||
func get_state():
|
func get_state():
|
||||||
_state_lock.lock()
|
|
||||||
var ret = _state
|
var ret = _state
|
||||||
_state_lock.unlock()
|
|
||||||
return ret
|
return ret
|
||||||
|
|
||||||
func start_server(address):
|
func start_server(address):
|
||||||
@ -114,8 +109,10 @@ func start_server(address):
|
|||||||
_set_state(KiriSocketState.SERVER_STARTING)
|
_set_state(KiriSocketState.SERVER_STARTING)
|
||||||
|
|
||||||
assert(not _worker_thread)
|
assert(not _worker_thread)
|
||||||
_worker_thread = Thread.new()
|
_worker_thread = true
|
||||||
_worker_thread.start(_server_thread_func.bind(address))
|
|
||||||
|
# Starts coroutine.
|
||||||
|
_server_thread_func(address)
|
||||||
|
|
||||||
func start_client(address):
|
func start_client(address):
|
||||||
|
|
||||||
@ -123,53 +120,51 @@ func start_client(address):
|
|||||||
|
|
||||||
assert(not _worker_thread)
|
assert(not _worker_thread)
|
||||||
|
|
||||||
_worker_thread = Thread.new()
|
_worker_thread = true
|
||||||
_worker_thread.start(_client_thread_func.bind(address))
|
|
||||||
|
# Starts coroutine.
|
||||||
|
_client_thread_func(address)
|
||||||
|
|
||||||
func stop():
|
func stop():
|
||||||
|
|
||||||
assert(_worker_thread)
|
if not _worker_thread:
|
||||||
|
return
|
||||||
|
|
||||||
_should_quit = true
|
_should_quit = true
|
||||||
_worker_thread.wait_to_finish()
|
while _worker_thread:
|
||||||
_worker_thread = null
|
_worker_thread_should_continue.emit()
|
||||||
_should_quit = false
|
_should_quit = false
|
||||||
|
|
||||||
func is_running():
|
func is_running():
|
||||||
return not (_worker_thread == null)
|
return not (_worker_thread == null)
|
||||||
|
|
||||||
func _normal_communication_loop(sock : StreamPeer, address):
|
func _normal_communication_loop_iteration(sock : StreamPeer, address):
|
||||||
|
|
||||||
while not _should_quit:
|
if sock.poll() != OK:
|
||||||
|
return FAILED
|
||||||
|
|
||||||
if sock.poll() != OK:
|
if sock.get_status() != StreamPeerTCP.STATUS_CONNECTED:
|
||||||
break
|
return FAILED
|
||||||
|
|
||||||
if sock.get_status() != StreamPeerTCP.STATUS_CONNECTED:
|
# Get new data.
|
||||||
break
|
var available_bytes = sock.get_available_bytes()
|
||||||
|
if available_bytes > 0:
|
||||||
|
var incoming_bytes = sock.get_data(available_bytes)
|
||||||
|
_packet_buffer.add_bytes(PackedByteArray(incoming_bytes[1]))
|
||||||
|
if incoming_bytes[0] != OK:
|
||||||
|
return FAILED
|
||||||
|
|
||||||
# Get new data.
|
# Send all packets from queue.
|
||||||
_state_lock.lock()
|
while len(self._outgoing_packet_queue):
|
||||||
var available_bytes = sock.get_available_bytes()
|
var next_outgoing_packet = _outgoing_packet_queue.pop_front()
|
||||||
if available_bytes > 0:
|
var len_to_send = len(next_outgoing_packet)
|
||||||
var incoming_bytes = sock.get_data(available_bytes)
|
sock.put_u8((len_to_send & 0x000000ff) >> 0)
|
||||||
_packet_buffer.add_bytes(PackedByteArray(incoming_bytes[1]))
|
sock.put_u8((len_to_send & 0x0000ff00) >> 8)
|
||||||
if incoming_bytes[0] != OK:
|
sock.put_u8((len_to_send & 0x00ff0000) >> 16)
|
||||||
break
|
sock.put_u8((len_to_send & 0xff000000) >> 24)
|
||||||
_state_lock.unlock()
|
sock.put_data(next_outgoing_packet)
|
||||||
|
|
||||||
# Send all packets from queue.
|
return OK
|
||||||
_state_lock.lock()
|
|
||||||
while len(self._outgoing_packet_queue):
|
|
||||||
var next_outgoing_packet = _outgoing_packet_queue.pop_front()
|
|
||||||
var len_to_send = len(next_outgoing_packet)
|
|
||||||
sock.put_u8((len_to_send & 0x000000ff) >> 0)
|
|
||||||
sock.put_u8((len_to_send & 0x0000ff00) >> 8)
|
|
||||||
sock.put_u8((len_to_send & 0x00ff0000) >> 16)
|
|
||||||
sock.put_u8((len_to_send & 0xff000000) >> 24)
|
|
||||||
sock.put_data(next_outgoing_packet)
|
|
||||||
_state_lock.unlock()
|
|
||||||
|
|
||||||
OS.delay_usec(1)
|
|
||||||
|
|
||||||
func _client_thread_func(address):
|
func _client_thread_func(address):
|
||||||
|
|
||||||
@ -182,7 +177,13 @@ func _client_thread_func(address):
|
|||||||
if connect_err == OK:
|
if connect_err == OK:
|
||||||
_set_state(KiriSocketState.CONNECTED)
|
_set_state(KiriSocketState.CONNECTED)
|
||||||
|
|
||||||
_normal_communication_loop(sock, address)
|
while not _should_quit:
|
||||||
|
|
||||||
|
await _worker_thread_should_continue
|
||||||
|
|
||||||
|
var err = _normal_communication_loop_iteration(sock, address)
|
||||||
|
if err != OK:
|
||||||
|
break
|
||||||
|
|
||||||
# We are now disconnected.
|
# We are now disconnected.
|
||||||
_set_state(KiriSocketState.DISCONNECTED)
|
_set_state(KiriSocketState.DISCONNECTED)
|
||||||
@ -191,10 +192,11 @@ func _client_thread_func(address):
|
|||||||
else:
|
else:
|
||||||
_set_state(KiriSocketState.ERROR, "Connection failed")
|
_set_state(KiriSocketState.ERROR, "Connection failed")
|
||||||
|
|
||||||
func _set_state(state : KiriSocketState, error_string=null):
|
sock.close()
|
||||||
_state_lock.lock()
|
_worker_thread = false
|
||||||
_state = state
|
|
||||||
|
|
||||||
|
func _set_state(state : KiriSocketState, error_string=null):
|
||||||
|
_state = state
|
||||||
if _state == KiriSocketState.ERROR:
|
if _state == KiriSocketState.ERROR:
|
||||||
assert(error_string)
|
assert(error_string)
|
||||||
_error_string = error_string
|
_error_string = error_string
|
||||||
@ -202,12 +204,19 @@ func _set_state(state : KiriSocketState, error_string=null):
|
|||||||
assert(not error_string)
|
assert(not error_string)
|
||||||
_error_string = ""
|
_error_string = ""
|
||||||
|
|
||||||
_state_lock.unlock()
|
|
||||||
|
|
||||||
func _server_to_client_thread_func(connection : StreamPeerTCP, address):
|
func _server_to_client_thread_func(connection : StreamPeerTCP, address):
|
||||||
|
|
||||||
|
print("_server_to_client_thread_func start")
|
||||||
|
|
||||||
_set_state(KiriSocketState.CONNECTED)
|
_set_state(KiriSocketState.CONNECTED)
|
||||||
_normal_communication_loop(connection, address)
|
|
||||||
|
while not _should_quit:
|
||||||
|
|
||||||
|
await _worker_thread_should_continue
|
||||||
|
|
||||||
|
var err = _normal_communication_loop_iteration(connection, address)
|
||||||
|
if err != OK:
|
||||||
|
break
|
||||||
|
|
||||||
# FIXME: Missing some error handling here due to exception differences
|
# FIXME: Missing some error handling here due to exception differences
|
||||||
# between Python and GDScript.
|
# between Python and GDScript.
|
||||||
@ -217,6 +226,11 @@ func _server_to_client_thread_func(connection : StreamPeerTCP, address):
|
|||||||
if get_state() == KiriSocketState.CONNECTED:
|
if get_state() == KiriSocketState.CONNECTED:
|
||||||
_set_state(KiriSocketState.DISCONNECTED)
|
_set_state(KiriSocketState.DISCONNECTED)
|
||||||
|
|
||||||
|
connection.disconnect_from_host()
|
||||||
|
_worker_thread = false
|
||||||
|
|
||||||
|
print("_server_to_client_thread_func stop")
|
||||||
|
|
||||||
func _server_thread_func(address):
|
func _server_thread_func(address):
|
||||||
|
|
||||||
while not _should_quit:
|
while not _should_quit:
|
||||||
@ -237,20 +251,37 @@ func _server_thread_func(address):
|
|||||||
|
|
||||||
while not _should_quit:
|
while not _should_quit:
|
||||||
|
|
||||||
|
await _worker_thread_should_continue
|
||||||
|
|
||||||
if sock.is_connection_available():
|
if sock.is_connection_available():
|
||||||
var connection : StreamPeerTCP = sock.take_connection()
|
var connection : StreamPeerTCP = sock.take_connection()
|
||||||
var new_client : KiriPacketSocket = KiriPacketSocket.new()
|
var new_client : KiriPacketSocket = KiriPacketSocket.new()
|
||||||
new_client._start_client_connection_from_server(connection, address)
|
new_client._start_client_connection_from_server(connection, address)
|
||||||
_state_lock.lock()
|
|
||||||
_new_connections_to_server.append(new_client)
|
_new_connections_to_server.append(new_client)
|
||||||
_state_lock.unlock()
|
|
||||||
|
|
||||||
OS.delay_usec(1)
|
sock.stop()
|
||||||
|
sock = null
|
||||||
|
|
||||||
|
# Close all connections that were waiting to be accepted.
|
||||||
|
for c in _new_connections_to_server:
|
||||||
|
c.stop()
|
||||||
|
_new_connections_to_server = []
|
||||||
|
|
||||||
|
_worker_thread = false
|
||||||
|
|
||||||
func _start_client_connection_from_server(connection : StreamPeerTCP, address):
|
func _start_client_connection_from_server(connection : StreamPeerTCP, address):
|
||||||
|
|
||||||
assert(not _worker_thread)
|
assert(not _worker_thread)
|
||||||
_worker_thread = Thread.new()
|
_worker_thread = true
|
||||||
_worker_thread.start(_server_to_client_thread_func.bind(connection, address))
|
|
||||||
|
# Coroutine call.
|
||||||
|
_server_to_client_thread_func(connection, address)
|
||||||
|
|
||||||
|
func _notification(what):
|
||||||
|
if what == NOTIFICATION_PREDELETE:
|
||||||
|
# Well, this is horrible.
|
||||||
|
if self:
|
||||||
|
if is_running():
|
||||||
|
stop()
|
||||||
|
|
||||||
|
|
156
addons/KiriPythonRPCWrapper/KiriPythonRPCWrapper/__init__.py
Executable file
156
addons/KiriPythonRPCWrapper/KiriPythonRPCWrapper/__init__.py
Executable file
@ -0,0 +1,156 @@
|
|||||||
|
#!/usr/bin/python3
|
||||||
|
|
||||||
|
import importlib.util
|
||||||
|
import sys
|
||||||
|
import argparse
|
||||||
|
import time
|
||||||
|
import json
|
||||||
|
|
||||||
|
import KiriPacketSocket
|
||||||
|
|
||||||
|
# This whole thing being in a try/except is just so we can catch
|
||||||
|
# errors and see them before the terminal window closes.
|
||||||
|
# try:
|
||||||
|
if True:
|
||||||
|
|
||||||
|
# Parse arguments
|
||||||
|
arg_parser = argparse.ArgumentParser(
|
||||||
|
prog="KiriPythonRPCWrapper",
|
||||||
|
description="Wrapper for Python modules to RPCs from Godot.",
|
||||||
|
epilog="")
|
||||||
|
|
||||||
|
arg_parser.add_argument("--script", type=str, required=True)
|
||||||
|
arg_parser.add_argument("--port", type=int, required=True)
|
||||||
|
|
||||||
|
args = arg_parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
# module_path = "../KiriPacketSocket/__init__.py"
|
||||||
|
# module_name = "KiriPacketSocket"
|
||||||
|
|
||||||
|
module_path = args.script
|
||||||
|
module_name = ""
|
||||||
|
|
||||||
|
# Attempt to load the module.
|
||||||
|
module_spec = importlib.util.spec_from_file_location(
|
||||||
|
module_name, module_path)
|
||||||
|
module = importlib.util.module_from_spec(module_spec)
|
||||||
|
module_spec.loader.exec_module(module)
|
||||||
|
|
||||||
|
# This will be all the functions we find in the module that don't
|
||||||
|
# start with "_".
|
||||||
|
known_entrypoints = {}
|
||||||
|
|
||||||
|
# Scan the module for "public" functions.
|
||||||
|
for entrypoint in dir(module):
|
||||||
|
|
||||||
|
# Skip anything starting with "_". Probably not meant to be
|
||||||
|
# exposed.
|
||||||
|
if entrypoint.startswith("_"):
|
||||||
|
continue
|
||||||
|
|
||||||
|
attr = getattr(module, entrypoint)
|
||||||
|
|
||||||
|
# if hasattr(attr, "__call__"):
|
||||||
|
if callable(attr):
|
||||||
|
known_entrypoints[entrypoint] = attr
|
||||||
|
|
||||||
|
# Connect to server.
|
||||||
|
packet_socket = KiriPacketSocket.PacketSocket()
|
||||||
|
packet_socket.start_client(("127.0.0.1", args.port))
|
||||||
|
while packet_socket.get_state() == packet_socket.SocketState.CONNECTING:
|
||||||
|
time.sleep(0.001)
|
||||||
|
|
||||||
|
if packet_socket.get_state() != packet_socket.SocketState.CONNECTED:
|
||||||
|
packet_socket.stop()
|
||||||
|
raise Exception("Failed to connect to RPC host.")
|
||||||
|
|
||||||
|
print("Starting packet processing.")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def send_error_response(code, message, request_id):
|
||||||
|
ret_dict = {
|
||||||
|
"jsonrpc" : "2.0",
|
||||||
|
"error" : {
|
||||||
|
"code" : code,
|
||||||
|
"message" : message
|
||||||
|
},
|
||||||
|
"id" : request_id
|
||||||
|
}
|
||||||
|
ret_dict_json = json.dumps(ret_dict)
|
||||||
|
packet_socket.send_packet(ret_dict_json.encode("utf-8"))
|
||||||
|
|
||||||
|
def send_response(result, request_id):
|
||||||
|
try:
|
||||||
|
ret_dict = {
|
||||||
|
"jsonrpc" : "2.0",
|
||||||
|
"result" : ret,
|
||||||
|
"id" : request_id
|
||||||
|
}
|
||||||
|
ret_dict_json = json.dumps(ret_dict)
|
||||||
|
packet_socket.send_packet(ret_dict_json.encode("utf-8"))
|
||||||
|
except Exception as e:
|
||||||
|
send_error_response(-32603, "Error sending result: " + str(e), request_id)
|
||||||
|
|
||||||
|
# Start processing packets.
|
||||||
|
while True:
|
||||||
|
|
||||||
|
# Shutdown when we lose connection to host.
|
||||||
|
if packet_socket.get_state() != packet_socket.SocketState.CONNECTED:
|
||||||
|
packet_socket.stop()
|
||||||
|
raise Exception("Disconnected from RPC host.")
|
||||||
|
|
||||||
|
next_packet = packet_socket.get_next_packet()
|
||||||
|
while next_packet:
|
||||||
|
this_packet = next_packet
|
||||||
|
next_packet = packet_socket.get_next_packet()
|
||||||
|
|
||||||
|
print("GOT PACKET: ", this_packet)
|
||||||
|
|
||||||
|
# FIXME: Handle batches.
|
||||||
|
|
||||||
|
# Parse the incoming dict.
|
||||||
|
try:
|
||||||
|
request_dict_json = this_packet.decode("utf-8")
|
||||||
|
request_dict = json.loads(request_dict_json)
|
||||||
|
except Exception as e:
|
||||||
|
send_error_response(-32700, "Error parsing packet: " + str(e), request_id)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Make sure all the fields are there.
|
||||||
|
try:
|
||||||
|
method = request_dict["method"]
|
||||||
|
func_args = request_dict["params"]
|
||||||
|
request_id = request_dict["id"]
|
||||||
|
except Exception as e:
|
||||||
|
send_error_response(-32602, "Missing field: " + str(e), request_id)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Make sure the method is something we scanned earlier.
|
||||||
|
try:
|
||||||
|
func = known_entrypoints[method]
|
||||||
|
except Exception as e:
|
||||||
|
send_error_response(-32601, "Method not found: " + str(e), request_id)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Call the dang function.
|
||||||
|
try:
|
||||||
|
ret = func(*func_args)
|
||||||
|
except Exception as e:
|
||||||
|
send_error_response(-32603, "Call failed: " + str(e), request_id)
|
||||||
|
continue
|
||||||
|
|
||||||
|
send_response(ret, request_id)
|
||||||
|
|
||||||
|
time.sleep(0.0001)
|
||||||
|
|
||||||
|
# except Exception as e:
|
||||||
|
# sys.stderr.write(e)
|
||||||
|
# time.sleep(5)
|
||||||
|
# raise e
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -11,3 +11,4 @@ def func_to_call(asdf):
|
|||||||
def other_func_to_call():
|
def other_func_to_call():
|
||||||
print("jksdmckjsdncjksncs")
|
print("jksdmckjsdncjksncs")
|
||||||
|
|
||||||
|
|
313
addons/KiriPythonRPCWrapper/KiriPythonWrapperInstance.gd
Normal file
313
addons/KiriPythonRPCWrapper/KiriPythonWrapperInstance.gd
Normal file
@ -0,0 +1,313 @@
|
|||||||
|
extends RefCounted
|
||||||
|
class_name KiriPythonWrapperInstance
|
||||||
|
|
||||||
|
enum KiriPythonWrapperStatus {
|
||||||
|
STATUS_RUNNING,
|
||||||
|
STATUS_STOPPED
|
||||||
|
}
|
||||||
|
|
||||||
|
class KiriPythonWrapperActiveRequest:
|
||||||
|
|
||||||
|
enum KiriPythonWrapperActiveRequestState {
|
||||||
|
STATE_WAITING_TO_SEND,
|
||||||
|
STATE_SENT,
|
||||||
|
STATE_RESPONSE_RECEIVED
|
||||||
|
}
|
||||||
|
|
||||||
|
var id : int
|
||||||
|
var method_name : String
|
||||||
|
var arguments : Variant # Dictionary or Array
|
||||||
|
var callback # Callable or null
|
||||||
|
var state : KiriPythonWrapperActiveRequestState
|
||||||
|
var response # Return value from the call
|
||||||
|
var error_response = ""
|
||||||
|
|
||||||
|
var _active_request_queue = {}
|
||||||
|
var _request_counter = 0
|
||||||
|
|
||||||
|
var _server_packet_socket : KiriPacketSocket = null
|
||||||
|
var communication_packet_socket : KiriPacketSocket = null
|
||||||
|
|
||||||
|
var python_script_path : String = ""
|
||||||
|
|
||||||
|
var _build_wrangler : KiriPythonBuildWrangler = null
|
||||||
|
|
||||||
|
var _external_process_pid = -1
|
||||||
|
|
||||||
|
signal _rpc_async_response_received
|
||||||
|
|
||||||
|
|
||||||
|
func _init(python_file_path : String):
|
||||||
|
_build_wrangler = KiriPythonBuildWrangler.new()
|
||||||
|
python_script_path = python_file_path
|
||||||
|
|
||||||
|
func _get_python_executable():
|
||||||
|
return _build_wrangler.get_runtime_python_executable_system_path()
|
||||||
|
|
||||||
|
func _get_wrapper_script():
|
||||||
|
# FIXME: Paths will be different for builds.
|
||||||
|
var script_path = self.get_script().get_path()
|
||||||
|
var script_dirname = script_path.get_base_dir()
|
||||||
|
return ProjectSettings.globalize_path( \
|
||||||
|
script_dirname + "/KiriPythonRPCWrapper_start.py")
|
||||||
|
|
||||||
|
func _get_wrapper_cache_path() -> String:
|
||||||
|
return _build_wrangler._get_cache_path_godot().path_join("packaged_scripts")
|
||||||
|
|
||||||
|
func _get_wrapper_script_cache_path() -> String:
|
||||||
|
return _get_wrapper_cache_path().path_join("addons/KiriPythonRPCWrapper/KiriPythonRPCWrapper/__init__.py")
|
||||||
|
|
||||||
|
func setup_python():
|
||||||
|
|
||||||
|
# Unpack base Python build.
|
||||||
|
_build_wrangler.unpack_python(false)
|
||||||
|
|
||||||
|
# Unpack Python wrapper.
|
||||||
|
var extra_scripts = _build_wrangler.get_extra_scripts_list()
|
||||||
|
print(extra_scripts)
|
||||||
|
for extra_script : String in extra_scripts:
|
||||||
|
|
||||||
|
# Chop off the "res://".
|
||||||
|
var extra_script_relative : String = extra_script.substr(len("res://"))
|
||||||
|
|
||||||
|
# Some other path wrangling.
|
||||||
|
var extraction_path : String = _get_wrapper_cache_path().path_join(extra_script_relative)
|
||||||
|
var extraction_path_dir : String = extraction_path.get_base_dir()
|
||||||
|
|
||||||
|
# Make the dir.
|
||||||
|
DirAccess.make_dir_recursive_absolute(extraction_path_dir)
|
||||||
|
|
||||||
|
# Extract the file.
|
||||||
|
var bytes : PackedByteArray = FileAccess.get_file_as_bytes(extra_script)
|
||||||
|
FileAccess.open(extraction_path, FileAccess.WRITE).store_buffer(bytes)
|
||||||
|
|
||||||
|
|
||||||
|
func get_status():
|
||||||
|
|
||||||
|
if _external_process_pid == -1:
|
||||||
|
return KiriPythonWrapperStatus.STATUS_STOPPED
|
||||||
|
|
||||||
|
if not OS.is_process_running(_external_process_pid):
|
||||||
|
return KiriPythonWrapperStatus.STATUS_STOPPED
|
||||||
|
|
||||||
|
return KiriPythonWrapperStatus.STATUS_RUNNING
|
||||||
|
|
||||||
|
func run_python_command(
|
||||||
|
args : PackedStringArray,
|
||||||
|
output : Array = [],
|
||||||
|
read_stderr : bool = false,
|
||||||
|
open_console : bool = false):
|
||||||
|
|
||||||
|
var python_exe_path : String = _get_python_executable()
|
||||||
|
|
||||||
|
# Do a little switcheroo on Linux to open a console.
|
||||||
|
if open_console:
|
||||||
|
if OS.get_name() == "Linux":
|
||||||
|
args = PackedStringArray(["-e", python_exe_path]) + args
|
||||||
|
python_exe_path = "xterm"
|
||||||
|
|
||||||
|
return OS.execute(python_exe_path, args, output, read_stderr, open_console)
|
||||||
|
|
||||||
|
func convert_cache_item_to_real_path(path : String):
|
||||||
|
var real_python_script_path = path
|
||||||
|
if real_python_script_path.begins_with("res://"):
|
||||||
|
real_python_script_path = _build_wrangler._get_script_cache_path_system().path_join(
|
||||||
|
real_python_script_path.substr(len("res://")))
|
||||||
|
else:
|
||||||
|
real_python_script_path = ProjectSettings.globalize_path(
|
||||||
|
real_python_script_path)
|
||||||
|
return real_python_script_path
|
||||||
|
|
||||||
|
func start_process(open_terminal : bool = false):
|
||||||
|
|
||||||
|
# FIXME: Make sure we don't have one running.
|
||||||
|
|
||||||
|
var open_port = 9500
|
||||||
|
|
||||||
|
var real_python_script_path = convert_cache_item_to_real_path(
|
||||||
|
python_script_path)
|
||||||
|
|
||||||
|
assert(not _server_packet_socket)
|
||||||
|
_server_packet_socket = KiriPacketSocket.new()
|
||||||
|
while true:
|
||||||
|
_server_packet_socket.start_server(["127.0.0.1", open_port])
|
||||||
|
|
||||||
|
# Wait for the server to start.
|
||||||
|
while _server_packet_socket.get_state() == KiriPacketSocket.KiriSocketState.SERVER_STARTING:
|
||||||
|
OS.delay_usec(1)
|
||||||
|
|
||||||
|
# If we're successfully listening, then we found a port to use and we
|
||||||
|
# don't need to loop anymore.
|
||||||
|
if _server_packet_socket.get_state() == KiriPacketSocket.KiriSocketState.SERVER_LISTENING:
|
||||||
|
break
|
||||||
|
|
||||||
|
# This port is busy. Try the next one.
|
||||||
|
_server_packet_socket.stop()
|
||||||
|
open_port += 1
|
||||||
|
|
||||||
|
var python_exe_path : String = _get_python_executable()
|
||||||
|
var wrapper_script_path : String = \
|
||||||
|
ProjectSettings.globalize_path(_get_wrapper_script_cache_path())
|
||||||
|
|
||||||
|
var startup_command : Array = [
|
||||||
|
python_exe_path,
|
||||||
|
wrapper_script_path,
|
||||||
|
"--script", real_python_script_path,
|
||||||
|
"--port", open_port]
|
||||||
|
|
||||||
|
if open_terminal:
|
||||||
|
if OS.get_name() == "Linux":
|
||||||
|
startup_command = ["xterm", "-e"] + startup_command
|
||||||
|
|
||||||
|
#print("startup command: ", startup_command)
|
||||||
|
|
||||||
|
_external_process_pid = OS.create_process(
|
||||||
|
startup_command[0], startup_command.slice(1),
|
||||||
|
open_terminal)
|
||||||
|
|
||||||
|
#print("external process: ", _external_process_pid)
|
||||||
|
|
||||||
|
func stop_process():
|
||||||
|
|
||||||
|
if _external_process_pid != -1:
|
||||||
|
OS.kill(_external_process_pid)
|
||||||
|
_external_process_pid = -1
|
||||||
|
|
||||||
|
# Clean up server and communication sockets.
|
||||||
|
if _server_packet_socket:
|
||||||
|
_server_packet_socket.stop()
|
||||||
|
_server_packet_socket = null
|
||||||
|
|
||||||
|
if communication_packet_socket:
|
||||||
|
communication_packet_socket.stop()
|
||||||
|
communication_packet_socket = null
|
||||||
|
|
||||||
|
func call_rpc_callback(method : String, args : Variant, callback = null) -> int:
|
||||||
|
|
||||||
|
assert((args is Dictionary) or (args is Array))
|
||||||
|
assert((callback == null) or (callback is Callable))
|
||||||
|
|
||||||
|
var new_request = KiriPythonWrapperActiveRequest.new()
|
||||||
|
new_request.id = _request_counter
|
||||||
|
_request_counter += 1
|
||||||
|
new_request.method_name = method
|
||||||
|
new_request.arguments = args
|
||||||
|
new_request.callback = callback
|
||||||
|
|
||||||
|
_active_request_queue[new_request.id] = new_request
|
||||||
|
|
||||||
|
return new_request.id
|
||||||
|
|
||||||
|
func call_rpc_async(method : String, args : Variant):
|
||||||
|
|
||||||
|
var request_id = call_rpc_callback(method, args, func(request_ob):
|
||||||
|
_rpc_async_response_received.emit(request_ob)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Wait (block) until we get a response.
|
||||||
|
while true:
|
||||||
|
var rpc_response = await _rpc_async_response_received
|
||||||
|
if not rpc_response:
|
||||||
|
push_error("Error happened while waiting for RPC response in async call.")
|
||||||
|
break
|
||||||
|
|
||||||
|
if rpc_response.id == request_id:
|
||||||
|
return rpc_response.response
|
||||||
|
|
||||||
|
return null
|
||||||
|
|
||||||
|
func call_rpc_sync(method : String, args : Variant):
|
||||||
|
|
||||||
|
# Kinda hacky. We're using arrays because we can change the contents.
|
||||||
|
# Without the array or something else mutable we'd just end up with the
|
||||||
|
# internal pointer pointing to different values without affecting these
|
||||||
|
# ones.
|
||||||
|
var done_array = [false]
|
||||||
|
var response_list = []
|
||||||
|
var request_id = call_rpc_callback(method, args, func(request_ob):
|
||||||
|
done_array[0] = true
|
||||||
|
response_list.append(request_ob.response)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Wait (block) until we get a response.
|
||||||
|
while not done_array[0]:
|
||||||
|
|
||||||
|
# Bail out if something happened to our instance or connection to it.
|
||||||
|
if communication_packet_socket:
|
||||||
|
if communication_packet_socket.is_disconnected_or_error():
|
||||||
|
push_error("Disconnected from RPC client while waiting for response.")
|
||||||
|
break
|
||||||
|
|
||||||
|
poll()
|
||||||
|
OS.delay_usec(1)
|
||||||
|
|
||||||
|
if len(response_list):
|
||||||
|
return response_list[0]
|
||||||
|
|
||||||
|
return null
|
||||||
|
|
||||||
|
func poll():
|
||||||
|
|
||||||
|
# Hand-off between listening socket and actual communications socket.
|
||||||
|
if _server_packet_socket:
|
||||||
|
communication_packet_socket = _server_packet_socket.get_next_server_connection()
|
||||||
|
if communication_packet_socket:
|
||||||
|
_server_packet_socket.stop()
|
||||||
|
_server_packet_socket = null
|
||||||
|
|
||||||
|
if communication_packet_socket:
|
||||||
|
|
||||||
|
if communication_packet_socket.is_disconnected_or_error():
|
||||||
|
# Tell any awaiting async calls that they're never getting an
|
||||||
|
# answer. So sad.
|
||||||
|
_rpc_async_response_received.emit(null)
|
||||||
|
stop_process()
|
||||||
|
push_error("poll(): Disconnected from RPC client.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Send all waiting requests
|
||||||
|
for request_id in _active_request_queue:
|
||||||
|
var request : KiriPythonWrapperActiveRequest = _active_request_queue[request_id]
|
||||||
|
if request.state == request.KiriPythonWrapperActiveRequestState.STATE_WAITING_TO_SEND:
|
||||||
|
var request_dict = {
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"method": request.method_name,
|
||||||
|
"params": request.arguments,
|
||||||
|
"id": request.id
|
||||||
|
}
|
||||||
|
var request_dict_json = JSON.stringify(request_dict)
|
||||||
|
communication_packet_socket.send_packet(request_dict_json.to_utf8_buffer())
|
||||||
|
request.state = request.KiriPythonWrapperActiveRequestState.STATE_SENT
|
||||||
|
|
||||||
|
# Check for responses.
|
||||||
|
var packet = communication_packet_socket.get_next_packet()
|
||||||
|
while packet != null:
|
||||||
|
var packet_dict = JSON.parse_string(packet.get_string_from_utf8())
|
||||||
|
if packet_dict:
|
||||||
|
if packet_dict.has("id"):
|
||||||
|
var request_id = packet_dict["id"]
|
||||||
|
|
||||||
|
# floats aren't even allowed in JSON RPC as an id. Probably
|
||||||
|
# meant it to be an int.
|
||||||
|
if request_id is float:
|
||||||
|
request_id = int(request_id)
|
||||||
|
|
||||||
|
if _active_request_queue.has(request_id):
|
||||||
|
var request : KiriPythonWrapperActiveRequest = \
|
||||||
|
_active_request_queue[request_id]
|
||||||
|
if "result" in packet_dict:
|
||||||
|
request.response = packet_dict["result"]
|
||||||
|
elif "error" in packet_dict:
|
||||||
|
push_error(packet_dict["error"])
|
||||||
|
else:
|
||||||
|
request.error_response = "Couldn't find result on packet."
|
||||||
|
if request.callback:
|
||||||
|
request.callback.call(request)
|
||||||
|
|
||||||
|
# Clean up request.
|
||||||
|
_active_request_queue.erase(request_id)
|
||||||
|
|
||||||
|
packet = communication_packet_socket.get_next_packet()
|
||||||
|
|
||||||
|
|
||||||
|
|
380
addons/KiriPythonRPCWrapper/KiriTARReader.gd
Normal file
380
addons/KiriPythonRPCWrapper/KiriTARReader.gd
Normal file
@ -0,0 +1,380 @@
|
|||||||
|
# TARReader
|
||||||
|
#
|
||||||
|
# Read .tar.zip files. Interface mostly identical to ZIPReader.
|
||||||
|
#
|
||||||
|
# Why .tar.zip instead of .tar.bz2, .tar.gz, .tar.zst, or something normal?
|
||||||
|
# Godot supports loading files with GZip and Zstandard compression, but only
|
||||||
|
# files that it's saved (with a header/footer), so it can't load normal .tar.gz
|
||||||
|
# or .tar.zst files. It can load zips, though.
|
||||||
|
#tar
|
||||||
|
# DO NOT USE THIS ON UNTRUSTED DATA.
|
||||||
|
|
||||||
|
extends RefCounted
|
||||||
|
class_name KiriTARReader
|
||||||
|
|
||||||
|
#region Internal data
|
||||||
|
|
||||||
|
class TarFileRecord:
|
||||||
|
extends RefCounted
|
||||||
|
var filename : String
|
||||||
|
var offset : int
|
||||||
|
var file_size : int
|
||||||
|
|
||||||
|
# Unix file permissions.
|
||||||
|
#
|
||||||
|
# Technically this is an int, but we're just going to leave it as an octal
|
||||||
|
# string because that's what we can feed right into chmod.
|
||||||
|
var mode : String
|
||||||
|
|
||||||
|
# Symlinks.
|
||||||
|
var is_link : bool
|
||||||
|
var link_destination : String
|
||||||
|
|
||||||
|
var is_directory : bool
|
||||||
|
|
||||||
|
var type_indicator : String
|
||||||
|
|
||||||
|
var _internal_file_list = []
|
||||||
|
var _internal_file_list_indices = {} # Map filename -> index in _internal_file_list
|
||||||
|
var _reader : ZIPReader = null
|
||||||
|
var _tar_file_cache : PackedByteArray = []
|
||||||
|
var _tar_file_hash : PackedByteArray = []
|
||||||
|
|
||||||
|
func _load_record(record : TarFileRecord) -> PackedByteArray:
|
||||||
|
load_cache()
|
||||||
|
return _tar_file_cache.slice(record.offset, record.offset + record.file_size)
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Cache wrangling
|
||||||
|
|
||||||
|
# We have to load the entire .tar file into memory with the way the ZipReader
|
||||||
|
# API works, but we'll at least include an option to nuke the cache to free up
|
||||||
|
# memory if you want to just leave the file open.
|
||||||
|
#
|
||||||
|
# This lets us avoid re-opening and decompressing the entire .tar every time we
|
||||||
|
# need something out of it, while still letting us manually free the memory when
|
||||||
|
# we won't need it for a while.
|
||||||
|
func clear_cache():
|
||||||
|
_tar_file_cache = []
|
||||||
|
|
||||||
|
func load_cache() -> Error:
|
||||||
|
assert(_reader)
|
||||||
|
|
||||||
|
if len(_tar_file_cache):
|
||||||
|
# Cache already in-memory.
|
||||||
|
return OK
|
||||||
|
|
||||||
|
var zip_file_list = _reader.get_files()
|
||||||
|
if len(zip_file_list) != 1:
|
||||||
|
return ERR_FILE_UNRECOGNIZED
|
||||||
|
|
||||||
|
_tar_file_cache = _reader.read_file(zip_file_list[0])
|
||||||
|
|
||||||
|
return OK
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Number wrangling
|
||||||
|
|
||||||
|
func _octal_str_to_int(s : String) -> int:
|
||||||
|
var ret : int = 0;
|
||||||
|
var digit_multiplier = 1;
|
||||||
|
while len(s):
|
||||||
|
var lsb = s.substr(len(s) - 1, 1)
|
||||||
|
s = s.substr(0, len(s) - 1)
|
||||||
|
ret += digit_multiplier * lsb.to_int()
|
||||||
|
digit_multiplier *= 8
|
||||||
|
return ret
|
||||||
|
|
||||||
|
func _pad_to_512(x : int) -> int:
|
||||||
|
var x_lowbits = x & 511
|
||||||
|
var x_hibits = x & ~511
|
||||||
|
|
||||||
|
if x_lowbits:
|
||||||
|
x_hibits += 512
|
||||||
|
|
||||||
|
return x_hibits
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Public API
|
||||||
|
|
||||||
|
func close() -> Error:
|
||||||
|
_internal_file_list = []
|
||||||
|
_reader.close()
|
||||||
|
_reader = null
|
||||||
|
clear_cache()
|
||||||
|
return OK
|
||||||
|
|
||||||
|
func file_exists(path: String, case_sensitive: bool = true) -> bool:
|
||||||
|
for record : TarFileRecord in _internal_file_list:
|
||||||
|
if case_sensitive:
|
||||||
|
if record.filename == path:
|
||||||
|
return true
|
||||||
|
else:
|
||||||
|
if record.filename.nocasecmp_to(path) == 0:
|
||||||
|
return true
|
||||||
|
return false
|
||||||
|
|
||||||
|
func get_files() -> PackedStringArray:
|
||||||
|
var ret : PackedStringArray = []
|
||||||
|
for record : TarFileRecord in _internal_file_list:
|
||||||
|
ret.append(record.filename)
|
||||||
|
return ret
|
||||||
|
|
||||||
|
func get_tar_hash():
|
||||||
|
return _tar_file_hash.hex_encode()
|
||||||
|
|
||||||
|
func open(path: String) -> Error:
|
||||||
|
|
||||||
|
assert(not _reader)
|
||||||
|
_reader = ZIPReader.new()
|
||||||
|
var err = _reader.open(path)
|
||||||
|
if err != OK:
|
||||||
|
_reader.close()
|
||||||
|
_reader = null
|
||||||
|
return err
|
||||||
|
|
||||||
|
load_cache()
|
||||||
|
|
||||||
|
# Hash it.
|
||||||
|
print("Computing tar hash...")
|
||||||
|
var hashing : HashingContext = HashingContext.new()
|
||||||
|
hashing.start(HashingContext.HASH_SHA256)
|
||||||
|
hashing.update(_tar_file_cache)
|
||||||
|
_tar_file_hash = hashing.finish()
|
||||||
|
print("Done computing tar hash.")
|
||||||
|
|
||||||
|
var tar_file_offset = 0
|
||||||
|
var zero_filled_record_count = 0
|
||||||
|
var zero_filled_record : PackedByteArray = []
|
||||||
|
zero_filled_record.resize(512)
|
||||||
|
zero_filled_record.fill(0)
|
||||||
|
|
||||||
|
var paxheader_next_file = {}
|
||||||
|
var paxheader_global = {}
|
||||||
|
|
||||||
|
while tar_file_offset < len(_tar_file_cache):
|
||||||
|
var chunk = _tar_file_cache.slice(tar_file_offset, tar_file_offset + 512)
|
||||||
|
|
||||||
|
if chunk == zero_filled_record:
|
||||||
|
zero_filled_record_count += 1
|
||||||
|
if zero_filled_record_count >= 2:
|
||||||
|
break
|
||||||
|
tar_file_offset += 512
|
||||||
|
continue
|
||||||
|
|
||||||
|
var tar_record : TarFileRecord = TarFileRecord.new()
|
||||||
|
|
||||||
|
var tar_chunk_name = chunk.slice(0, 100)
|
||||||
|
var tar_chunk_size = chunk.slice(124, 124+12)
|
||||||
|
var tar_chunk_mode = chunk.slice(100, 100+8)
|
||||||
|
var tar_chunk_link_indicator = chunk.slice(156, 156+1)
|
||||||
|
var tar_chunk_link_file = chunk.slice(157, 157+100)
|
||||||
|
|
||||||
|
# FIXME: Technically "ustar\0" but we'll skip the \0
|
||||||
|
var tar_ustar_indicator = chunk.slice(257, 257+5)
|
||||||
|
var tar_ustar_file_prefix = chunk.slice(345, 345+155)
|
||||||
|
|
||||||
|
# Pluck out the relevant bits we need for the record.
|
||||||
|
tar_record.filename = tar_chunk_name.get_string_from_utf8()
|
||||||
|
|
||||||
|
tar_record.file_size = _octal_str_to_int(tar_chunk_size.get_string_from_utf8())
|
||||||
|
tar_record.mode = tar_chunk_mode.get_string_from_utf8()
|
||||||
|
tar_record.is_link = (tar_chunk_link_indicator[0] != 0 and tar_chunk_link_indicator.get_string_from_utf8()[0] == "2")
|
||||||
|
tar_record.link_destination = tar_chunk_link_file.get_string_from_utf8()
|
||||||
|
|
||||||
|
tar_record.is_directory = (tar_chunk_link_indicator[0] != 0 and tar_chunk_link_indicator.get_string_from_utf8()[0] == "5")
|
||||||
|
|
||||||
|
if tar_chunk_link_indicator[0] != 0:
|
||||||
|
tar_record.type_indicator = tar_chunk_link_indicator.get_string_from_utf8()
|
||||||
|
else:
|
||||||
|
tar_record.type_indicator = ""
|
||||||
|
|
||||||
|
# Append prefix if this is the "ustar" format.
|
||||||
|
# TODO: Test this.
|
||||||
|
if tar_ustar_indicator.get_string_from_utf8() == "ustar":
|
||||||
|
tar_record.filename = \
|
||||||
|
tar_ustar_file_prefix.get_string_from_utf8() + \
|
||||||
|
tar_record.filename
|
||||||
|
|
||||||
|
# TODO: Things we skipped:
|
||||||
|
# - owner id (108, 108+8)
|
||||||
|
# - group id (116, 116+8)
|
||||||
|
# - modification time (136, 136+12)
|
||||||
|
# - checksum (148, 148+8)
|
||||||
|
# - mosty related to USTAR format
|
||||||
|
|
||||||
|
# Skip header.
|
||||||
|
tar_file_offset += 512
|
||||||
|
|
||||||
|
# Record start offset.
|
||||||
|
tar_record.offset = tar_file_offset
|
||||||
|
|
||||||
|
# Skip file contents.
|
||||||
|
tar_file_offset += _pad_to_512(tar_record.file_size)
|
||||||
|
|
||||||
|
if tar_record.filename.get_file() == "@PaxHeader":
|
||||||
|
|
||||||
|
# This is a special file entry that just has some extended data
|
||||||
|
# about the next file or all the following files. It's not an actual
|
||||||
|
# file.
|
||||||
|
var paxheader_data : PackedByteArray = _tar_file_cache.slice(
|
||||||
|
tar_record.offset,
|
||||||
|
tar_record.offset + tar_record.file_size)
|
||||||
|
|
||||||
|
var paxheader_str : String = paxheader_data.get_string_from_utf8()
|
||||||
|
|
||||||
|
# FIXME: Do some error checking here.
|
||||||
|
var paxheader_lines = paxheader_str.split("\n", false)
|
||||||
|
for line in paxheader_lines:
|
||||||
|
var length_and_the_rest = line.split(" ")
|
||||||
|
var key_and_value = length_and_the_rest[1].split("=")
|
||||||
|
var key = key_and_value[0]
|
||||||
|
var value = key_and_value[1]
|
||||||
|
|
||||||
|
if tar_record.type_indicator == "x":
|
||||||
|
paxheader_next_file[key] = value
|
||||||
|
elif tar_record.type_indicator == "g":
|
||||||
|
paxheader_global[key] = value
|
||||||
|
|
||||||
|
else:
|
||||||
|
|
||||||
|
# Apply paxheader. We're just using "path" for now.
|
||||||
|
# See here for other available fields:
|
||||||
|
# https://pubs.opengroup.org/onlinepubs/009695399/utilities/pax.html
|
||||||
|
var merged_paxheader : Dictionary = paxheader_global.duplicate()
|
||||||
|
merged_paxheader.merge(paxheader_next_file, true)
|
||||||
|
paxheader_next_file = {}
|
||||||
|
|
||||||
|
if merged_paxheader.has("path"):
|
||||||
|
tar_record.filename = merged_paxheader["path"]
|
||||||
|
|
||||||
|
if merged_paxheader.has("linkpath"):
|
||||||
|
tar_record.link_destination = merged_paxheader["linkpath"]
|
||||||
|
|
||||||
|
# Add it to our record list.
|
||||||
|
_internal_file_list_indices[tar_record.filename] = len(_internal_file_list)
|
||||||
|
_internal_file_list.append(tar_record)
|
||||||
|
|
||||||
|
return OK
|
||||||
|
|
||||||
|
# Extract a file into memory as a PackedByteArray.
|
||||||
|
func read_file(path : String, case_sensitive : bool = true) -> PackedByteArray:
|
||||||
|
|
||||||
|
for record : TarFileRecord in _internal_file_list:
|
||||||
|
if case_sensitive:
|
||||||
|
if record.filename == path:
|
||||||
|
return _load_record(record)
|
||||||
|
else:
|
||||||
|
if record.filename.nocasecmp_to(path) == 0:
|
||||||
|
return _load_record(record)
|
||||||
|
|
||||||
|
return []
|
||||||
|
|
||||||
|
func _convert_permissions(tar_mode_str : String) -> FileAccess.UnixPermissionFlags:
|
||||||
|
# Okay so this turned out to be easier than I thought. Godot's
|
||||||
|
# UnixPermissionFlags line up with the actual permission bits in the tar.
|
||||||
|
return _octal_str_to_int(tar_mode_str)
|
||||||
|
|
||||||
|
|
||||||
|
# Extract a file to a specific path. Sets permissions when possible, handles
|
||||||
|
# symlinks and directories. Will extract to the dest_path plus the internal
|
||||||
|
# relative path.
|
||||||
|
#
|
||||||
|
# Example:
|
||||||
|
# dest_path: "foo/bar", filename: "butts/whatever/thingy.txt"
|
||||||
|
# extracts to: "foo/bar/butts/whatever/thingy.txt"
|
||||||
|
func unpack_file(dest_path : String, filename : String, force_overwrite : bool = false):
|
||||||
|
var full_dest_path : String = dest_path.path_join(filename)
|
||||||
|
DirAccess.make_dir_recursive_absolute(full_dest_path.get_base_dir())
|
||||||
|
|
||||||
|
assert(_internal_file_list_indices.has(filename))
|
||||||
|
var record : TarFileRecord = _internal_file_list[_internal_file_list_indices[filename]]
|
||||||
|
|
||||||
|
# FIXME: There are probably a million other ways to do directory
|
||||||
|
# traversal attacks than just what we've checked for here.
|
||||||
|
if record.filename.is_absolute_path():
|
||||||
|
assert(false)
|
||||||
|
return
|
||||||
|
if record.filename.simplify_path().begins_with(".."):
|
||||||
|
assert(false)
|
||||||
|
return
|
||||||
|
|
||||||
|
var need_file_made : bool = true
|
||||||
|
var need_permission_update : bool = true
|
||||||
|
var exists_in_some_way : bool = FileAccess.file_exists(full_dest_path) || DirAccess.dir_exists_absolute(full_dest_path)
|
||||||
|
|
||||||
|
# Check to see if we need to make the dir/file/etc.
|
||||||
|
if force_overwrite == false:
|
||||||
|
|
||||||
|
if exists_in_some_way:
|
||||||
|
|
||||||
|
# Link exists. Don't overwrite.
|
||||||
|
if record.is_link:
|
||||||
|
#print("Skip (link exist): ", full_dest_path)
|
||||||
|
# FIXME: Check symlink destination?
|
||||||
|
need_file_made = false
|
||||||
|
|
||||||
|
if record.is_directory:
|
||||||
|
#print("Skip (dir exist): ", full_dest_path)
|
||||||
|
need_file_made = false
|
||||||
|
|
||||||
|
# If the file is there and it's a complete file, then we're probably
|
||||||
|
# done. We can't check or set mtime through Godot's API, though.
|
||||||
|
var f : FileAccess = FileAccess.open(full_dest_path, FileAccess.READ)
|
||||||
|
if f.get_length() == record.file_size:
|
||||||
|
#print("Skip (file exist): ", full_dest_path)
|
||||||
|
need_file_made = false
|
||||||
|
f.close()
|
||||||
|
|
||||||
|
if not record.is_link and OS.get_name() != "Windows":
|
||||||
|
if FileAccess.file_exists(full_dest_path) || DirAccess.dir_exists_absolute(full_dest_path):
|
||||||
|
var existing_permissions : FileAccess.UnixPermissionFlags = FileAccess.get_unix_permissions(full_dest_path)
|
||||||
|
var wanted_permissions : FileAccess.UnixPermissionFlags = _convert_permissions(record.mode)
|
||||||
|
if existing_permissions == wanted_permissions:
|
||||||
|
need_permission_update = false
|
||||||
|
#print("Permission are fine: ", record.mode, " ", existing_permissions, " ", full_dest_path)
|
||||||
|
else:
|
||||||
|
print("Permission update needed on existing file: ", record.mode, " ", existing_permissions, " ", full_dest_path)
|
||||||
|
|
||||||
|
if record.is_link:
|
||||||
|
|
||||||
|
# Okay, look. I know that symbolic links technically exist on
|
||||||
|
# Windows, but they're messy and hardly ever used. FIXME later
|
||||||
|
# if for some reason you need to support that. -Kiri
|
||||||
|
assert(OS.get_name() != "Windows")
|
||||||
|
|
||||||
|
# Fire off a command to make a symbolic link on *normal* OSes.
|
||||||
|
var err = OS.execute("ln", [
|
||||||
|
"-s",
|
||||||
|
record.link_destination,
|
||||||
|
ProjectSettings.globalize_path(full_dest_path)
|
||||||
|
])
|
||||||
|
|
||||||
|
assert(err != -1)
|
||||||
|
|
||||||
|
elif record.is_directory:
|
||||||
|
|
||||||
|
# It's just a directory. Make it.
|
||||||
|
DirAccess.make_dir_recursive_absolute(full_dest_path)
|
||||||
|
|
||||||
|
else:
|
||||||
|
|
||||||
|
# Okay this is an actual file. Extract it.
|
||||||
|
var file_data : PackedByteArray = read_file(record.filename)
|
||||||
|
var out_file = FileAccess.open(full_dest_path, FileAccess.WRITE)
|
||||||
|
out_file.store_buffer(file_data)
|
||||||
|
out_file.close()
|
||||||
|
|
||||||
|
# Set permissions (on normal OSes, not Windows). I don't think this
|
||||||
|
# applies to symlinks, though.
|
||||||
|
if not record.is_link:
|
||||||
|
if need_permission_update:
|
||||||
|
if OS.get_name() != "Windows":
|
||||||
|
var err : Error = FileAccess.set_unix_permissions(
|
||||||
|
full_dest_path, _convert_permissions(record.mode))
|
||||||
|
assert(err != -1)
|
||||||
|
|
||||||
|
#endregion
|
4
addons/KiriPythonRPCWrapper/README.md
Normal file
4
addons/KiriPythonRPCWrapper/README.md
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
TODO
|
||||||
|
- How to use
|
||||||
|
- How to package Python stuff
|
||||||
|
|
@ -0,0 +1,69 @@
|
|||||||
|
# What
|
||||||
|
|
||||||
|
This is where we store standalone Python builds for distributing with the built application. These will get unpacked into a temp directory on desktop platforms so that we can have an extremely specific, isolated Python environment.
|
||||||
|
|
||||||
|
Standalone Python builds from:
|
||||||
|
|
||||||
|
https://github.com/indygreg/python-build-standalone/releases
|
||||||
|
|
||||||
|
# .tar.zst vs .tar.zip vs .tar.bz2 vs tar.gz, etc
|
||||||
|
|
||||||
|
We're going to unpack these at runtime so they need to exist in a way that Godot can load. It's fairly simple for us to write our own .tar format parser, but for the compression format (zst, zip, gz, bz2, etc) it's better to rely on the engine's built-in decompression code.
|
||||||
|
|
||||||
|
Of these formats, the only one that can be read by Godot (without Godot-specific headers being attached by saving the file from Godot) is the .zip format. Unfortunately, .zip format doesn't include a lot of the file permissions that the original .tar.whatever archive includes.
|
||||||
|
|
||||||
|
So we're splitting the difference in an slightly unusual way: Use .zip as the compression around the .tar file instead of bzip2, gzip, zstd, it whatever else, then write a .tar parser to load the internal .tar at runtime. What we get from this is a slightly worse compression format that Godot can actually read at runtime, which preserves file permissions and other attributes the way a .tar would.
|
||||||
|
|
||||||
|
For format reference on .tar:
|
||||||
|
|
||||||
|
https://www.gnu.org/software/tar/manual/html_node/Standard.html
|
||||||
|
https://www.ibm.com/docs/en/zos/2.4.0?topic=formats-tar-format-tar-archives
|
||||||
|
|
||||||
|
# The Process
|
||||||
|
|
||||||
|
## Automated way
|
||||||
|
|
||||||
|
Run `update.bsh`.
|
||||||
|
|
||||||
|
## Obsolete, manual way
|
||||||
|
|
||||||
|
### 1. Grab latest archives
|
||||||
|
|
||||||
|
To update the archives here, grab the latest archive from:
|
||||||
|
|
||||||
|
https://github.com/indygreg/python-build-standalone/releases
|
||||||
|
|
||||||
|
There's a huge list of files there, so here's how to determine the latest version for this project:
|
||||||
|
|
||||||
|
cpython-PYTHONVERSION+RELEASE-ARCHITECTURE-PLATFORM-OPTIMIZATIONS.tar.zst
|
||||||
|
|
||||||
|
- PYTHONVERSION: The Python version. Unless there's a good reason to, you probably want the latest version of this.
|
||||||
|
|
||||||
|
- RELEASE: Should correspond to the latest release. Formatted as a date (YYYYMMDD).
|
||||||
|
|
||||||
|
- ARCHITECTURE: CPU architecture. This is going to be funky for Macs, but for desktop Linux/Windows PCs we usually just want `x86_64`. `x86_64_v2` and up include instructions found in newer and newer architectures. This may change if we start supporting stuff like Linux on ARM or whatever.
|
||||||
|
|
||||||
|
- PLATFORM:
|
||||||
|
- For Windows we want `windows-msvc-shared`.
|
||||||
|
- For Linux we want `unknown-linux-gnu`.
|
||||||
|
|
||||||
|
- OPTIMIZATIONS: `pgo+lto` for fully optimized builds.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
- Linux Python 3.12.3, release 20240415: `cpython-3.12.3+20240415-x86_64-unknown-linux-gnu-pgo+lto-full.tar.zst`
|
||||||
|
|
||||||
|
- Windows Python 3.12.3, release 20240415: `cpython-3.12.3+20240415-x86_64-pc-windows-msvc-shared-pgo-full.tar.zst`
|
||||||
|
|
||||||
|
See here for more info:
|
||||||
|
|
||||||
|
https://gregoryszorc.com/docs/python-build-standalone/20240415/running.html
|
||||||
|
|
||||||
|
### 2. Stick them in this directory
|
||||||
|
|
||||||
|
### 3. Run the conversion script
|
||||||
|
|
||||||
|
Run `./convert_zsts.bsh` in this directory.
|
||||||
|
|
||||||
|
### 4. Add them to git
|
||||||
|
|
21
addons/KiriPythonRPCWrapper/StandalonePythonBuilds/convert_zsts.bsh
Executable file
21
addons/KiriPythonRPCWrapper/StandalonePythonBuilds/convert_zsts.bsh
Executable file
@ -0,0 +1,21 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Why do we do this? Because Godot can read zip files but not zst
|
||||||
|
# files. But we still want to preserve the file attributes in the .tar
|
||||||
|
# archive.
|
||||||
|
|
||||||
|
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
|
# Decompress zsts...
|
||||||
|
for ZSTFILE in *.tar.zst; do
|
||||||
|
if [ \! -e "$(basename ${ZSTFILE} .zst)" ]; then
|
||||||
|
zstd -d "${ZSTFILE}"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# Recompress zips...
|
||||||
|
for TARFILE in *.tar; do
|
||||||
|
zip "${TARFILE}.zip" "${TARFILE}"
|
||||||
|
done
|
||||||
|
|
75
addons/KiriPythonRPCWrapper/StandalonePythonBuilds/update.bsh
Executable file
75
addons/KiriPythonRPCWrapper/StandalonePythonBuilds/update.bsh
Executable file
@ -0,0 +1,75 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# I am writing this with an ocular migraine and it's hard as shit to
|
||||||
|
# real the goddamn code, so please excuse any obvious mistakes. I
|
||||||
|
# literally cannot see them right now.
|
||||||
|
|
||||||
|
PYTHON_VERSIONS="3.12.3"
|
||||||
|
|
||||||
|
# TODO: Add more to this list if we want to support more platforms.
|
||||||
|
PYTHON_PLATFORM_CONFIGS="x86_64-pc-windows-msvc-shared-pgo-full x86_64-unknown-linux-gnu-pgo+lto-full"
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
|
# wget \
|
||||||
|
# https://raw.githubusercontent.com/indygreg/python-build-standalone/latest-release/latest-release.json \
|
||||||
|
# -o latest-release.json
|
||||||
|
|
||||||
|
RELEASE_PARTS=$(curl https://raw.githubusercontent.com/indygreg/python-build-standalone/latest-release/latest-release.json | \
|
||||||
|
python3 -c "import json; import sys; d = json.loads(sys.stdin.read()); print(d[\"tag\"]); print(d[\"asset_url_prefix\"]);")
|
||||||
|
|
||||||
|
|
||||||
|
RELEASE_TAG="$(echo $RELEASE_PARTS | cut -d" " -f 1)"
|
||||||
|
RELEASE_BASE_URL="$(echo $RELEASE_PARTS | cut -d" " -f 2)"
|
||||||
|
|
||||||
|
echo $RELEASE_TAG
|
||||||
|
echo $RELEASE_BASE_URL
|
||||||
|
|
||||||
|
echo "Fetching new files from release..."
|
||||||
|
|
||||||
|
for PYTHON_VERSION in $PYTHON_VERSIONS; do
|
||||||
|
|
||||||
|
for CONFIG in $PYTHON_PLATFORM_CONFIGS; do
|
||||||
|
FILENAME="cpython-${PYTHON_VERSION}+${RELEASE_TAG}-$CONFIG.tar.zst"
|
||||||
|
if [ \! -e "${FILENAME}" ] ; then
|
||||||
|
wget "${RELEASE_BASE_URL}/${FILENAME}"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "Decompressing zsts..."
|
||||||
|
|
||||||
|
# Decompress zsts...
|
||||||
|
for ZSTFILE in *.tar.zst; do
|
||||||
|
if [ \! -e "$(basename ${ZSTFILE} .zst)" ]; then
|
||||||
|
zstd -d "${ZSTFILE}"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "Compressing zips..."
|
||||||
|
|
||||||
|
# Recompress zips...
|
||||||
|
for TARFILE in *.tar; do
|
||||||
|
if [ \! -e "${TARFILE}.zip" ]; then
|
||||||
|
zip "${TARFILE}.zip" "${TARFILE}"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# Write version data.
|
||||||
|
# FIXME: Extremely dirty and hacky.
|
||||||
|
echo "{ \"release\" : \"${RELEASE_TAG}\", \"versions\" : [" > python_release_info.json
|
||||||
|
FIRST="1"
|
||||||
|
for PYTHON_VERSION in $PYTHON_VERSIONS; do
|
||||||
|
if [ "$FIRST" == "0" ]; then
|
||||||
|
echo "," >> python_release_info.json
|
||||||
|
fi
|
||||||
|
FIRST=0
|
||||||
|
echo "\"${PYTHON_VERSION}\"" >> python_release_info.json
|
||||||
|
done
|
||||||
|
echo "]" >> python_release_info.json
|
||||||
|
echo "}" >> python_release_info.json
|
||||||
|
|
||||||
|
|
26
addons/KiriPythonRPCWrapper/TODO.md
Normal file
26
addons/KiriPythonRPCWrapper/TODO.md
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
Done:
|
||||||
|
x Handle bundling of the actual Python modules we want to use.
|
||||||
|
x Remove dependency on psutil.
|
||||||
|
x Clean up removal of psutil.
|
||||||
|
x remove parent_pid from wrapper script
|
||||||
|
x remove KiriPythonRPCWrapper_start.py
|
||||||
|
x remove test_rpc.py
|
||||||
|
x Un-thread the GDScript side of PacketSocket.
|
||||||
|
x Fix whatever this is: <stuff was here>
|
||||||
|
x example Python module from OUTSIDE the addon
|
||||||
|
x Remove xterm dependency, or make it like a debug-only thing.
|
||||||
|
x Test on WINE/Windows.
|
||||||
|
x First-time setup of requirements (pip, etc).
|
||||||
|
x Deal with interrupted setup operations
|
||||||
|
x We check for the python.exe file in a given setup location to see if
|
||||||
|
we need to unpack stuff, but what if that exists but the setup was
|
||||||
|
interrupted and we're missing files?
|
||||||
|
x Deal with bad state after interrupted unpacking operation
|
||||||
|
|
||||||
|
The big ones:
|
||||||
|
|
||||||
|
- Add some kind of progress bar, or API for progress tracking, for the unpacking.
|
||||||
|
- Progress bar or API for progress tracking for pip installs.
|
||||||
|
- Maybe we should parse the pip requirements.txt and also set up an API for calling pip install.
|
||||||
|
- Documentation.
|
||||||
|
- how to use .kiri_export_python
|
@ -1,11 +0,0 @@
|
|||||||
@tool
|
|
||||||
extends EditorPlugin
|
|
||||||
|
|
||||||
func _enter_tree():
|
|
||||||
# Initialization of the plugin goes here.
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
func _exit_tree():
|
|
||||||
# Clean-up of the plugin goes here.
|
|
||||||
pass
|
|
@ -1,154 +0,0 @@
|
|||||||
#!/usr/bin/python3
|
|
||||||
|
|
||||||
import importlib.util
|
|
||||||
import sys
|
|
||||||
import argparse
|
|
||||||
import time
|
|
||||||
import psutil
|
|
||||||
import json
|
|
||||||
|
|
||||||
import KiriPacketSocket
|
|
||||||
|
|
||||||
# Parse arguments
|
|
||||||
arg_parser = argparse.ArgumentParser(
|
|
||||||
prog="KiriPythonRPCWrapper",
|
|
||||||
description="Wrapper for Python modules to RPCs from Godot.",
|
|
||||||
epilog="")
|
|
||||||
|
|
||||||
arg_parser.add_argument("--script", type=str, required=True)
|
|
||||||
arg_parser.add_argument("--port", type=int, required=True)
|
|
||||||
arg_parser.add_argument("--parent_pid", type=int, required=True)
|
|
||||||
|
|
||||||
args = arg_parser.parse_args()
|
|
||||||
|
|
||||||
|
|
||||||
# module_path = "../KiriPacketSocket/__init__.py"
|
|
||||||
# module_name = "KiriPacketSocket"
|
|
||||||
|
|
||||||
module_path = args.script
|
|
||||||
module_name = ""
|
|
||||||
|
|
||||||
# Attempt to load the module.
|
|
||||||
module_spec = importlib.util.spec_from_file_location(
|
|
||||||
module_name, module_path)
|
|
||||||
module = importlib.util.module_from_spec(module_spec)
|
|
||||||
module_spec.loader.exec_module(module)
|
|
||||||
|
|
||||||
# This will be all the functions we find in the module that don't
|
|
||||||
# start with "_".
|
|
||||||
known_entrypoints = {}
|
|
||||||
|
|
||||||
# Scan the module for "public" functions.
|
|
||||||
for entrypoint in dir(module):
|
|
||||||
|
|
||||||
# Skip anything starting with "_". Probably not meant to be
|
|
||||||
# exposed.
|
|
||||||
if entrypoint.startswith("_"):
|
|
||||||
continue
|
|
||||||
|
|
||||||
attr = getattr(module, entrypoint)
|
|
||||||
|
|
||||||
# if hasattr(attr, "__call__"):
|
|
||||||
if callable(attr):
|
|
||||||
known_entrypoints[entrypoint] = attr
|
|
||||||
|
|
||||||
# Connect to server.
|
|
||||||
packet_socket = KiriPacketSocket.PacketSocket()
|
|
||||||
packet_socket.start_client(("127.0.0.1", args.port))
|
|
||||||
while packet_socket.get_state() == packet_socket.SocketState.CONNECTING:
|
|
||||||
time.sleep(0.001)
|
|
||||||
|
|
||||||
if packet_socket.get_state() != packet_socket.SocketState.CONNECTED:
|
|
||||||
packet_socket.stop()
|
|
||||||
raise Exception("Failed to connect to RPC host.")
|
|
||||||
|
|
||||||
print("Starting packet processing.")
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def send_error_response(code, message, request_id):
|
|
||||||
ret_dict = {
|
|
||||||
"jsonrpc" : "2.0",
|
|
||||||
"error" : {
|
|
||||||
"code" : code,
|
|
||||||
"message" : message
|
|
||||||
},
|
|
||||||
"id" : request_id
|
|
||||||
}
|
|
||||||
ret_dict_json = json.dumps(ret_dict)
|
|
||||||
packet_socket.send_packet(ret_dict_json.encode("utf-8"))
|
|
||||||
|
|
||||||
def send_response(result, request_id):
|
|
||||||
try:
|
|
||||||
ret_dict = {
|
|
||||||
"jsonrpc" : "2.0",
|
|
||||||
"result" : ret,
|
|
||||||
"id" : request_id
|
|
||||||
}
|
|
||||||
ret_dict_json = json.dumps(ret_dict)
|
|
||||||
packet_socket.send_packet(ret_dict_json.encode("utf-8"))
|
|
||||||
except Exception as e:
|
|
||||||
send_error_response(-32603, "Error sending result: " + str(e), request_id)
|
|
||||||
|
|
||||||
# Start processing packets.
|
|
||||||
while True:
|
|
||||||
|
|
||||||
# Shutdown when we lose connection to host.
|
|
||||||
if packet_socket.get_state() != packet_socket.SocketState.CONNECTED:
|
|
||||||
packet_socket.stop()
|
|
||||||
raise Exception("Disconnected from RPC host.")
|
|
||||||
|
|
||||||
# Watch parent PID so we can clean up when needed.
|
|
||||||
if not psutil.pid_exists(args.parent_pid):
|
|
||||||
packet_socket.stop()
|
|
||||||
raise Exception("RPC host process died")
|
|
||||||
|
|
||||||
next_packet = packet_socket.get_next_packet()
|
|
||||||
while next_packet:
|
|
||||||
this_packet = next_packet
|
|
||||||
next_packet = packet_socket.get_next_packet()
|
|
||||||
|
|
||||||
print("GOT PACKET: ", this_packet)
|
|
||||||
|
|
||||||
# FIXME: Handle batches.
|
|
||||||
|
|
||||||
# Parse the incoming dict.
|
|
||||||
try:
|
|
||||||
request_dict_json = this_packet.decode("utf-8")
|
|
||||||
request_dict = json.loads(request_dict_json)
|
|
||||||
except Exception as e:
|
|
||||||
send_error_response(-32700, "Error parsing packet: " + str(e), request_id)
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Make sure all the fields are there.
|
|
||||||
try:
|
|
||||||
method = request_dict["method"]
|
|
||||||
func_args = request_dict["params"]
|
|
||||||
request_id = request_dict["id"]
|
|
||||||
except Exception as e:
|
|
||||||
send_error_response(-32602, "Missing field: " + str(e), request_id)
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Make sure the method is something we scanned earlier.
|
|
||||||
try:
|
|
||||||
func = known_entrypoints[method]
|
|
||||||
except Exception as e:
|
|
||||||
send_error_response(-32601, "Method not found: " + str(e), request_id)
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Call the dang function.
|
|
||||||
try:
|
|
||||||
ret = func(*func_args)
|
|
||||||
except Exception as e:
|
|
||||||
send_error_response(-32603, "Call failed: " + str(e), request_id)
|
|
||||||
continue
|
|
||||||
|
|
||||||
send_response(ret, request_id)
|
|
||||||
|
|
||||||
time.sleep(0.0001)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
|||||||
#!/usr/bin/python3
|
|
||||||
|
|
||||||
import time
|
|
||||||
|
|
||||||
try:
|
|
||||||
import KiriPythonRPCWrapper
|
|
||||||
except Exception as e:
|
|
||||||
print(e)
|
|
||||||
time.sleep(5)
|
|
||||||
|
|
@ -1,219 +0,0 @@
|
|||||||
extends RefCounted
|
|
||||||
class_name KiriPythonWrapperInstance
|
|
||||||
|
|
||||||
var external_process_pid = -1
|
|
||||||
|
|
||||||
var server_packet_socket : KiriPacketSocket = null
|
|
||||||
var communication_packet_socket : KiriPacketSocket = null
|
|
||||||
|
|
||||||
var python_script_path : String = ""
|
|
||||||
|
|
||||||
enum KiriPythonWrapperStatus {
|
|
||||||
STATUS_RUNNING,
|
|
||||||
STATUS_STOPPED
|
|
||||||
}
|
|
||||||
|
|
||||||
func _init(python_file_path : String):
|
|
||||||
python_script_path = python_file_path
|
|
||||||
|
|
||||||
func _get_python_executable():
|
|
||||||
# FIXME: Adjust per-OS. Maybe test a few locations.
|
|
||||||
return "/usr/bin/python3"
|
|
||||||
|
|
||||||
func _get_wrapper_script():
|
|
||||||
# FIXME: Paths will be different for builds.
|
|
||||||
var script_path = self.get_script().get_path()
|
|
||||||
var script_dirname = script_path.get_base_dir()
|
|
||||||
return ProjectSettings.globalize_path( \
|
|
||||||
script_dirname + "/KiriPythonRPCWrapper_start.py")
|
|
||||||
|
|
||||||
func get_status():
|
|
||||||
|
|
||||||
if external_process_pid == -1:
|
|
||||||
return KiriPythonWrapperStatus.STATUS_STOPPED
|
|
||||||
|
|
||||||
if not OS.is_process_running(external_process_pid):
|
|
||||||
return KiriPythonWrapperStatus.STATUS_STOPPED
|
|
||||||
|
|
||||||
return KiriPythonWrapperStatus.STATUS_RUNNING
|
|
||||||
|
|
||||||
func start_process():
|
|
||||||
|
|
||||||
# FIXME: Make sure we don't have one running.
|
|
||||||
|
|
||||||
var open_port = 9500
|
|
||||||
|
|
||||||
assert(not server_packet_socket)
|
|
||||||
server_packet_socket = KiriPacketSocket.new()
|
|
||||||
while true:
|
|
||||||
server_packet_socket.start_server(["127.0.0.1", open_port])
|
|
||||||
|
|
||||||
# Wait for the server to start.
|
|
||||||
while server_packet_socket.get_state() == KiriPacketSocket.KiriSocketState.SERVER_STARTING:
|
|
||||||
OS.delay_usec(1)
|
|
||||||
|
|
||||||
# If we're successfully listening, then we found a port to use and we
|
|
||||||
# don't need to loop anymore.
|
|
||||||
if server_packet_socket.get_state() == KiriPacketSocket.KiriSocketState.SERVER_LISTENING:
|
|
||||||
break
|
|
||||||
|
|
||||||
# This port is busy. Try the next one.
|
|
||||||
server_packet_socket.stop()
|
|
||||||
open_port += 1
|
|
||||||
|
|
||||||
print("Port: ", open_port)
|
|
||||||
|
|
||||||
var python_exe_path : String = _get_python_executable()
|
|
||||||
var wrapper_script_path : String = _get_wrapper_script()
|
|
||||||
|
|
||||||
var startup_command : Array = [
|
|
||||||
"xterm", "-e",
|
|
||||||
python_exe_path,
|
|
||||||
wrapper_script_path,
|
|
||||||
"--script", python_script_path,
|
|
||||||
"--port", open_port,
|
|
||||||
"--parent_pid", OS.get_process_id()]
|
|
||||||
|
|
||||||
print("startup command: ", startup_command)
|
|
||||||
|
|
||||||
external_process_pid = OS.create_process(
|
|
||||||
startup_command[0], startup_command.slice(1), true)
|
|
||||||
|
|
||||||
print("external process: ", external_process_pid)
|
|
||||||
|
|
||||||
func stop_process():
|
|
||||||
|
|
||||||
if external_process_pid != -1:
|
|
||||||
OS.kill(external_process_pid)
|
|
||||||
external_process_pid = -1
|
|
||||||
|
|
||||||
|
|
||||||
# Clean up server and communication sockets.
|
|
||||||
if server_packet_socket:
|
|
||||||
server_packet_socket.stop()
|
|
||||||
server_packet_socket = null
|
|
||||||
|
|
||||||
if communication_packet_socket:
|
|
||||||
communication_packet_socket.stop()
|
|
||||||
communication_packet_socket = null
|
|
||||||
|
|
||||||
|
|
||||||
class KiriPythonWrapperActiveRequest:
|
|
||||||
|
|
||||||
enum KiriPythonWrapperActiveRequestState {
|
|
||||||
STATE_WAITING_TO_SEND,
|
|
||||||
STATE_SENT,
|
|
||||||
STATE_RESPONSE_RECEIVED
|
|
||||||
}
|
|
||||||
|
|
||||||
var id : int
|
|
||||||
var method_name : String
|
|
||||||
var arguments : Variant # Dictionary or Array
|
|
||||||
var callback # Callable or null
|
|
||||||
var state : KiriPythonWrapperActiveRequestState
|
|
||||||
var response # Return value from the call
|
|
||||||
var error_response = ""
|
|
||||||
|
|
||||||
var _active_request_queue = {}
|
|
||||||
var _request_counter = 0
|
|
||||||
|
|
||||||
func call_rpc_async(method : String, args : Variant, callback = null) -> int:
|
|
||||||
|
|
||||||
assert((args is Dictionary) or (args is Array))
|
|
||||||
assert((callback == null) or (callback is Callable))
|
|
||||||
|
|
||||||
var new_request = KiriPythonWrapperActiveRequest.new()
|
|
||||||
new_request.id = _request_counter
|
|
||||||
_request_counter += 1
|
|
||||||
new_request.method_name = method
|
|
||||||
new_request.arguments = args
|
|
||||||
new_request.callback = callback
|
|
||||||
|
|
||||||
_active_request_queue[new_request.id] = new_request
|
|
||||||
|
|
||||||
return new_request.id
|
|
||||||
|
|
||||||
func call_rpc_sync(method : String, args : Variant):
|
|
||||||
|
|
||||||
# Kinda hacky. We're using arrays because we can change the contents.
|
|
||||||
# Without the array or something else mutable we'd just end up with the
|
|
||||||
# internal pointer pointing to different values without affecting these
|
|
||||||
# ones.
|
|
||||||
var done_array = [false]
|
|
||||||
var response_list = []
|
|
||||||
var request_id = call_rpc_async(method, args, func(request_ob):
|
|
||||||
done_array[0] = true
|
|
||||||
response_list.append(request_ob.response)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Wait (block) until we get a response.
|
|
||||||
while not done_array[0]:
|
|
||||||
|
|
||||||
# Bail out if something happened to our instance or connection to it.
|
|
||||||
if communication_packet_socket:
|
|
||||||
if communication_packet_socket.is_disconnected_or_error():
|
|
||||||
break
|
|
||||||
|
|
||||||
poll()
|
|
||||||
OS.delay_usec(1)
|
|
||||||
|
|
||||||
if len(response_list):
|
|
||||||
return response_list[0]
|
|
||||||
|
|
||||||
return null
|
|
||||||
|
|
||||||
func poll():
|
|
||||||
|
|
||||||
# Hand-off between listening socket and actual communications socket.
|
|
||||||
if server_packet_socket:
|
|
||||||
communication_packet_socket = server_packet_socket.get_next_server_connection()
|
|
||||||
if communication_packet_socket:
|
|
||||||
server_packet_socket.stop()
|
|
||||||
server_packet_socket = null
|
|
||||||
|
|
||||||
if communication_packet_socket:
|
|
||||||
|
|
||||||
# Send all waiting requests
|
|
||||||
for request_id in _active_request_queue:
|
|
||||||
var request : KiriPythonWrapperActiveRequest = _active_request_queue[request_id]
|
|
||||||
if request.state == request.KiriPythonWrapperActiveRequestState.STATE_WAITING_TO_SEND:
|
|
||||||
var request_dict = {
|
|
||||||
"jsonrpc": "2.0",
|
|
||||||
"method": request.method_name,
|
|
||||||
"params": request.arguments,
|
|
||||||
"id": request.id
|
|
||||||
}
|
|
||||||
var request_dict_json = JSON.stringify(request_dict)
|
|
||||||
communication_packet_socket.send_packet(request_dict_json.to_utf8_buffer())
|
|
||||||
request.state = request.KiriPythonWrapperActiveRequestState.STATE_SENT
|
|
||||||
|
|
||||||
# Check for responses.
|
|
||||||
var packet = communication_packet_socket.get_next_packet()
|
|
||||||
while packet != null:
|
|
||||||
var packet_dict = JSON.parse_string(packet.get_string_from_utf8())
|
|
||||||
if packet_dict:
|
|
||||||
if packet_dict.has("id"):
|
|
||||||
var request_id = packet_dict["id"]
|
|
||||||
|
|
||||||
# floats aren't even allowed in JSON RPC as an id. Probably
|
|
||||||
# meant it to be an int.
|
|
||||||
if request_id is float:
|
|
||||||
request_id = int(request_id)
|
|
||||||
|
|
||||||
if _active_request_queue.has(request_id):
|
|
||||||
var request : KiriPythonWrapperActiveRequest = \
|
|
||||||
_active_request_queue[request_id]
|
|
||||||
if "result" in packet_dict:
|
|
||||||
request.response = packet_dict["result"]
|
|
||||||
else:
|
|
||||||
request.error_response = "Couldn't find result on packet."
|
|
||||||
if request.callback:
|
|
||||||
request.callback.call(request)
|
|
||||||
|
|
||||||
# Clean up request.
|
|
||||||
_active_request_queue.erase(request_id)
|
|
||||||
|
|
||||||
packet = communication_packet_socket.get_next_packet()
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
|||||||
#!/usr/bin/python3
|
|
||||||
|
|
||||||
import time
|
|
||||||
|
|
||||||
import KiriPacketSocket
|
|
||||||
|
|
||||||
ps = KiriPacketSocket.PacketSocket()
|
|
||||||
ps.start_server(("127.0.0.1", 9506))
|
|
||||||
|
|
||||||
connections = []
|
|
||||||
|
|
||||||
while True:
|
|
||||||
|
|
||||||
psc = ps.get_next_server_connection()
|
|
||||||
if psc:
|
|
||||||
print("Got connection.")
|
|
||||||
connections.append(psc)
|
|
||||||
psc.send_packet(b'ABCDEFGHIJ')
|
|
||||||
|
|
||||||
|
|
||||||
for conn in connections:
|
|
||||||
p = conn.get_next_packet()
|
|
||||||
while p:
|
|
||||||
print(p)
|
|
||||||
conn.send_packet(p + b'1')
|
|
||||||
p = conn.get_next_packet()
|
|
||||||
|
|
||||||
time.sleep(0.0001)
|
|
102
export_presets.cfg
Normal file
102
export_presets.cfg
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
[preset.0]
|
||||||
|
|
||||||
|
name="Linux/X11"
|
||||||
|
platform="Linux/X11"
|
||||||
|
runnable=true
|
||||||
|
dedicated_server=false
|
||||||
|
custom_features=""
|
||||||
|
export_filter="all_resources"
|
||||||
|
include_filter=""
|
||||||
|
exclude_filter=""
|
||||||
|
export_path="./GodotJSONRPCTest.x86_64"
|
||||||
|
encryption_include_filters=""
|
||||||
|
encryption_exclude_filters=""
|
||||||
|
encrypt_pck=false
|
||||||
|
encrypt_directory=false
|
||||||
|
|
||||||
|
[preset.0.options]
|
||||||
|
|
||||||
|
custom_template/debug=""
|
||||||
|
custom_template/release=""
|
||||||
|
debug/export_console_wrapper=1
|
||||||
|
binary_format/embed_pck=false
|
||||||
|
texture_format/bptc=true
|
||||||
|
texture_format/s3tc=true
|
||||||
|
texture_format/etc=false
|
||||||
|
texture_format/etc2=false
|
||||||
|
binary_format/architecture="x86_64"
|
||||||
|
ssh_remote_deploy/enabled=false
|
||||||
|
ssh_remote_deploy/host="user@host_ip"
|
||||||
|
ssh_remote_deploy/port="22"
|
||||||
|
ssh_remote_deploy/extra_args_ssh=""
|
||||||
|
ssh_remote_deploy/extra_args_scp=""
|
||||||
|
ssh_remote_deploy/run_script="#!/usr/bin/env bash
|
||||||
|
export DISPLAY=:0
|
||||||
|
unzip -o -q \"{temp_dir}/{archive_name}\" -d \"{temp_dir}\"
|
||||||
|
\"{temp_dir}/{exe_name}\" {cmd_args}"
|
||||||
|
ssh_remote_deploy/cleanup_script="#!/usr/bin/env bash
|
||||||
|
kill $(pgrep -x -f \"{temp_dir}/{exe_name} {cmd_args}\")
|
||||||
|
rm -rf \"{temp_dir}\""
|
||||||
|
|
||||||
|
[preset.1]
|
||||||
|
|
||||||
|
name="Windows Desktop"
|
||||||
|
platform="Windows Desktop"
|
||||||
|
runnable=true
|
||||||
|
dedicated_server=false
|
||||||
|
custom_features=""
|
||||||
|
export_filter="all_resources"
|
||||||
|
include_filter=""
|
||||||
|
exclude_filter=""
|
||||||
|
export_path="./GodotJSONRPCTest.exe"
|
||||||
|
encryption_include_filters=""
|
||||||
|
encryption_exclude_filters=""
|
||||||
|
encrypt_pck=false
|
||||||
|
encrypt_directory=false
|
||||||
|
|
||||||
|
[preset.1.options]
|
||||||
|
|
||||||
|
custom_template/debug=""
|
||||||
|
custom_template/release=""
|
||||||
|
debug/export_console_wrapper=1
|
||||||
|
binary_format/embed_pck=false
|
||||||
|
texture_format/bptc=true
|
||||||
|
texture_format/s3tc=true
|
||||||
|
texture_format/etc=false
|
||||||
|
texture_format/etc2=false
|
||||||
|
binary_format/architecture="x86_64"
|
||||||
|
codesign/enable=false
|
||||||
|
codesign/timestamp=true
|
||||||
|
codesign/timestamp_server_url=""
|
||||||
|
codesign/digest_algorithm=1
|
||||||
|
codesign/description=""
|
||||||
|
codesign/custom_options=PackedStringArray()
|
||||||
|
application/modify_resources=true
|
||||||
|
application/icon=""
|
||||||
|
application/console_wrapper_icon=""
|
||||||
|
application/icon_interpolation=4
|
||||||
|
application/file_version=""
|
||||||
|
application/product_version=""
|
||||||
|
application/company_name=""
|
||||||
|
application/product_name=""
|
||||||
|
application/file_description=""
|
||||||
|
application/copyright=""
|
||||||
|
application/trademarks=""
|
||||||
|
application/export_angle=0
|
||||||
|
ssh_remote_deploy/enabled=false
|
||||||
|
ssh_remote_deploy/host="user@host_ip"
|
||||||
|
ssh_remote_deploy/port="22"
|
||||||
|
ssh_remote_deploy/extra_args_ssh=""
|
||||||
|
ssh_remote_deploy/extra_args_scp=""
|
||||||
|
ssh_remote_deploy/run_script="Expand-Archive -LiteralPath '{temp_dir}\\{archive_name}' -DestinationPath '{temp_dir}'
|
||||||
|
$action = New-ScheduledTaskAction -Execute '{temp_dir}\\{exe_name}' -Argument '{cmd_args}'
|
||||||
|
$trigger = New-ScheduledTaskTrigger -Once -At 00:00
|
||||||
|
$settings = New-ScheduledTaskSettingsSet
|
||||||
|
$task = New-ScheduledTask -Action $action -Trigger $trigger -Settings $settings
|
||||||
|
Register-ScheduledTask godot_remote_debug -InputObject $task -Force:$true
|
||||||
|
Start-ScheduledTask -TaskName godot_remote_debug
|
||||||
|
while (Get-ScheduledTask -TaskName godot_remote_debug | ? State -eq running) { Start-Sleep -Milliseconds 100 }
|
||||||
|
Unregister-ScheduledTask -TaskName godot_remote_debug -Confirm:$false -ErrorAction:SilentlyContinue"
|
||||||
|
ssh_remote_deploy/cleanup_script="Stop-ScheduledTask -TaskName godot_remote_debug -ErrorAction:SilentlyContinue
|
||||||
|
Unregister-ScheduledTask -TaskName godot_remote_debug -Confirm:$false -ErrorAction:SilentlyContinue
|
||||||
|
Remove-Item -Recurse -Force '{temp_dir}'"
|
@ -11,6 +11,7 @@ config_version=5
|
|||||||
[application]
|
[application]
|
||||||
|
|
||||||
config/name="GodotJSONRPCTest"
|
config/name="GodotJSONRPCTest"
|
||||||
|
run/main_scene="res://TarTest.tscn"
|
||||||
config/features=PackedStringArray("4.2", "GL Compatibility")
|
config/features=PackedStringArray("4.2", "GL Compatibility")
|
||||||
run/max_fps=60
|
run/max_fps=60
|
||||||
config/icon="res://icon.svg"
|
config/icon="res://icon.svg"
|
||||||
@ -21,7 +22,7 @@ window/vsync/vsync_mode=0
|
|||||||
|
|
||||||
[editor_plugins]
|
[editor_plugins]
|
||||||
|
|
||||||
enabled=PackedStringArray("res://addons/kiripythonrpcwrapper/plugin.cfg")
|
enabled=PackedStringArray("res://addons/KiriPythonRPCWrapper/plugin.cfg")
|
||||||
|
|
||||||
[rendering]
|
[rendering]
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user