diff --git a/.github/workflows/unittests.yml b/.github/workflows/unittests.yml index b2530bd06c7d..3ad29b007772 100644 --- a/.github/workflows/unittests.yml +++ b/.github/workflows/unittests.yml @@ -24,7 +24,7 @@ on: - '.github/workflows/unittests.yml' jobs: - build: + unit: runs-on: ${{ matrix.os }} name: Test Python ${{ matrix.python.version }} ${{ matrix.os }} @@ -60,3 +60,32 @@ jobs: - name: Unittests run: | pytest -n auto + + hosting: + runs-on: ${{ matrix.os }} + name: Test hosting with ${{ matrix.python.version }} on ${{ matrix.os }} + + strategy: + matrix: + os: + - ubuntu-latest + python: + - {version: '3.11'} # current + + steps: + - uses: actions/checkout@v4 + - name: Set up Python ${{ matrix.python.version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python.version }} + - name: Install dependencies + run: | + python -m venv venv + source venv/bin/activate + python -m pip install --upgrade pip + python ModuleUpdate.py --yes --force --append "WebHostLib/requirements.txt" + - name: Test hosting + run: | + source venv/bin/activate + export PYTHONPATH=$(pwd) + python test/hosting/__main__.py diff --git a/Launcher.py b/Launcher.py index 503ad5f8bd82..e26e4afc0f05 100644 --- a/Launcher.py +++ b/Launcher.py @@ -162,6 +162,7 @@ def launch(exe, in_terminal=False): def run_gui(): from kvui import App, ContainerLayout, GridLayout, Button, Label, ScrollBox, Widget + from kivy.core.window import Window from kivy.uix.image import AsyncImage from kivy.uix.relativelayout import RelativeLayout @@ -226,6 +227,8 @@ def build_button(component: Component) -> Widget: if client: client_layout.layout.add_widget(build_button(client[1])) + Window.bind(on_drop_file=self._on_drop_file) + return self.container @staticmethod @@ -235,6 +238,14 @@ def component_action(button): else: launch(get_exe(button.component), button.component.cli) + def _on_drop_file(self, window: Window, filename: bytes, x: int, y: int) -> None: + """ When a patch file is dropped into the window, run the associated component. """ + file, component = identify(filename.decode()) + if file and component: + run_component(component, file) + else: + logging.warning(f"unable to identify component for {filename}") + def _stop(self, *largs): # ran into what appears to be https://groups.google.com/g/kivy-users/c/saWDLoYCSZ4 with PyCharm. # Closing the window explicitly cleans it up. diff --git a/MultiServer.py b/MultiServer.py index 22375da2b3c5..dc5e3d21ac89 100644 --- a/MultiServer.py +++ b/MultiServer.py @@ -3,6 +3,7 @@ import argparse import asyncio import collections +import contextlib import copy import datetime import functools @@ -176,7 +177,7 @@ class Context: location_name_groups: typing.Dict[str, typing.Dict[str, typing.Set[str]]] all_item_and_group_names: typing.Dict[str, typing.Set[str]] all_location_and_group_names: typing.Dict[str, typing.Set[str]] - non_hintable_names: typing.Dict[str, typing.Set[str]] + non_hintable_names: typing.Dict[str, typing.AbstractSet[str]] spheres: typing.List[typing.Dict[int, typing.Set[int]]] """ each sphere is { player: { location_id, ... } } """ logger: logging.Logger @@ -231,7 +232,7 @@ def __init__(self, host: str, port: int, server_password: str, password: str, lo self.embedded_blacklist = {"host", "port"} self.client_ids: typing.Dict[typing.Tuple[int, int], datetime.datetime] = {} self.auto_save_interval = 60 # in seconds - self.auto_saver_thread = None + self.auto_saver_thread: typing.Optional[threading.Thread] = None self.save_dirty = False self.tags = ['AP'] self.games: typing.Dict[int, str] = {} @@ -268,6 +269,11 @@ def _load_game_data(self): for world_name, world in worlds.AutoWorldRegister.world_types.items(): self.non_hintable_names[world_name] = world.hint_blacklist + for game_package in self.gamespackage.values(): + # remove groups from data sent to clients + del game_package["item_name_groups"] + del game_package["location_name_groups"] + def _init_game_data(self): for game_name, game_package in self.gamespackage.items(): if "checksum" in game_package: @@ -1926,8 +1932,6 @@ def _cmd_status(self, tag: str = "") -> bool: def _cmd_exit(self) -> bool: """Shutdown the server""" self.ctx.server.ws_server.close() - if self.ctx.shutdown_task: - self.ctx.shutdown_task.cancel() self.ctx.exit_event.set() return True @@ -2285,7 +2289,8 @@ def parse_args() -> argparse.Namespace: async def auto_shutdown(ctx, to_cancel=None): - await asyncio.sleep(ctx.auto_shutdown) + with contextlib.suppress(asyncio.TimeoutError): + await asyncio.wait_for(ctx.exit_event.wait(), ctx.auto_shutdown) def inactivity_shutdown(): ctx.server.ws_server.close() @@ -2305,7 +2310,8 @@ def inactivity_shutdown(): if seconds < 0: inactivity_shutdown() else: - await asyncio.sleep(seconds) + with contextlib.suppress(asyncio.TimeoutError): + await asyncio.wait_for(ctx.exit_event.wait(), seconds) def load_server_cert(path: str, cert_key: typing.Optional[str]) -> "ssl.SSLContext": diff --git a/Utils.py b/Utils.py index eea81a2d3201..a7fd7f4f334c 100644 --- a/Utils.py +++ b/Utils.py @@ -458,8 +458,14 @@ class KeyedDefaultDict(collections.defaultdict): """defaultdict variant that uses the missing key as argument to default_factory""" default_factory: typing.Callable[[typing.Any], typing.Any] - def __init__(self, default_factory: typing.Callable[[Any], Any] = None, **kwargs): - super().__init__(default_factory, **kwargs) + def __init__(self, + default_factory: typing.Callable[[Any], Any] = None, + seq: typing.Union[typing.Mapping, typing.Iterable, None] = None, + **kwargs): + if seq is not None: + super().__init__(default_factory, seq, **kwargs) + else: + super().__init__(default_factory, **kwargs) def __missing__(self, key): self[key] = value = self.default_factory(key) diff --git a/WebHost.py b/WebHost.py index 9b5edd322f91..afacd6288ec2 100644 --- a/WebHost.py +++ b/WebHost.py @@ -12,6 +12,9 @@ import Utils import settings +if typing.TYPE_CHECKING: + from flask import Flask + Utils.local_path.cached_path = os.path.dirname(__file__) or "." # py3.8 is not abs. remove "." when dropping 3.8 settings.no_gui = True configpath = os.path.abspath("config.yaml") @@ -19,7 +22,7 @@ configpath = os.path.abspath(Utils.user_path('config.yaml')) -def get_app(): +def get_app() -> "Flask": from WebHostLib import register, cache, app as raw_app from WebHostLib.models import db diff --git a/WebHostLib/customserver.py b/WebHostLib/customserver.py index 3a86cb551d27..9f70165b61e5 100644 --- a/WebHostLib/customserver.py +++ b/WebHostLib/customserver.py @@ -168,17 +168,28 @@ def get_random_port(): def get_static_server_data() -> dict: import worlds data = { - "non_hintable_names": {}, - "gamespackage": worlds.network_data_package["games"], - "item_name_groups": {world_name: world.item_name_groups for world_name, world in - worlds.AutoWorldRegister.world_types.items()}, - "location_name_groups": {world_name: world.location_name_groups for world_name, world in - worlds.AutoWorldRegister.world_types.items()}, + "non_hintable_names": { + world_name: world.hint_blacklist + for world_name, world in worlds.AutoWorldRegister.world_types.items() + }, + "gamespackage": { + world_name: { + key: value + for key, value in game_package.items() + if key not in ("item_name_groups", "location_name_groups") + } + for world_name, game_package in worlds.network_data_package["games"].items() + }, + "item_name_groups": { + world_name: world.item_name_groups + for world_name, world in worlds.AutoWorldRegister.world_types.items() + }, + "location_name_groups": { + world_name: world.location_name_groups + for world_name, world in worlds.AutoWorldRegister.world_types.items() + }, } - for world_name, world in worlds.AutoWorldRegister.world_types.items(): - data["non_hintable_names"][world_name] = world.hint_blacklist - return data @@ -266,12 +277,15 @@ async def start_room(room_id): ctx.logger.exception("Could not determine port. Likely hosting failure.") with db_session: ctx.auto_shutdown = Room.get(id=room_id).timeout + if ctx.saving: + setattr(asyncio.current_task(), "save", lambda: ctx._save(True)) ctx.shutdown_task = asyncio.create_task(auto_shutdown(ctx, [])) await ctx.shutdown_task except (KeyboardInterrupt, SystemExit): if ctx.saving: ctx._save() + setattr(asyncio.current_task(), "save", None) except Exception as e: with db_session: room = Room.get(id=room_id) @@ -281,8 +295,12 @@ async def start_room(room_id): else: if ctx.saving: ctx._save() + setattr(asyncio.current_task(), "save", None) finally: try: + ctx.save_dirty = False # make sure the saving thread does not write to DB after final wakeup + ctx.exit_event.set() # make sure the saving thread stops at some point + # NOTE: async saving should probably be an async task and could be merged with shutdown_task with (db_session): # ensure the Room does not spin up again on its own, minute of safety buffer room = Room.get(id=room_id) @@ -294,13 +312,32 @@ async def start_room(room_id): rooms_shutting_down.put(room_id) class Starter(threading.Thread): + _tasks: typing.List[asyncio.Future] + + def __init__(self): + super().__init__() + self._tasks = [] + + def _done(self, task: asyncio.Future): + self._tasks.remove(task) + task.result() + def run(self): while 1: next_room = rooms_to_run.get(block=True, timeout=None) - asyncio.run_coroutine_threadsafe(start_room(next_room), loop) + task = asyncio.run_coroutine_threadsafe(start_room(next_room), loop) + self._tasks.append(task) + task.add_done_callback(self._done) logging.info(f"Starting room {next_room} on {name}.") starter = Starter() starter.daemon = True starter.start() - loop.run_forever() + try: + loop.run_forever() + finally: + # save all tasks that want to be saved during shutdown + for task in asyncio.all_tasks(loop): + save: typing.Optional[typing.Callable[[], typing.Any]] = getattr(task, "save", None) + if save: + save() diff --git a/WebHostLib/options.py b/WebHostLib/options.py index 62ba86a56626..53c3a6151b82 100644 --- a/WebHostLib/options.py +++ b/WebHostLib/options.py @@ -91,7 +91,7 @@ def option_presets(game: str) -> Response: f"Expected {option.special_range_names.keys()} or {option.range_start}-{option.range_end}." presets[preset_name][preset_option_name] = option.value - elif isinstance(option, Options.Range): + elif isinstance(option, (Options.Range, Options.OptionSet, Options.OptionList, Options.ItemDict)): presets[preset_name][preset_option_name] = option.value elif isinstance(preset_option, str): # Ensure the option value is valid for Choice and Toggle options diff --git a/WebHostLib/requirements.txt b/WebHostLib/requirements.txt index 62707d78cf1f..3452c9d416db 100644 --- a/WebHostLib/requirements.txt +++ b/WebHostLib/requirements.txt @@ -1,9 +1,10 @@ -flask>=3.0.0 +flask>=3.0.3 +werkzeug>=3.0.3 pony>=0.7.17 -waitress>=2.1.2 -Flask-Caching>=2.1.0 -Flask-Compress>=1.14 -Flask-Limiter>=3.5.0 +waitress>=3.0.0 +Flask-Caching>=2.3.0 +Flask-Compress>=1.15 +Flask-Limiter>=3.7.0 bokeh>=3.1.1; python_version <= '3.8' -bokeh>=3.3.2; python_version >= '3.9' -markupsafe>=2.1.3 +bokeh>=3.4.1; python_version >= '3.9' +markupsafe>=2.1.5 diff --git a/WebHostLib/static/styles/playerOptions/playerOptions.css b/WebHostLib/static/styles/playerOptions/playerOptions.css index 6165e3a0f622..56c9263d3330 100644 --- a/WebHostLib/static/styles/playerOptions/playerOptions.css +++ b/WebHostLib/static/styles/playerOptions/playerOptions.css @@ -15,7 +15,7 @@ html { border-radius: 8px; padding: 1rem; color: #eeffeb; - word-break: break-all; + word-break: break-word; } #player-options #player-options-header h1 { margin-bottom: 0; diff --git a/WebHostLib/static/styles/playerOptions/playerOptions.scss b/WebHostLib/static/styles/playerOptions/playerOptions.scss index 525b8ef15403..06bde759d263 100644 --- a/WebHostLib/static/styles/playerOptions/playerOptions.scss +++ b/WebHostLib/static/styles/playerOptions/playerOptions.scss @@ -16,7 +16,7 @@ html{ border-radius: 8px; padding: 1rem; color: #eeffeb; - word-break: break-all; + word-break: break-word; #player-options-header{ h1{ diff --git a/WebHostLib/templates/weightedOptions/macros.html b/WebHostLib/templates/weightedOptions/macros.html index 5b8944a43887..a6e4545fdaf7 100644 --- a/WebHostLib/templates/weightedOptions/macros.html +++ b/WebHostLib/templates/weightedOptions/macros.html @@ -1,9 +1,9 @@ {% macro Toggle(option_name, option) %} - {{ RangeRow(option_name, option, "No", "false") }} - {{ RangeRow(option_name, option, "Yes", "true") }} - {{ RandomRows(option_name, option) }} + {{ RangeRow(option_name, option, "No", "false", False, "true" if option.default else "false") }} + {{ RangeRow(option_name, option, "Yes", "true", False, "true" if option.default else "false") }} + {{ RandomRow(option_name, option) }}
{% endmacro %} @@ -18,10 +18,10 @@ {% for id, name in option.name_lookup.items() %} {% if name != 'random' %} - {{ RangeRow(option_name, option, option.get_option_name(id), name) }} + {{ RangeRow(option_name, option, option.get_option_name(id), name, False, name if option.get_option_name(option.default)|lower == name|lower else None) }} {% endif %} {% endfor %} - {{ RandomRows(option_name, option) }} + {{ RandomRow(option_name, option) }} {% endmacro %} @@ -72,7 +72,9 @@ - + {% if option.default %} + {{ RangeRow(option_name, option, option.default, option.default) }} + {% endif %}
@@ -90,10 +92,10 @@ {% for id, name in option.name_lookup.items() %} {% if name != 'random' %} - {{ RangeRow(option_name, option, option.get_option_name(id), name) }} + {{ RangeRow(option_name, option, option.get_option_name(id), name, False, name if option.get_option_name(option.default)|lower == name else None) }} {% endif %} {% endfor %} - {{ RandomRows(option_name, option) }} + {{ RandomRow(option_name, option) }} {% endmacro %} @@ -112,7 +114,7 @@ type="number" id="{{ option_name }}-{{ item_name }}-qty" name="{{ option_name }}||{{ item_name }}" - value="0" + value="{{ option.default[item_name] if item_name in option.default else "0" }}" /> {% endfor %} @@ -121,13 +123,14 @@ {% macro OptionList(option_name, option) %}
- {% for key in option.valid_keys|sort %} + {% for key in (option.valid_keys if option.valid_keys is ordered else option.valid_keys|sort) %}