GodotPythonJSONRPC/addons/KiriPythonRPCWrapper/KiriPythonBuildWrangler.gd
2024-07-14 12:58:20 -07:00

300 lines
9.7 KiB
GDScript

# 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"
func _get_cache_path_system() -> String:
return OS.get_user_data_dir().path_join(_get_cache_path_relative())
# 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 unpack_python(overwrite : bool = false):
var cache_path_godot : String = _get_cache_path_godot()
# Check to see if the Python executable already exists. If it does, we might
# just skip unpacking.
var python_executable_expected_path : String = \
get_runtime_python_executable_godot_path()
if not overwrite:
if FileAccess.file_exists(python_executable_expected_path):
return
# 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)
# 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)
# 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