Skip to content

Commit ba5e574

Browse files
beauxqwu4
authored andcommitted
Core: hot reload components from installed apworld (ArchipelagoMW#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
1 parent 7383d40 commit ba5e574

File tree

6 files changed

+110
-27
lines changed

6 files changed

+110
-27
lines changed

Launcher.py

+50-21
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
import webbrowser
2020
from os.path import isfile
2121
from shutil import which
22-
from typing import Sequence, Union, Optional
22+
from typing import Callable, Sequence, Union, Optional
2323

2424
import Utils
2525
import settings
@@ -160,6 +160,9 @@ def launch(exe, in_terminal=False):
160160
subprocess.Popen(exe)
161161

162162

163+
refresh_components: Optional[Callable[[], None]] = None
164+
165+
163166
def run_gui():
164167
from kvui import App, ContainerLayout, GridLayout, Button, Label, ScrollBox, Widget
165168
from kivy.core.window import Window
@@ -170,30 +173,16 @@ class Launcher(App):
170173
base_title: str = "Archipelago Launcher"
171174
container: ContainerLayout
172175
grid: GridLayout
173-
174-
_tools = {c.display_name: c for c in components if c.type == Type.TOOL}
175-
_clients = {c.display_name: c for c in components if c.type == Type.CLIENT}
176-
_adjusters = {c.display_name: c for c in components if c.type == Type.ADJUSTER}
177-
_miscs = {c.display_name: c for c in components if c.type == Type.MISC}
176+
_tool_layout: Optional[ScrollBox] = None
177+
_client_layout: Optional[ScrollBox] = None
178178

179179
def __init__(self, ctx=None):
180180
self.title = self.base_title
181181
self.ctx = ctx
182182
self.icon = r"data/icon.png"
183183
super().__init__()
184184

185-
def build(self):
186-
self.container = ContainerLayout()
187-
self.grid = GridLayout(cols=2)
188-
self.container.add_widget(self.grid)
189-
self.grid.add_widget(Label(text="General", size_hint_y=None, height=40))
190-
self.grid.add_widget(Label(text="Clients", size_hint_y=None, height=40))
191-
tool_layout = ScrollBox()
192-
tool_layout.layout.orientation = "vertical"
193-
self.grid.add_widget(tool_layout)
194-
client_layout = ScrollBox()
195-
client_layout.layout.orientation = "vertical"
196-
self.grid.add_widget(client_layout)
185+
def _refresh_components(self) -> None:
197186

198187
def build_button(component: Component) -> Widget:
199188
"""
@@ -218,14 +207,47 @@ def build_button(component: Component) -> Widget:
218207
return box_layout
219208
return button
220209

210+
# clear before repopulating
211+
assert self._tool_layout and self._client_layout, "must call `build` first"
212+
tool_children = reversed(self._tool_layout.layout.children)
213+
for child in tool_children:
214+
self._tool_layout.layout.remove_widget(child)
215+
client_children = reversed(self._client_layout.layout.children)
216+
for child in client_children:
217+
self._client_layout.layout.remove_widget(child)
218+
219+
_tools = {c.display_name: c for c in components if c.type == Type.TOOL}
220+
_clients = {c.display_name: c for c in components if c.type == Type.CLIENT}
221+
_adjusters = {c.display_name: c for c in components if c.type == Type.ADJUSTER}
222+
_miscs = {c.display_name: c for c in components if c.type == Type.MISC}
223+
221224
for (tool, client) in itertools.zip_longest(itertools.chain(
222-
self._tools.items(), self._miscs.items(), self._adjusters.items()), self._clients.items()):
225+
_tools.items(), _miscs.items(), _adjusters.items()
226+
), _clients.items()):
223227
# column 1
224228
if tool:
225-
tool_layout.layout.add_widget(build_button(tool[1]))
229+
self._tool_layout.layout.add_widget(build_button(tool[1]))
226230
# column 2
227231
if client:
228-
client_layout.layout.add_widget(build_button(client[1]))
232+
self._client_layout.layout.add_widget(build_button(client[1]))
233+
234+
def build(self):
235+
self.container = ContainerLayout()
236+
self.grid = GridLayout(cols=2)
237+
self.container.add_widget(self.grid)
238+
self.grid.add_widget(Label(text="General", size_hint_y=None, height=40))
239+
self.grid.add_widget(Label(text="Clients", size_hint_y=None, height=40))
240+
self._tool_layout = ScrollBox()
241+
self._tool_layout.layout.orientation = "vertical"
242+
self.grid.add_widget(self._tool_layout)
243+
self._client_layout = ScrollBox()
244+
self._client_layout.layout.orientation = "vertical"
245+
self.grid.add_widget(self._client_layout)
246+
247+
self._refresh_components()
248+
249+
global refresh_components
250+
refresh_components = self._refresh_components
229251

230252
Window.bind(on_drop_file=self._on_drop_file)
231253

@@ -254,10 +276,17 @@ def _stop(self, *largs):
254276

255277
Launcher().run()
256278

279+
# avoiding Launcher reference leak
280+
# and don't try to do something with widgets after window closed
281+
global refresh_components
282+
refresh_components = None
283+
257284

258285
def run_component(component: Component, *args):
259286
if component.func:
260287
component.func(*args)
288+
if refresh_components:
289+
refresh_components()
261290
elif component.script_name:
262291
subprocess.run([*get_exe(component.script_name), *args])
263292
else:

typings/kivy/uix/boxlayout.pyi

+6
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
from typing import Literal
2+
from .layout import Layout
3+
4+
5+
class BoxLayout(Layout):
6+
orientation: Literal['horizontal', 'vertical']

typings/kivy/uix/layout.pyi

+7-1
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,14 @@
1-
from typing import Any
1+
from typing import Any, Sequence
2+
23
from .widget import Widget
34

45

56
class Layout(Widget):
7+
@property
8+
def children(self) -> Sequence[Widget]: ...
9+
610
def add_widget(self, widget: Widget) -> None: ...
711

12+
def remove_widget(self, widget: Widget) -> None: ...
13+
814
def do_layout(self, *largs: Any, **kwargs: Any) -> None: ...

typings/schema/__init__.pyi

+17
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
from typing import Any, Callable
2+
3+
4+
class And:
5+
def __init__(self, __type: type, __func: Callable[[Any], bool]) -> None: ...
6+
7+
8+
class Or:
9+
def __init__(self, *args: object) -> None: ...
10+
11+
12+
class Schema:
13+
def __init__(self, __x: object) -> None: ...
14+
15+
16+
class Optional(Schema):
17+
...

worlds/LauncherComponents.py

+22-1
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import bisect
12
import logging
23
import pathlib
34
import weakref
@@ -94,9 +95,10 @@ def _install_apworld(apworld_src: str = "") -> Optional[Tuple[pathlib.Path, path
9495

9596
apworld_path = pathlib.Path(apworld_src)
9697

98+
module_name = pathlib.Path(apworld_path.name).stem
9799
try:
98100
import zipfile
99-
zipfile.ZipFile(apworld_path).open(pathlib.Path(apworld_path.name).stem + "/__init__.py")
101+
zipfile.ZipFile(apworld_path).open(module_name + "/__init__.py")
100102
except ValueError as e:
101103
raise Exception("Archive appears invalid or damaged.") from e
102104
except KeyError as e:
@@ -107,6 +109,9 @@ def _install_apworld(apworld_src: str = "") -> Optional[Tuple[pathlib.Path, path
107109
raise Exception("Custom Worlds directory appears to not be writable.")
108110
for world_source in worlds.world_sources:
109111
if apworld_path.samefile(world_source.resolved_path):
112+
# Note that this doesn't check if the same world is already installed.
113+
# It only checks if the user is trying to install the apworld file
114+
# that comes from the installation location (worlds or custom_worlds)
110115
raise Exception(f"APWorld is already installed at {world_source.resolved_path}.")
111116

112117
# TODO: run generic test suite over the apworld.
@@ -116,6 +121,22 @@ def _install_apworld(apworld_src: str = "") -> Optional[Tuple[pathlib.Path, path
116121
import shutil
117122
shutil.copyfile(apworld_path, target)
118123

124+
# If a module with this name is already loaded, then we can't load it now.
125+
# TODO: We need to be able to unload a world module,
126+
# so the user can update a world without restarting the application.
127+
found_already_loaded = False
128+
for loaded_world in worlds.world_sources:
129+
loaded_name = pathlib.Path(loaded_world.path).stem
130+
if module_name == loaded_name:
131+
found_already_loaded = True
132+
break
133+
if found_already_loaded:
134+
raise Exception(f"Installed APWorld successfully, but '{module_name}' is already loaded,\n"
135+
"so a Launcher restart is required to use the new installation.")
136+
world_source = worlds.WorldSource(str(target), is_zip=True)
137+
bisect.insort(worlds.world_sources, world_source)
138+
world_source.load()
139+
119140
return apworld_path, target
120141

121142

worlds/__init__.py

+8-4
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
import importlib
2+
import logging
23
import os
34
import sys
45
import warnings
56
import zipimport
67
import time
78
import dataclasses
8-
from typing import Dict, List, TypedDict, Optional
9+
from typing import Dict, List, TypedDict
910

1011
from Utils import local_path, user_path
1112

@@ -48,7 +49,7 @@ class WorldSource:
4849
path: str # typically relative path from this module
4950
is_zip: bool = False
5051
relative: bool = True # relative to regular world import folder
51-
time_taken: Optional[float] = None
52+
time_taken: float = -1.0
5253

5354
def __repr__(self) -> str:
5455
return f"{self.__class__.__name__}({self.path}, is_zip={self.is_zip}, relative={self.relative})"
@@ -92,7 +93,6 @@ def load(self) -> bool:
9293
print(f"Could not load world {self}:", file=file_like)
9394
traceback.print_exc(file=file_like)
9495
file_like.seek(0)
95-
import logging
9696
logging.exception(file_like.read())
9797
failed_world_loads.append(os.path.basename(self.path).rsplit(".", 1)[0])
9898
return False
@@ -107,7 +107,11 @@ def load(self) -> bool:
107107
if not entry.name.startswith(("_", ".")):
108108
file_name = entry.name if relative else os.path.join(folder, entry.name)
109109
if entry.is_dir():
110-
world_sources.append(WorldSource(file_name, relative=relative))
110+
init_file_path = os.path.join(entry.path, '__init__.py')
111+
if os.path.isfile(init_file_path):
112+
world_sources.append(WorldSource(file_name, relative=relative))
113+
else:
114+
logging.warning(f"excluding {entry.name} from world sources because it has no __init__.py")
111115
elif entry.is_file() and entry.name.endswith(".apworld"):
112116
world_sources.append(WorldSource(file_name, is_zip=True, relative=relative))
113117

0 commit comments

Comments
 (0)