Core: hot reload components from installed apworld (#3480)

* Core: hot reload components from installed apworld

* address PR reviews

`Launcher` widget members default to `None` so they can be defined in `build`

`Launcher._refresh_components` is not wrapped

loaded world goes into `world_sources` so we can check if it's already loaded.
(`WorldSource` can be ordered now without trying to compare `None` and `float`)
(don't load empty directories so we don't detect them as worlds)

* clarify that the installation is successful
This commit is contained in:
Doug Hoskisson
2024-06-06 11:36:14 -07:00
committed by GitHub
parent 808f2a8ff0
commit 6bb1cce43f
6 changed files with 110 additions and 27 deletions

View File

@@ -1,3 +1,4 @@
import bisect
import logging
import pathlib
import weakref
@@ -94,9 +95,10 @@ def _install_apworld(apworld_src: str = "") -> Optional[Tuple[pathlib.Path, path
apworld_path = pathlib.Path(apworld_src)
module_name = pathlib.Path(apworld_path.name).stem
try:
import zipfile
zipfile.ZipFile(apworld_path).open(pathlib.Path(apworld_path.name).stem + "/__init__.py")
zipfile.ZipFile(apworld_path).open(module_name + "/__init__.py")
except ValueError as e:
raise Exception("Archive appears invalid or damaged.") from e
except KeyError as e:
@@ -107,6 +109,9 @@ def _install_apworld(apworld_src: str = "") -> Optional[Tuple[pathlib.Path, path
raise Exception("Custom Worlds directory appears to not be writable.")
for world_source in worlds.world_sources:
if apworld_path.samefile(world_source.resolved_path):
# Note that this doesn't check if the same world is already installed.
# It only checks if the user is trying to install the apworld file
# that comes from the installation location (worlds or custom_worlds)
raise Exception(f"APWorld is already installed at {world_source.resolved_path}.")
# TODO: run generic test suite over the apworld.
@@ -116,6 +121,22 @@ def _install_apworld(apworld_src: str = "") -> Optional[Tuple[pathlib.Path, path
import shutil
shutil.copyfile(apworld_path, target)
# If a module with this name is already loaded, then we can't load it now.
# TODO: We need to be able to unload a world module,
# so the user can update a world without restarting the application.
found_already_loaded = False
for loaded_world in worlds.world_sources:
loaded_name = pathlib.Path(loaded_world.path).stem
if module_name == loaded_name:
found_already_loaded = True
break
if found_already_loaded:
raise Exception(f"Installed APWorld successfully, but '{module_name}' is already loaded,\n"
"so a Launcher restart is required to use the new installation.")
world_source = worlds.WorldSource(str(target), is_zip=True)
bisect.insort(worlds.world_sources, world_source)
world_source.load()
return apworld_path, target

View File

@@ -1,11 +1,12 @@
import importlib
import logging
import os
import sys
import warnings
import zipimport
import time
import dataclasses
from typing import Dict, List, TypedDict, Optional
from typing import Dict, List, TypedDict
from Utils import local_path, user_path
@@ -48,7 +49,7 @@ class WorldSource:
path: str # typically relative path from this module
is_zip: bool = False
relative: bool = True # relative to regular world import folder
time_taken: Optional[float] = None
time_taken: float = -1.0
def __repr__(self) -> str:
return f"{self.__class__.__name__}({self.path}, is_zip={self.is_zip}, relative={self.relative})"
@@ -92,7 +93,6 @@ class WorldSource:
print(f"Could not load world {self}:", file=file_like)
traceback.print_exc(file=file_like)
file_like.seek(0)
import logging
logging.exception(file_like.read())
failed_world_loads.append(os.path.basename(self.path).rsplit(".", 1)[0])
return False
@@ -107,7 +107,11 @@ for folder in (folder for folder in (user_folder, local_folder) if folder):
if not entry.name.startswith(("_", ".")):
file_name = entry.name if relative else os.path.join(folder, entry.name)
if entry.is_dir():
world_sources.append(WorldSource(file_name, relative=relative))
init_file_path = os.path.join(entry.path, '__init__.py')
if os.path.isfile(init_file_path):
world_sources.append(WorldSource(file_name, relative=relative))
else:
logging.warning(f"excluding {entry.name} from world sources because it has no __init__.py")
elif entry.is_file() and entry.name.endswith(".apworld"):
world_sources.append(WorldSource(file_name, is_zip=True, relative=relative))