# 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//.local/share/godot/app_userdata//_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