diff --git a/CommonClient.py b/CommonClient.py index 6e2c5bb0d..b95bd310c 100755 --- a/CommonClient.py +++ b/CommonClient.py @@ -13,6 +13,44 @@ import websockets from websockets.protocol import State +from websockets.asyncio.connection import Connection + +# World clients written against websockets 13.x (shipped MWGG) check liveness via +# the legacy `socket.closed` / `socket.open` attributes, which the websockets 14+ +# asyncio Connection removed. Restore them as State-backed properties with the +# legacy semantics (both are False while opening/closing) so those clients run +# unmodified against the bundled websockets 16. Each offending call site gets one +# deprecation warning in the client log (not one per access -- these checks sit +# in per-package and watcher loops) so world authors can find and migrate it. +_legacy_socket_attr_sites: typing.Set[typing.Tuple[str, int, str]] = set() + + +def _warn_legacy_socket_attr(name: str) -> None: + frame = sys._getframe(2) + site = (frame.f_code.co_filename, frame.f_lineno, name) + if site in _legacy_socket_attr_sites: + return + _legacy_socket_attr_sites.add(site) + logging.getLogger("Client").warning( + "Deprecated websockets API: %s:%s reads socket.%s, which websockets 14+ removed. " + "MWGG shims it for now; migrate to socket.state checks against websockets.protocol.State.", + site[0], site[1], name) + + +def _compat_socket_closed(self: Connection) -> bool: + _warn_legacy_socket_attr("closed") + return self.state is State.CLOSED + + +def _compat_socket_open(self: Connection) -> bool: + _warn_legacy_socket_attr("open") + return self.state is State.OPEN + + +if not hasattr(Connection, "closed"): + Connection.closed = property(_compat_socket_closed) +if not hasattr(Connection, "open"): + Connection.open = property(_compat_socket_open) import Utils apname = Utils.instance_name if Utils.instance_name else "Archipelago" diff --git a/ModuleUpdate.py b/ModuleUpdate.py index 8792fc099..e979be25a 100644 --- a/ModuleUpdate.py +++ b/ModuleUpdate.py @@ -25,7 +25,7 @@ from typing import Any, List, Optional, TypeVar, override from importlib import invalidate_caches -from BaseUtils import tuplize_version, Version, local_path, write_path, mwgg_venv_site_packages, use_worlds_venv, is_frozen +from BaseUtils import tuplize_version, Version, local_path, mwgg_venv_site_packages, use_worlds_venv, is_frozen from APContainer import APWorldContainer # mwgg_igdb package source — orphan branch on the Index repo @@ -135,16 +135,17 @@ def update(self, *s: Iterable[_T]) -> None: requirements_files: RequirementsSet[Path] = RequirementsSet({Path(local_path("requirements.txt"))}) worlds_files: dict[str, RequirementsSet[str]] = {"wheels": RequirementsSet(), "apworlds": RequirementsSet()} -# Frozen builds: custom_worlds lives under write_path() — i.e. -# ~/.local/share/MultiworldGG/custom_worlds on Linux, %LOCALAPPDATA% on Windows, -# ~/Library/Application Support/ on macOS — so it's user-writable on every -# install shape (AppImage's `/tmp/.mount_…` mount is read-only; tarball install -# dirs may be in /opt/ etc.). Dev mode keeps using the in-repo dir for -# convenience (drop apworlds in /custom_worlds/ and they're picked up). -if is_frozen(): - custom_worlds_dir = Path(write_path("custom_worlds")) -else: - custom_worlds_dir = Path(local_path("custom_worlds")) +# custom_worlds always lives next to the executable / source checkout -- the +# upstream location, and where users actually drop their apworlds. This is the +# single source of truth for the launch scan (register_custom_worlds / +# get_available_worlds), Utils.set_game_names, and the launch path. Do NOT +# special-case frozen builds to write_path(): that splits the scan from the launch +# path and custom worlds silently stop being selectable. +def _resolve_custom_worlds_dir() -> Path: + return Path(local_path("custom_worlds")) + + +custom_worlds_dir = _resolve_custom_worlds_dir() # Best-effort mkdir. Skipped silently on the rare read-only filesystem; the # downstream readers already handle missing directories. diff --git a/MultiWorld.py b/MultiWorld.py index cd17bf736..31b744c45 100644 --- a/MultiWorld.py +++ b/MultiWorld.py @@ -285,7 +285,7 @@ async def main(args: list[str]): from Utils import register_custom_worlds register_custom_worlds() except Exception as e: - logger.warning(f"Could not scan custom_worlds on launch: {e}") + logger.warning(f"Could not scan custom_worlds on launch: {e}", exc_info=True) # Run the main client in the current process run_client(args, queue=splash_queue) \ No newline at end of file diff --git a/Utils.py b/Utils.py index 4e71683b3..529abec4d 100644 --- a/Utils.py +++ b/Utils.py @@ -111,7 +111,7 @@ def set_game_names(game_names: typing.List[str], strict: bool = True) -> typing. game_names = _expand_game_choices(game_names) _worlds_to_install = {game: "" for game in game_names} _unknown_worlds = [] - custom_worlds_dir = Path(local_path("custom_worlds")) + custom_worlds_dir = ModuleUpdate.custom_worlds_dir _unlisted_worlds = [world for world in ModuleUpdate.find_world_modules() if world not in GameIndex.game_names.values()] # We only have the module name here, not the game name, and that is buried deep in the metadata @@ -299,7 +299,7 @@ def register_custom_worlds() -> typing.List[str]: try: module_name = discover_custom_world_module(world_file) except Exception as e: - update_logger.warning(f"Skipping custom world {world_file.name}: {e}") + update_logger.warning(f"Skipping custom world {world_file.name}: {e}", exc_info=True) continue if module_name and module_name not in found: found.append(module_name) @@ -432,7 +432,7 @@ def _install_module_threaded(): # dir, so importlib.import_module(worlds.) now works via the # normal file loader. Just register them with the live GameIndex # so launcher lookups (game_name -> module) resolve cleanly. - custom_worlds_dir = Path(local_path("custom_worlds")) + custom_worlds_dir = ModuleUpdate.custom_worlds_dir for target in custom_fallbacks: slug = target.removeprefix("worlds.") apworld_file = custom_worlds_dir / f"{slug}.apworld" diff --git a/kvui.py b/kvui.py index c5b4d021e..b36eaab4e 100644 --- a/kvui.py +++ b/kvui.py @@ -2,20 +2,84 @@ import logging if os.environ.get("MWGG_FRONTEND", "gui") == "tui": - # TUI frontend is active. Per-world client wrappers (kh2, albw, ...) still import - # GameManager from here and call ctx.run_gui() during their launch. We must preserve - # the takeover handshake -- server_loop blocks on `await ctx.takeover_complete.wait()` - # until _takeover_existing_ui() runs and sets the event -- but we must NOT pull in - # Kivy. Just constructing the real (Kivy-backed) GameManager opens a rogue window on - # top of the Textual TUI; that's the leak we're fixing here. + # TUI frontend is active. Per-world client wrappers (kh2, albw, kh3, ...) still do + # `from kvui import ` and call ctx.run_gui() during launch. Two + # requirements collide: we must NOT import Kivy (merely importing kivy.core.window + # opens a rogue window over the Textual TUI), yet those imports must still succeed + # and the names stay usable as base classes / dp() / etc., or the client crashes + # before the takeover handshake runs (server_loop blocks on + # `await ctx.takeover_complete.wait()` until _takeover_existing_ui() sets the event). + # + # Resolution: hand every Kivy/KivyMD name an inert, non-Kivy stand-in. This is + # semantically safe because the Kivy per-world UI is never built under the TUI -- + # LegacyKvuiClientBuilder.build() returns early when MWGG_FRONTEND=tui -- so the + # stand-ins only have to survive import, subclassing, instantiation and attribute + # access, never rendering. GameManager stays a real class so the takeover in its + # async_run() keeps working. + + class _InertMeta(type): + """Metaclass so stand-in *classes* tolerate attribute access too, e.g. + ``Clock.schedule_interval`` or ``App.get_running_app`` used on the class.""" + + def __getattr__(cls, name): + if name.startswith("__") and name.endswith("__"): + raise AttributeError(name) + return _INERT + + class _Inert(metaclass=_InertMeta): + """Universal inert stand-in for a Kivy/KivyMD object under the TUI frontend. + + Usable as a base class; instances accept any constructor args and tolerate + being called, attribute-accessed, indexed or iterated. Calling the class + (e.g. ``StringProperty("")``) returns an inert instance. + """ + + def __init__(self, *args, **kwargs): + pass + + def __call__(self, *args, **kwargs): + return _INERT + + def __getattr__(self, name): + if name.startswith("__") and name.endswith("__"): + raise AttributeError(name) + return _INERT + + def __getitem__(self, key): + return _INERT + + def __bool__(self): + return False + + def __iter__(self): + return iter(()) + + _INERT = _Inert() + + def dp(value): + """kivy.metrics.dp stand-in -- density is meaningless without a window, and + worlds use dp() in class bodies (e.g. ``height=dp(30)``), so return the value.""" + return value + + sp = dp + Clock = _INERT + Window = _INERT class GameManager: logging_pairs: list = [] base_title: str = "" - def __init__(self, ctx): + def __init__(self, ctx, *args, **kwargs): self.ctx = ctx + def __getattr__(self, name): + # Per-world GameManager subclasses reach for Kivy-app attributes the GUI + # build would have supplied; under the TUI build() never runs, so answer + # inertly instead of crashing. + if name.startswith("__") and name.endswith("__"): + raise AttributeError(name) + return _INERT + async def async_run(self): if self.ctx._can_takeover_existing_ui(): await self.ctx._takeover_existing_ui() @@ -41,6 +105,18 @@ def create_custom_screen(self, title, content, index=-1): def remove_custom_screen(self, button): pass + def __getattr__(name): + """Serve an inert stand-in for any Kivy/KivyMD name a world client imports from + kvui under the TUI frontend (PEP 562). Cached as a module global so the class + identity is stable across imports -- multiple inheritance and any isinstance/ + issubclass checks depend on it. Never imports Kivy.""" + if name.startswith("__") and name.endswith("__"): + raise AttributeError(name) + stub = _InertMeta(name, (_Inert,), {}) + globals()[name] = stub + logging.getLogger("kvui").debug("kvui(tui): served inert stand-in for %r", name) + return stub + else: from openpyxl.xml.constants import PACKAGE_CHARTSHEETS_RELS from mwgg_gui.components.dialog import MessageBox @@ -67,7 +143,6 @@ def remove_custom_screen(self, button): from kivymd.uix.gridlayout import MDGridLayout as MainLayout from kivymd.uix.floatlayout import MDFloatLayout as ContainerLayout from kivymd.uix.recycleview import MDRecycleView - from kivymd.uix.behaviors import HoverBehavior from kivymd.uix.divider import MDDivider from kivymd.uix.label import MDLabel from kivymd.uix.progressindicator import MDLinearProgressIndicator @@ -77,6 +152,56 @@ def remove_custom_screen(self, button): from kivy.uix.image import AsyncImage as ApAsyncImage from kivymd.uix.tooltip import MDTooltipPlain as ToolTip + # Compatibility re-exports for world clients that do `from kvui import ...`. + # MultiworldGG worlds (kh3, etc.) import these canonical kivy/kivymd names from + # kvui; the GUI imports above bind some only under MWGG aliases (ToggleButton, + # MainLayout) or not at all. + from kivy.clock import Clock + from kivy.core.window import Window + from kivy.factory import Factory + from kivymd.uix.button import MDButton, MDButtonText, MDIconButton + from kivymd.uix.gridlayout import MDGridLayout + from kivymd.uix.textfield.textfield import MDTextField + + # World clients subclass HoverBehavior alongside kivymd widgets, e.g. + # `class X(HoverBehavior, MDIconButton)`. kivymd.uix.behaviors.HoverBehavior + # shares an MRO-incompatible base with those widgets; this plain object-based + # mixin (the original MWGG HoverBehavior) linearizes cleanly and exposes the + # hovered/border_point/on_enter/on_leave API those clients expect. + class HoverBehavior(object): + """originally from https://stackoverflow.com/a/605348110""" + hovered = BooleanProperty(False) + border_point = ObjectProperty(None) + + def __init__(self, **kwargs): + self.register_event_type("on_enter") + self.register_event_type("on_leave") + Window.bind(mouse_pos=self.on_mouse_pos) + Window.bind(on_cursor_leave=self.on_cursor_leave) + super(HoverBehavior, self).__init__(**kwargs) + + def on_mouse_pos(self, window, pos): + if not self.get_root_window(): + return # Abort if not displayed + # to_widget translates window pos to within widget pos + inside = self.collide_point(*self.to_widget(*pos)) + if self.hovered == inside: + return # We have already done what was needed + self.border_point = pos + self.hovered = inside + if inside: + self.dispatch("on_enter") + else: + self.dispatch("on_leave") + + def on_cursor_leave(self, *args): + # if the mouse left the window, it is no longer inside the hover widget. + self.hovered = BooleanProperty(False) + self.border_point = ObjectProperty(None) + self.dispatch("on_leave") + + Factory.register("HoverBehavior", HoverBehavior) + class GameManager: logging_pairs: list = [] base_title: str = "" diff --git a/test/_stubs/mwgg_igdb.py b/test/_stubs/mwgg_igdb.py index 069e4e83c..2015ada9e 100644 --- a/test/_stubs/mwgg_igdb.py +++ b/test/_stubs/mwgg_igdb.py @@ -64,22 +64,51 @@ def games(self, item) -> None: self._games[key] = value def search(self, query: str) -> dict: - return {} + # Faithful-enough port of the real GameIndex.search: exact-term AND, then + # substring fallback, returning {module_slug: game_data}. The launcher + # (mwgg_tui/mwgg_gui) resolves a typed query through here, so the stub must + # actually search rather than return {} -- otherwise a regression where a + # custom world never lands in the search index would pass unnoticed. + if not query: + return {} + terms = query.lower().split() + exact = [self._search_index[t] for t in terms if t in self._search_index] + matching: set = set() + if exact: + matching = set(exact[0]) + for s in exact[1:]: + matching &= s + if not matching: + for term in terms: + for indexed_term, slugs in self._search_index.items(): + if term in indexed_term: + matching |= slugs + return {slug: self._games.get(slug, {}) for slug in matching} def get_game(self, game_module: str) -> dict: return self._games.get(game_module, {}) + def _index_value(self, game_module: str, value) -> None: + # Mirror real GameIndex._index_value: index the full lowercased string and + # each whitespace word, so a world is searchable by its display name. + if not value: + return + cleaned = str(value).lower() + if not cleaned: + return + self.search_index = cleaned, game_module + for word in cleaned.split(): + self.search_index = word, game_module + def add_game(self, game_module: str, game_data: dict) -> None: self._games[game_module] = game_data name = game_data.get("game_name") if name: self._game_names[name] = game_module self._module_to_name[game_module] = name - for term in game_module.lower().split(): - if term in self._search_index: - self._search_index[term].add(game_module) - else: - self._search_index[term] = {game_module} + # Searchable by display name (mirrors the real module) and by slug. + self._index_value(game_module, name) + self._index_value(game_module, game_module.replace("_", " ")) def get_module_for_game(self, game_name: str, worlds: bool = False): module = self._game_names.get(game_name) diff --git a/test/general/test_custom_world_scan.py b/test/general/test_custom_world_scan.py index 16af59840..80ebd3244 100644 --- a/test/general/test_custom_world_scan.py +++ b/test/general/test_custom_world_scan.py @@ -1,14 +1,25 @@ """Regression tests for scanning custom_worlds/ into the in-memory GameIndex. -Two things that have regressed before and are guarded here: +custom_worlds are apworld **zip files**: on launch they must be scanned, their +manifest read (never imported), and each handed to ``GameIndex.add_game`` so it +lands in the search index and a user can find it by name -- and it must *remain* +there. This has regressed repeatedly, so the guarantees are pinned here: + * a stray non-world file (e.g. a README.txt) in custom_worlds/ must not abort - the scan -- discover_custom_world_module used to raise UnboundLocalError on - any unrecognized suffix, which aborted the whole directory scan; and - * an apworld dropped in custom_worlds/ must be registered via - GameIndex.add_game so it becomes selectable on launch. + the scan; + * an apworld dropped in custom_worlds/ is registered via ``GameIndex.add_game`` + so it is searchable by name (the launcher resolves its game list through + ``GameIndex.search``) and resolvable module<->name; + * the world stays registered across a second scan (idempotent); and + * the scan never imports the world module -- only the zip manifest is read. """ +import importlib import json +import sys import zipfile +from pathlib import Path + +import pytest import ModuleUpdate import Utils @@ -20,6 +31,34 @@ def _make_apworld(path, game_name: str) -> None: zf.writestr("archipelago.json", json.dumps({"game": game_name, "compatible_version": 5})) +@pytest.fixture(autouse=True) +def _restore_game_index(): + """Snapshot the GameIndex singleton and restore it after each test. + + The index is a process-global singleton seeded with every shipped world in + test/__init__.py; restoring keeps those available during the test while + preventing a test's custom-world additions from leaking into the rest of the + suite. Containers are copied (deep for the search index, whose values are + sets the tests mutate in place) and restored via clear()+update() so the + singleton's dict identities are preserved. + """ + games = dict(GameIndex._games) + game_names = dict(GameIndex._game_names) + module_to_name = dict(GameIndex._module_to_name) + search_index = {key: set(slugs) for key, slugs in GameIndex._search_index.items()} + try: + yield + finally: + for live, snapshot in ( + (GameIndex._games, games), + (GameIndex._game_names, game_names), + (GameIndex._module_to_name, module_to_name), + (GameIndex._search_index, search_index), + ): + live.clear() + live.update(snapshot) + + def test_register_custom_worlds_skips_readme_and_registers_apworld(tmp_path, monkeypatch): (tmp_path / "README.txt").write_text("Drop your apworlds here.\n") _make_apworld(tmp_path / "test_custom.apworld", "Test Custom Game") @@ -40,3 +79,92 @@ def test_discover_returns_none_for_stray_file(tmp_path): def test_register_custom_worlds_tolerates_missing_dir(tmp_path, monkeypatch): monkeypatch.setattr(ModuleUpdate, "custom_worlds_dir", tmp_path / "does_not_exist") assert Utils.register_custom_worlds() == [] + + +def test_custom_apworld_scanned_indexed_and_searchable(tmp_path, monkeypatch): + """End-to-end launch contract: an apworld in custom_worlds/ is scanned, added + to the index (searchable by name, resolvable both ways), stays after a rescan, + is surfaced by get_available_worlds(), and is never imported.""" + slug = "test_custom_selectable" + # Name words deliberately disjoint from the slug, so search() hits prove the + # *name* was indexed (not merely the slug derived from the filename). + name = "Quest For The Crystal" + (tmp_path / "README.txt").write_text("Drop your apworlds here.\n") + _make_apworld(tmp_path / f"{slug}.apworld", name) + monkeypatch.setattr(ModuleUpdate, "custom_worlds_dir", tmp_path) + + # apworlds are zip files: the scan must read the manifest, never import the + # world. Record any importlib.import_module call to catch a regression that + # starts importing worlds. (the legit `from mwgg_igdb import ...` / + # `from APContainer import ...` go through __import__, not this seam). + assert f"worlds.{slug}" not in sys.modules + imported: list[str] = [] + real_import_module = importlib.import_module + + def _spy_import_module(module_name, *args, **kwargs): + imported.append(module_name) + return real_import_module(module_name, *args, **kwargs) + + monkeypatch.setattr(importlib, "import_module", _spy_import_module) + + found = Utils.register_custom_worlds() + + assert slug in found # scanned + assert "README" not in found # stray file skipped, not aborting the scan + + # Added to the index and resolvable both ways. + assert slug in GameIndex.get_all_games() + assert GameIndex.get_game_name_for_module(slug) == name + assert GameIndex.get_module_for_game(name) == slug + + # Searchable by name -- the launcher resolves its typed query through search(). + assert slug in GameIndex.search("crystal") # a name word absent from the slug + assert slug in GameIndex.search(name) + + # Never imported the world module. + assert f"worlds.{slug}" not in sys.modules + assert not any(m == f"worlds.{slug}" or m.startswith(f"worlds.{slug}.") for m in imported) + + # Remains after a second scan: idempotent, no duplicate growth, no raise. + count_before = len(GameIndex.get_all_games()) + found_again = Utils.register_custom_worlds() + assert slug in found_again + assert slug in GameIndex.search("crystal") + assert len(GameIndex.get_all_games()) == count_before + + # get_available_worlds() unions custom worlds with on-disk worlds. Stub out the + # uv-backed find_world_modules so the test is hermetic and the union is provable. + monkeypatch.setattr(ModuleUpdate, "find_world_modules", lambda: {"sentinel_world"}) + available = Utils.get_available_worlds() + assert "sentinel_world" in available # union preserved + assert slug in available # custom world surfaced as selectable + + +def test_custom_worlds_dir_is_executable_folder_even_when_frozen(monkeypatch): + """custom_worlds must resolve next to the executable / source checkout -- the + upstream location, and where users actually drop apworlds -- never to + write_path()/AppData. Splitting the scan dir from the launch dir is what makes + custom worlds silently un-selectable in frozen builds. + + The is_frozen() mock is a tripwire: it has no effect on the current resolver, + but if anyone reintroduces an `if is_frozen(): write_path(...)` branch this test + fails because the resolved dir would jump to write_path under the mock. + """ + from BaseUtils import write_path + monkeypatch.setattr(ModuleUpdate, "is_frozen", lambda: True) + + resolved = ModuleUpdate._resolve_custom_worlds_dir() + + assert resolved == Path(ModuleUpdate.local_path("custom_worlds")) + assert resolved != Path(write_path("custom_worlds")) + + +def test_add_game_indexes_into_search_index(): + """add_game must place a world in the search index so a user can search for it + by name -- pinned independently of the scan plumbing. The name words are + disjoint from the slug so a hit proves the name itself was indexed.""" + GameIndex.add_game("probe_widget", {"game_name": "Galaxy Explorer"}) + + assert "probe_widget" in GameIndex.search("galaxy") + assert "probe_widget" in GameIndex.search("explorer") + assert GameIndex.get_module_for_game("Galaxy Explorer") == "probe_widget" diff --git a/test/general/test_kvui_tui.py b/test/general/test_kvui_tui.py new file mode 100644 index 000000000..2a7d82ca1 --- /dev/null +++ b/test/general/test_kvui_tui.py @@ -0,0 +1,102 @@ +"""kvui TUI branch: world clients that route all Kivy access through kvui must be +able to `from kvui import ` under MWGG_FRONTEND=tui WITHOUT importing +Kivy (importing kivy.core.window would open a rogue window over the Textual TUI). + +These run in a clean subprocess because (a) the parent test process has no +MWGG_FRONTEND set, so importing kvui here would take the GUI branch and pull in +kivymd, and (b) a fresh interpreter is the only rigorous way to assert that +importing kvui did not drag Kivy into sys.modules. +""" +import os +import subprocess +import sys +import unittest + +REPO_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +# Mirrors the unconditional first line of a kh3/kh2/albw-style run_gui() override. +WORLD_CLIENT_IMPORT = ( + "from kvui import (Clock, GameManager, HoverBehavior, MDBoxLayout, MDButton, " + "MDButtonText, MDGridLayout, MDIconButton, MDLabel, MDTextField, Window, dp)" +) + + +def _run_tui(script: str) -> subprocess.CompletedProcess: + env = {**os.environ, "MWGG_FRONTEND": "tui"} + return subprocess.run( + [sys.executable, "-c", script], + cwd=REPO_ROOT, env=env, capture_output=True, text=True, + ) + + +class TestKvuiTuiStandins(unittest.TestCase): + def _assert_ok(self, script: str) -> None: + result = _run_tui(script + "\nprint('KVUI_TUI_OK')") + self.assertEqual( + result.returncode, 0, + msg=f"subprocess failed\nSTDOUT:\n{result.stdout}\nSTDERR:\n{result.stderr}", + ) + self.assertIn("KVUI_TUI_OK", result.stdout) + + def test_world_client_import_succeeds_without_kivy(self) -> None: + """The import that crashes today must succeed and pull in no Kivy.""" + self._assert_ok( + WORLD_CLIENT_IMPORT + "\n" + "import sys; assert 'kivy' not in sys.modules, 'Kivy was imported'" + ) + + def test_dp_returns_its_argument(self) -> None: + self._assert_ok("from kvui import dp, sp\nassert dp(30) == 30 and sp(12) == 12") + + def test_standins_support_inheritance_and_instantiation(self) -> None: + """Stand-ins are usable as base classes (incl. multiple inheritance) and + instantiable, with dp()/factory calls in the class body.""" + self._assert_ok( + WORLD_CLIENT_IMPORT + "\n" + "from kvui import StringProperty\n" + "class Panel(MDBoxLayout): pass\n" + "class Hoverable(HoverBehavior, MDLabel):\n" + " height = dp(30)\n" + " color = StringProperty('')\n" + "assert Hoverable.height == 30\n" + "assert MDBoxLayout in Panel.__mro__\n" + "assert MDButton().anything is not None # instance attr is inert, not error\n" + ) + + def test_game_manager_is_real_takeover_class(self) -> None: + """GameManager must stay a real class whose async_run drives the takeover; + its inert __getattr__ must not shadow that, and __init__ accepts extra args.""" + self._assert_ok( + "import asyncio\n" + "from kvui import GameManager\n" + "class M(GameManager):\n" + " def __init__(self, ctx): super().__init__(ctx, app=None)\n" + "class Ctx:\n" + " took_over = False\n" + " def _can_takeover_existing_ui(self): return True\n" + " async def _takeover_existing_ui(self): self.took_over = True\n" + "ctx = Ctx()\n" + "m = M(ctx)\n" + "assert m.run() is None\n" + "asyncio.run(m.async_run())\n" + "assert ctx.took_over, 'async_run did not reach the takeover handshake'\n" + ) + + def test_catch_all_handles_unenumerated_names_but_not_dunders(self) -> None: + """Any non-dunder name resolves to a stable inert class; dunders still raise.""" + self._assert_ok( + "import kvui\n" + "from kvui import SomeFutureCustomWidget as a\n" + "assert kvui.SomeFutureCustomWidget is a, 'stub identity not stable'\n" + "assert isinstance(a(), a)\n" + "raised = False\n" + "try:\n" + " kvui.__not_a_real_dunder__\n" + "except AttributeError:\n" + " raised = True\n" + "assert raised, 'catch-all manufactured a dunder'\n" + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/general/test_websockets_compat.py b/test/general/test_websockets_compat.py new file mode 100644 index 000000000..e55048f9c --- /dev/null +++ b/test/general/test_websockets_compat.py @@ -0,0 +1,68 @@ +"""World clients written against websockets 13.x (shipped MWGG) check server +liveness via the legacy `ctx.server.socket.closed` / `.open` attributes (e.g. +kh3's _is_ap_connected). The websockets 14+ asyncio Connection removed both, so +CommonClient restores them as State-backed properties; these tests pin that +compat surface and its legacy semantics (both False while opening/closing). +""" +import unittest +from types import SimpleNamespace + +from websockets.asyncio.client import ClientConnection +from websockets.asyncio.connection import Connection +from websockets.protocol import State + +import CommonClient # noqa: F401 importing it installs the compat properties + + +class TestWebsocketsLegacyCompat(unittest.TestCase): + def test_compat_properties_installed(self) -> None: + for name in ("closed", "open"): + with self.subTest(name=name): + self.assertIsInstance(getattr(Connection, name, None), property) + + def test_client_connection_inherits_compat(self) -> None: + # The object world clients actually touch is ctx.server.socket, a + # ClientConnection returned by websockets.connect in server_loop. + for name in ("closed", "open"): + with self.subTest(name=name): + self.assertTrue(hasattr(ClientConnection, name)) + + def test_closed_matches_legacy_semantics(self) -> None: + # legacy: closed is True only once fully CLOSED + for state, expected in ((State.CONNECTING, False), (State.OPEN, False), + (State.CLOSING, False), (State.CLOSED, True)): + # subTest param must be a plain string: pytest-xdist ships subtest + # reports over execnet, which cannot serialize the State enum + with self.subTest(state=state.name): + stub = SimpleNamespace(state=state) + self.assertIs(Connection.closed.fget(stub), expected) + + def test_open_matches_legacy_semantics(self) -> None: + # legacy: open is True only while fully OPEN + for state, expected in ((State.CONNECTING, False), (State.OPEN, True), + (State.CLOSING, False), (State.CLOSED, False)): + with self.subTest(state=state.name): + stub = SimpleNamespace(state=state) + self.assertIs(Connection.open.fget(stub), expected) + + def test_world_client_liveness_check_pattern(self) -> None: + # The exact expression kh3 uses: bool(server and socket and not socket.closed) + server = SimpleNamespace(socket=SimpleNamespace(state=State.OPEN)) + server.socket.closed = Connection.closed.fget(server.socket) + self.assertTrue(bool(server and server.socket and not server.socket.closed)) + + def test_deprecation_warning_once_per_call_site(self) -> None: + # Accessing a legacy attribute surfaces a warning in the client log, + # but only once per call site -- these checks sit in per-package loops. + stub = SimpleNamespace(state=State.OPEN) + with self.assertLogs("Client", level="WARNING") as logs: + for _ in range(3): + Connection.closed.fget(stub) # one call site, three accesses + records = [r for r in logs.output if "socket.closed" in r] + self.assertEqual(len(records), 1) + self.assertIn("Deprecated websockets API", records[0]) + self.assertIn(__file__, records[0]) # points at the offending call site + + +if __name__ == "__main__": + unittest.main() diff --git a/worlds/_bizhawk/context.py b/worlds/_bizhawk/context.py index 816333b2a..cfa32d96b 100644 --- a/worlds/_bizhawk/context.py +++ b/worlds/_bizhawk/context.py @@ -10,6 +10,8 @@ from typing import Any import urllib +from websockets.protocol import State + import settings from CommonClient import CommonContext, ClientCommandProcessor, get_base_parser, server_loop, logger, gui_enabled @@ -265,7 +267,7 @@ async def _game_watcher(ctx: BizHawkClientContext): rom_hash = await get_hash(ctx.bizhawk_ctx) if ctx.rom_hash is not None and ctx.rom_hash != rom_hash: - if ctx.server is not None and not ctx.server.socket.closed: + if ctx.server is not None and ctx.server.socket.state is not State.CLOSED: logger.info(f"ROM changed. Disconnecting from server.") ctx.auth = None @@ -296,7 +298,7 @@ async def _game_watcher(ctx: BizHawkClientContext): continue # Server auth - if ctx.server is not None and not ctx.server.socket.closed: + if ctx.server is not None and ctx.server.socket.state is not State.CLOSED: if ctx.auth_status == AuthStatus.NOT_AUTHENTICATED: Utils.async_start(ctx.server_auth(ctx.password_requested)) else: