2024-07-14 12:58:20 -07:00
|
|
|
# Python build wrangler
|
|
|
|
#
|
|
|
|
# This handles extracting and juggling standalone Python builds per-platform.
|
|
|
|
|
2024-07-14 10:23:19 -07:00
|
|
|
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()
|
2024-07-14 12:58:20 -07:00
|
|
|
var reader : KiriTARReader = KiriTARReader.new()
|
2024-07-14 10:23:19 -07:00
|
|
|
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.
|
|
|
|
|
2024-07-14 12:58:20 -07:00
|
|
|
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
|
|
|
|
|
2024-07-14 10:23:19 -07:00
|
|
|
#endregion
|