| 
									
										
										
										
											2020-04-22 05:09:46 +02:00
										 |  |  | from __future__ import annotations | 
					
						
							| 
									
										
										
										
											2021-01-02 12:49:43 +01:00
										 |  |  | 
 | 
					
						
							| 
									
										
										
										
											2022-11-02 07:51:35 -07:00
										 |  |  | import asyncio | 
					
						
							| 
									
										
										
										
											2023-03-20 11:01:08 -05:00
										 |  |  | import json | 
					
						
							| 
									
										
										
										
											2020-06-21 15:32:31 +02:00
										 |  |  | import typing | 
					
						
							| 
									
										
										
										
											2021-11-28 04:06:30 +01:00
										 |  |  | import builtins | 
					
						
							|  |  |  | import os | 
					
						
							| 
									
										
										
										
											2023-10-29 19:47:37 +01:00
										 |  |  | import itertools | 
					
						
							| 
									
										
										
										
											2021-11-28 04:06:30 +01:00
										 |  |  | import subprocess | 
					
						
							|  |  |  | import sys | 
					
						
							|  |  |  | import pickle | 
					
						
							|  |  |  | import functools | 
					
						
							|  |  |  | import io | 
					
						
							|  |  |  | import collections | 
					
						
							|  |  |  | import importlib | 
					
						
							|  |  |  | import logging | 
					
						
							| 
									
										
										
										
											2023-10-02 08:34:50 +02:00
										 |  |  | import warnings | 
					
						
							| 
									
										
										
										
											2022-09-30 00:36:30 +02:00
										 |  |  | 
 | 
					
						
							| 
									
										
										
										
											2023-08-25 13:25:02 -07:00
										 |  |  | from argparse import Namespace | 
					
						
							| 
									
										
										
										
											2023-07-05 22:39:35 +02:00
										 |  |  | from settings import Settings, get_settings | 
					
						
							| 
									
										
										
										
											2024-11-18 15:59:17 +01:00
										 |  |  | from time import sleep | 
					
						
							| 
									
										
										
										
											2024-11-27 03:28:00 +01:00
										 |  |  | from typing import BinaryIO, Coroutine, Optional, Set, Dict, Any, Union, TypeGuard | 
					
						
							| 
									
										
										
										
											2024-02-27 08:44:34 +01:00
										 |  |  | from yaml import load, load_all, dump | 
					
						
							| 
									
										
										
										
											2022-08-12 00:32:37 +02:00
										 |  |  | 
 | 
					
						
							|  |  |  | try: | 
					
						
							| 
									
										
										
										
											2024-02-27 08:44:34 +01:00
										 |  |  |     from yaml import CLoader as UnsafeLoader, CSafeLoader as SafeLoader, CDumper as Dumper | 
					
						
							| 
									
										
										
										
											2022-08-12 00:32:37 +02:00
										 |  |  | except ImportError: | 
					
						
							| 
									
										
										
										
											2024-02-27 08:44:34 +01:00
										 |  |  |     from yaml import Loader as UnsafeLoader, SafeLoader, Dumper | 
					
						
							| 
									
										
										
										
											2022-05-18 22:30:19 +02:00
										 |  |  | 
 | 
					
						
							|  |  |  | if typing.TYPE_CHECKING: | 
					
						
							| 
									
										
										
										
											2022-08-12 00:32:37 +02:00
										 |  |  |     import tkinter | 
					
						
							|  |  |  |     import pathlib | 
					
						
							| 
									
										
										
										
											2023-10-02 01:56:55 +02:00
										 |  |  |     from BaseClasses import Region | 
					
						
							| 
									
										
										
										
											2024-10-28 08:41:36 +01:00
										 |  |  |     import multiprocessing | 
					
						
							| 
									
										
										
										
											2020-06-21 15:32:31 +02:00
										 |  |  | 
 | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2021-08-06 19:33:17 +02:00
										 |  |  | def tuplize_version(version: str) -> Version: | 
					
						
							| 
									
										
										
										
											2020-12-29 19:23:14 +01:00
										 |  |  |     return Version(*(int(piece, 10) for piece in version.split("."))) | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2020-06-21 15:32:31 +02:00
										 |  |  | 
 | 
					
						
							| 
									
										
										
										
											2020-12-29 19:23:14 +01:00
										 |  |  | class Version(typing.NamedTuple): | 
					
						
							|  |  |  |     major: int | 
					
						
							|  |  |  |     minor: int | 
					
						
							| 
									
										
										
										
											2021-02-21 23:46:05 +01:00
										 |  |  |     build: int | 
					
						
							| 
									
										
										
										
											2020-04-22 05:09:46 +02:00
										 |  |  | 
 | 
					
						
							| 
									
										
										
										
											2023-04-25 13:26:52 +02:00
										 |  |  |     def as_simple_string(self) -> str: | 
					
						
							|  |  |  |         return ".".join(str(item) for item in self) | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2021-07-02 01:29:49 +02:00
										 |  |  | 
 | 
					
						
							| 
									
										
										
										
											2025-07-15 21:04:06 +02:00
										 |  |  | __version__ = "0.6.3" | 
					
						
							| 
									
										
										
										
											2021-06-18 22:15:54 +02:00
										 |  |  | version_tuple = tuplize_version(__version__) | 
					
						
							| 
									
										
										
										
											2020-04-20 14:50:49 +02:00
										 |  |  | 
 | 
					
						
							| 
									
										
										
										
											2022-08-12 00:32:37 +02:00
										 |  |  | is_linux = sys.platform.startswith("linux") | 
					
						
							|  |  |  | is_macos = sys.platform == "darwin" | 
					
						
							| 
									
										
										
										
											2022-06-04 18:10:34 +02:00
										 |  |  | is_windows = sys.platform in ("win32", "cygwin", "msys") | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2017-11-28 09:36:32 -05:00
										 |  |  | 
 | 
					
						
							| 
									
										
										
										
											2022-04-28 09:03:44 -07:00
										 |  |  | def int16_as_bytes(value: int) -> typing.List[int]: | 
					
						
							| 
									
										
										
										
											2018-02-17 18:38:54 -05:00
										 |  |  |     value = value & 0xFFFF | 
					
						
							|  |  |  |     return [value & 0xFF, (value >> 8) & 0xFF] | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2020-02-16 15:32:40 +01:00
										 |  |  | 
 | 
					
						
							| 
									
										
										
										
											2022-04-28 09:03:44 -07:00
										 |  |  | def int32_as_bytes(value: int) -> typing.List[int]: | 
					
						
							| 
									
										
										
										
											2018-02-17 18:38:54 -05:00
										 |  |  |     value = value & 0xFFFFFFFF | 
					
						
							|  |  |  |     return [value & 0xFF, (value >> 8) & 0xFF, (value >> 16) & 0xFF, (value >> 24) & 0xFF] | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2020-02-16 15:32:40 +01:00
										 |  |  | 
 | 
					
						
							| 
									
										
										
										
											2022-04-28 09:03:44 -07:00
										 |  |  | def pc_to_snes(value: int) -> int: | 
					
						
							| 
									
										
										
										
											2021-01-02 12:49:43 +01:00
										 |  |  |     return ((value << 1) & 0x7F0000) | (value & 0x7FFF) | 0x8000 | 
					
						
							| 
									
										
										
										
											2018-09-22 22:51:54 -04:00
										 |  |  | 
 | 
					
						
							| 
									
										
										
										
											2020-07-21 23:15:19 +02:00
										 |  |  | 
 | 
					
						
							| 
									
										
										
										
											2022-04-28 09:03:44 -07:00
										 |  |  | def snes_to_pc(value: int) -> int: | 
					
						
							| 
									
										
										
										
											2021-01-02 12:49:43 +01:00
										 |  |  |     return ((value & 0x7F0000) >> 1) | (value & 0x7FFF) | 
					
						
							| 
									
										
										
										
											2018-09-22 22:51:54 -04:00
										 |  |  | 
 | 
					
						
							| 
									
										
										
										
											2020-07-21 23:15:19 +02:00
										 |  |  | 
 | 
					
						
							| 
									
										
										
										
											2022-04-28 09:03:44 -07:00
										 |  |  | RetType = typing.TypeVar("RetType") | 
					
						
							| 
									
										
										
										
											2023-10-31 02:08:56 +01:00
										 |  |  | S = typing.TypeVar("S") | 
					
						
							|  |  |  | T = typing.TypeVar("T") | 
					
						
							| 
									
										
										
										
											2022-04-28 09:03:44 -07:00
										 |  |  | 
 | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | def cache_argsless(function: typing.Callable[[], RetType]) -> typing.Callable[[], RetType]: | 
					
						
							| 
									
										
										
										
											2022-04-30 04:39:08 +02:00
										 |  |  |     assert not function.__code__.co_argcount, "Can only cache 0 argument functions with this cache." | 
					
						
							| 
									
										
										
										
											2021-07-09 17:44:24 +02:00
										 |  |  | 
 | 
					
						
							| 
									
										
										
										
											2022-04-28 09:03:44 -07:00
										 |  |  |     sentinel = object() | 
					
						
							|  |  |  |     result: typing.Union[object, RetType] = sentinel | 
					
						
							| 
									
										
										
										
											2021-07-09 17:44:24 +02:00
										 |  |  | 
 | 
					
						
							| 
									
										
										
										
											2022-04-28 09:03:44 -07:00
										 |  |  |     def _wrap() -> RetType: | 
					
						
							| 
									
										
										
										
											2021-07-09 17:44:24 +02:00
										 |  |  |         nonlocal result | 
					
						
							|  |  |  |         if result is sentinel: | 
					
						
							|  |  |  |             result = function() | 
					
						
							| 
									
										
										
										
											2022-04-28 09:03:44 -07:00
										 |  |  |         return typing.cast(RetType, result) | 
					
						
							| 
									
										
										
										
											2021-07-09 17:44:24 +02:00
										 |  |  | 
 | 
					
						
							|  |  |  |     return _wrap | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2023-10-31 02:08:56 +01:00
										 |  |  | def cache_self1(function: typing.Callable[[S, T], RetType]) -> typing.Callable[[S, T], RetType]: | 
					
						
							|  |  |  |     """Specialized cache for self + 1 arg. Does not keep global ref to self and skips building a dict key tuple.""" | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |     assert function.__code__.co_argcount == 2, "Can only cache 2 argument functions with this cache." | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |     cache_name = f"__cache_{function.__name__}__" | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |     @functools.wraps(function) | 
					
						
							|  |  |  |     def wrap(self: S, arg: T) -> RetType: | 
					
						
							| 
									
										
										
										
											2024-05-23 10:55:45 -07:00
										 |  |  |         cache: Optional[Dict[T, RetType]] = getattr(self, cache_name, None) | 
					
						
							| 
									
										
										
										
											2023-10-31 02:08:56 +01:00
										 |  |  |         if cache is None: | 
					
						
							|  |  |  |             res = function(self, arg) | 
					
						
							|  |  |  |             setattr(self, cache_name, {arg: res}) | 
					
						
							|  |  |  |             return res | 
					
						
							|  |  |  |         try: | 
					
						
							|  |  |  |             return cache[arg] | 
					
						
							|  |  |  |         except KeyError: | 
					
						
							|  |  |  |             res = function(self, arg) | 
					
						
							|  |  |  |             cache[arg] = res | 
					
						
							|  |  |  |             return res | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2025-04-19 11:55:02 -04:00
										 |  |  |     wrap.__defaults__ = function.__defaults__ | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2023-10-31 02:08:56 +01:00
										 |  |  |     return wrap | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2021-07-19 21:52:08 +02:00
										 |  |  | def is_frozen() -> bool: | 
					
						
							| 
									
										
										
										
											2022-04-28 09:03:44 -07:00
										 |  |  |     return typing.cast(bool, getattr(sys, 'frozen', False)) | 
					
						
							| 
									
										
										
										
											2017-11-28 09:36:32 -05:00
										 |  |  | 
 | 
					
						
							| 
									
										
										
										
											2020-07-21 23:15:19 +02:00
										 |  |  | 
 | 
					
						
							| 
									
										
										
										
											2022-03-31 05:08:15 +02:00
										 |  |  | def local_path(*path: str) -> str: | 
					
						
							| 
									
										
										
										
											2023-03-29 20:14:45 +02:00
										 |  |  |     """
 | 
					
						
							|  |  |  |     Returns path to a file in the local Archipelago installation or source. | 
					
						
							|  |  |  |     This might be read-only and user_path should be used instead for ROMs, configuration, etc. | 
					
						
							|  |  |  |     """
 | 
					
						
							| 
									
										
										
										
											2022-03-31 05:08:15 +02:00
										 |  |  |     if hasattr(local_path, 'cached_path'): | 
					
						
							|  |  |  |         pass | 
					
						
							| 
									
										
										
										
											2021-07-19 21:52:08 +02:00
										 |  |  |     elif is_frozen(): | 
					
						
							| 
									
										
										
										
											2020-03-23 07:45:40 +01:00
										 |  |  |         if hasattr(sys, "_MEIPASS"): | 
					
						
							|  |  |  |             # we are running in a PyInstaller bundle | 
					
						
							|  |  |  |             local_path.cached_path = sys._MEIPASS  # pylint: disable=protected-access,no-member | 
					
						
							|  |  |  |         else: | 
					
						
							|  |  |  |             # cx_Freeze | 
					
						
							|  |  |  |             local_path.cached_path = os.path.dirname(os.path.abspath(sys.argv[0])) | 
					
						
							| 
									
										
										
										
											2017-11-28 09:36:32 -05:00
										 |  |  |     else: | 
					
						
							| 
									
										
										
										
											2020-03-23 07:45:40 +01:00
										 |  |  |         import __main__ | 
					
						
							| 
									
										
										
										
											2025-05-10 23:20:43 +10:00
										 |  |  |         if globals().get("__file__") and os.path.isfile(__file__): | 
					
						
							| 
									
										
										
										
											2021-04-04 03:18:19 +02:00
										 |  |  |             # we are running in a normal Python environment | 
					
						
							| 
									
										
										
										
											2025-05-10 23:20:43 +10:00
										 |  |  |             local_path.cached_path = os.path.dirname(os.path.abspath(__file__)) | 
					
						
							|  |  |  |         elif hasattr(__main__, "__file__") and os.path.isfile(__main__.__file__): | 
					
						
							|  |  |  |             # we are running in a normal Python environment, but AP was imported weirdly | 
					
						
							| 
									
										
										
										
											2021-04-04 03:18:19 +02:00
										 |  |  |             local_path.cached_path = os.path.dirname(os.path.abspath(__main__.__file__)) | 
					
						
							|  |  |  |         else: | 
					
						
							|  |  |  |             # pray | 
					
						
							|  |  |  |             local_path.cached_path = os.path.abspath(".") | 
					
						
							| 
									
										
										
										
											2020-03-15 19:32:00 +01:00
										 |  |  | 
 | 
					
						
							| 
									
										
										
										
											2020-08-25 13:22:47 +02:00
										 |  |  |     return os.path.join(local_path.cached_path, *path) | 
					
						
							| 
									
										
										
										
											2017-11-28 09:36:32 -05:00
										 |  |  | 
 | 
					
						
							| 
									
										
										
										
											2021-01-02 12:49:43 +01:00
										 |  |  | 
 | 
					
						
							| 
									
										
										
										
											2022-03-31 05:08:15 +02:00
										 |  |  | def home_path(*path: str) -> str: | 
					
						
							|  |  |  |     """Returns path to a file in the user home's Archipelago directory.""" | 
					
						
							|  |  |  |     if hasattr(home_path, 'cached_path'): | 
					
						
							|  |  |  |         pass | 
					
						
							|  |  |  |     elif sys.platform.startswith('linux'): | 
					
						
							| 
									
										
										
										
											2025-01-10 01:27:49 +01:00
										 |  |  |         xdg_data_home = os.getenv('XDG_DATA_HOME', os.path.expanduser('~/.local/share')) | 
					
						
							|  |  |  |         home_path.cached_path = xdg_data_home + '/Archipelago' | 
					
						
							|  |  |  |         if not os.path.isdir(home_path.cached_path): | 
					
						
							|  |  |  |             legacy_home_path = os.path.expanduser('~/Archipelago') | 
					
						
							|  |  |  |             if os.path.isdir(legacy_home_path): | 
					
						
							|  |  |  |                 os.renames(legacy_home_path, home_path.cached_path) | 
					
						
							|  |  |  |                 os.symlink(home_path.cached_path, legacy_home_path) | 
					
						
							|  |  |  |             else: | 
					
						
							|  |  |  |                 os.makedirs(home_path.cached_path, 0o700, exist_ok=True) | 
					
						
							| 
									
										
										
										
											2025-06-20 02:05:52 +10:00
										 |  |  |     elif sys.platform == 'darwin': | 
					
						
							|  |  |  |         import platformdirs | 
					
						
							|  |  |  |         home_path.cached_path = platformdirs.user_data_dir("Archipelago", False) | 
					
						
							|  |  |  |         os.makedirs(home_path.cached_path, 0o700, exist_ok=True) | 
					
						
							| 
									
										
										
										
											2022-03-31 05:08:15 +02:00
										 |  |  |     else: | 
					
						
							|  |  |  |         # not implemented | 
					
						
							|  |  |  |         home_path.cached_path = local_path()  # this will generate the same exceptions we got previously | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |     return os.path.join(home_path.cached_path, *path) | 
					
						
							| 
									
										
										
										
											2017-11-28 09:36:32 -05:00
										 |  |  | 
 | 
					
						
							| 
									
										
										
										
											2020-08-25 13:22:47 +02:00
										 |  |  | 
 | 
					
						
							| 
									
										
										
										
											2022-03-31 05:08:15 +02:00
										 |  |  | def user_path(*path: str) -> str: | 
					
						
							|  |  |  |     """Returns either local_path or home_path based on write permissions.""" | 
					
						
							| 
									
										
										
										
											2022-08-12 00:32:37 +02:00
										 |  |  |     if hasattr(user_path, "cached_path"): | 
					
						
							| 
									
										
										
										
											2022-03-31 05:08:15 +02:00
										 |  |  |         pass | 
					
						
							| 
									
										
										
										
											2025-06-20 02:05:52 +10:00
										 |  |  |     elif os.access(local_path(), os.W_OK) and not (is_macos and is_frozen()): | 
					
						
							| 
									
										
										
										
											2022-03-31 05:08:15 +02:00
										 |  |  |         user_path.cached_path = local_path() | 
					
						
							|  |  |  |     else: | 
					
						
							|  |  |  |         user_path.cached_path = home_path() | 
					
						
							| 
									
										
										
										
											2023-07-05 22:39:35 +02:00
										 |  |  |         # populate home from local | 
					
						
							|  |  |  |         if user_path.cached_path != local_path(): | 
					
						
							|  |  |  |             import filecmp | 
					
						
							|  |  |  |             if not os.path.exists(user_path("manifest.json")) or \ | 
					
						
							| 
									
										
										
										
											2023-11-04 10:26:51 +01:00
										 |  |  |                     not os.path.exists(local_path("manifest.json")) or \ | 
					
						
							| 
									
										
										
										
											2023-07-05 22:39:35 +02:00
										 |  |  |                     not filecmp.cmp(local_path("manifest.json"), user_path("manifest.json"), shallow=True): | 
					
						
							|  |  |  |                 import shutil | 
					
						
							| 
									
										
										
										
											2023-11-04 10:26:51 +01:00
										 |  |  |                 for dn in ("Players", "data/sprites", "data/lua"): | 
					
						
							| 
									
										
										
										
											2023-07-05 22:39:35 +02:00
										 |  |  |                     shutil.copytree(local_path(dn), user_path(dn), dirs_exist_ok=True) | 
					
						
							| 
									
										
										
										
											2023-11-04 10:26:51 +01:00
										 |  |  |                 if not os.path.exists(local_path("manifest.json")): | 
					
						
							|  |  |  |                     warnings.warn(f"Upgrading {user_path()} from something that is not a proper install") | 
					
						
							|  |  |  |                 else: | 
					
						
							|  |  |  |                     shutil.copy2(local_path("manifest.json"), user_path("manifest.json")) | 
					
						
							|  |  |  |             os.makedirs(user_path("worlds"), exist_ok=True) | 
					
						
							| 
									
										
										
										
											2022-03-31 05:08:15 +02:00
										 |  |  | 
 | 
					
						
							|  |  |  |     return os.path.join(user_path.cached_path, *path) | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2023-03-20 11:01:08 -05:00
										 |  |  | def cache_path(*path: str) -> str: | 
					
						
							|  |  |  |     """Returns path to a file in the user's Archipelago cache directory.""" | 
					
						
							|  |  |  |     if hasattr(cache_path, "cached_path"): | 
					
						
							|  |  |  |         pass | 
					
						
							|  |  |  |     else: | 
					
						
							| 
									
										
										
										
											2023-03-30 15:30:43 +02:00
										 |  |  |         import platformdirs | 
					
						
							|  |  |  |         cache_path.cached_path = platformdirs.user_cache_dir("Archipelago", False) | 
					
						
							| 
									
										
										
										
											2023-03-20 11:01:08 -05:00
										 |  |  | 
 | 
					
						
							|  |  |  |     return os.path.join(cache_path.cached_path, *path) | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2022-10-25 13:54:43 -04:00
										 |  |  | def output_path(*path: str) -> str: | 
					
						
							| 
									
										
										
										
											2022-03-31 05:08:15 +02:00
										 |  |  |     if hasattr(output_path, 'cached_path'): | 
					
						
							| 
									
										
										
										
											2020-08-25 13:22:47 +02:00
										 |  |  |         return os.path.join(output_path.cached_path, *path) | 
					
						
							| 
									
										
										
										
											2024-04-24 06:24:44 +02:00
										 |  |  |     output_path.cached_path = user_path(get_settings()["general_options"]["output_path"]) | 
					
						
							| 
									
										
										
										
											2020-08-25 13:22:47 +02:00
										 |  |  |     path = os.path.join(output_path.cached_path, *path) | 
					
						
							| 
									
										
										
										
											2020-08-01 16:52:11 +02:00
										 |  |  |     os.makedirs(os.path.dirname(path), exist_ok=True) | 
					
						
							|  |  |  |     return path | 
					
						
							| 
									
										
										
										
											2017-11-28 09:36:32 -05:00
										 |  |  | 
 | 
					
						
							| 
									
										
										
										
											2021-01-02 12:49:43 +01:00
										 |  |  | 
 | 
					
						
							| 
									
										
										
										
											2022-08-12 00:32:37 +02:00
										 |  |  | def open_file(filename: typing.Union[str, "pathlib.Path"]) -> None: | 
					
						
							|  |  |  |     if is_windows: | 
					
						
							| 
									
										
										
										
											2024-05-23 10:55:45 -07:00
										 |  |  |         os.startfile(filename)  # type: ignore | 
					
						
							| 
									
										
										
										
											2017-11-28 09:36:32 -05:00
										 |  |  |     else: | 
					
						
							| 
									
										
										
										
											2022-08-12 00:32:37 +02:00
										 |  |  |         from shutil import which | 
					
						
							|  |  |  |         open_command = which("open") if is_macos else (which("xdg-open") or which("gnome-open") or which("kde-open")) | 
					
						
							| 
									
										
										
										
											2024-05-23 10:55:45 -07:00
										 |  |  |         assert open_command, "Didn't find program for open_file! Please report this together with system details." | 
					
						
							| 
									
										
										
										
											2025-06-03 10:42:37 +00:00
										 |  |  | 
 | 
					
						
							|  |  |  |         env = os.environ | 
					
						
							|  |  |  |         if "LD_LIBRARY_PATH" in env: | 
					
						
							|  |  |  |             env = env.copy() | 
					
						
							|  |  |  |             del env["LD_LIBRARY_PATH"]  # exe is a system binary, so reset LD_LIBRARY_PATH | 
					
						
							|  |  |  |         subprocess.call([open_command, filename], env=env) | 
					
						
							| 
									
										
										
										
											2017-12-02 09:21:04 -05:00
										 |  |  | 
 | 
					
						
							| 
									
										
										
										
											2021-01-02 12:49:43 +01:00
										 |  |  | 
 | 
					
						
							| 
									
										
										
										
											2022-01-19 04:26:25 +01:00
										 |  |  | # from https://gist.github.com/pypt/94d747fe5180851196eb#gistcomment-4015118 with some changes | 
					
						
							|  |  |  | class UniqueKeyLoader(SafeLoader): | 
					
						
							|  |  |  |     def construct_mapping(self, node, deep=False): | 
					
						
							|  |  |  |         mapping = set() | 
					
						
							|  |  |  |         for key_node, value_node in node.value: | 
					
						
							|  |  |  |             key = self.construct_object(key_node, deep=deep) | 
					
						
							|  |  |  |             if key in mapping: | 
					
						
							| 
									
										
										
										
											2022-01-25 04:20:08 +01:00
										 |  |  |                 logging.error(f"YAML duplicates sanity check failed{key_node.start_mark}") | 
					
						
							|  |  |  |                 raise KeyError(f"Duplicate key {key} found in YAML. Already found keys: {mapping}.") | 
					
						
							| 
									
										
										
										
											2024-03-12 16:08:12 -05:00
										 |  |  |             if (str(key).startswith("+") and (str(key)[1:] in mapping)) or (f"+{key}" in mapping): | 
					
						
							|  |  |  |                 logging.error(f"YAML merge duplicates sanity check failed{key_node.start_mark}") | 
					
						
							|  |  |  |                 raise KeyError(f"Equivalent key {key} found in YAML. Already found keys: {mapping}.") | 
					
						
							| 
									
										
										
										
											2022-01-19 04:26:25 +01:00
										 |  |  |             mapping.add(key) | 
					
						
							|  |  |  |         return super().construct_mapping(node, deep) | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | parse_yaml = functools.partial(load, Loader=UniqueKeyLoader) | 
					
						
							| 
									
										
										
										
											2022-04-12 10:57:29 +02:00
										 |  |  | parse_yamls = functools.partial(load_all, Loader=UniqueKeyLoader) | 
					
						
							| 
									
										
										
										
											2022-08-12 00:32:37 +02:00
										 |  |  | unsafe_parse_yaml = functools.partial(load, Loader=UnsafeLoader) | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | del load, load_all  # should not be used. don't leak their names | 
					
						
							| 
									
										
										
										
											2020-02-16 15:32:40 +01:00
										 |  |  | 
 | 
					
						
							| 
									
										
										
										
											2021-07-31 01:40:27 +02:00
										 |  |  | 
 | 
					
						
							| 
									
										
										
										
											2021-11-13 23:14:26 +01:00
										 |  |  | def get_cert_none_ssl_context(): | 
					
						
							|  |  |  |     import ssl | 
					
						
							|  |  |  |     ctx = ssl.create_default_context() | 
					
						
							|  |  |  |     ctx.check_hostname = False | 
					
						
							|  |  |  |     ctx.verify_mode = ssl.CERT_NONE | 
					
						
							|  |  |  |     return ctx | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2021-07-09 17:44:24 +02:00
										 |  |  | @cache_argsless | 
					
						
							| 
									
										
										
										
											2020-03-06 00:48:23 +01:00
										 |  |  | def get_public_ipv4() -> str: | 
					
						
							|  |  |  |     import socket | 
					
						
							|  |  |  |     import urllib.request | 
					
						
							| 
									
										
										
										
											2023-10-02 08:34:50 +02:00
										 |  |  |     try: | 
					
						
							|  |  |  |         ip = socket.gethostbyname(socket.gethostname()) | 
					
						
							|  |  |  |     except socket.gaierror: | 
					
						
							|  |  |  |         # if hostname or resolvconf is not set up properly, this may fail | 
					
						
							|  |  |  |         warnings.warn("Could not resolve own hostname, falling back to 127.0.0.1") | 
					
						
							|  |  |  |         ip = "127.0.0.1" | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2021-11-13 23:14:26 +01:00
										 |  |  |     ctx = get_cert_none_ssl_context() | 
					
						
							| 
									
										
										
										
											2020-03-06 00:48:23 +01:00
										 |  |  |     try: | 
					
						
							| 
									
										
										
										
											2023-03-09 21:31:00 +01:00
										 |  |  |         ip = urllib.request.urlopen("https://checkip.amazonaws.com/", context=ctx, timeout=10).read().decode("utf8").strip() | 
					
						
							| 
									
										
										
										
											2020-03-06 00:48:23 +01:00
										 |  |  |     except Exception as e: | 
					
						
							| 
									
										
										
										
											2022-08-12 00:32:37 +02:00
										 |  |  |         # noinspection PyBroadException | 
					
						
							| 
									
										
										
										
											2020-03-06 00:48:23 +01:00
										 |  |  |         try: | 
					
						
							| 
									
										
										
										
											2023-03-09 21:31:00 +01:00
										 |  |  |             ip = urllib.request.urlopen("https://v4.ident.me", context=ctx, timeout=10).read().decode("utf8").strip() | 
					
						
							| 
									
										
										
										
											2022-08-12 00:32:37 +02:00
										 |  |  |         except Exception: | 
					
						
							| 
									
										
										
										
											2020-03-06 00:48:23 +01:00
										 |  |  |             logging.exception(e) | 
					
						
							|  |  |  |             pass  # we could be offline, in a local game, so no point in erroring out | 
					
						
							|  |  |  |     return ip | 
					
						
							| 
									
										
										
										
											2020-03-15 19:32:00 +01:00
										 |  |  | 
 | 
					
						
							| 
									
										
										
										
											2021-07-31 01:40:27 +02:00
										 |  |  | 
 | 
					
						
							| 
									
										
										
										
											2021-07-09 17:44:24 +02:00
										 |  |  | @cache_argsless | 
					
						
							| 
									
										
										
										
											2020-06-14 09:06:37 +02:00
										 |  |  | def get_public_ipv6() -> str: | 
					
						
							|  |  |  |     import socket | 
					
						
							|  |  |  |     import urllib.request | 
					
						
							| 
									
										
										
										
											2023-10-02 08:34:50 +02:00
										 |  |  |     try: | 
					
						
							|  |  |  |         ip = socket.gethostbyname(socket.gethostname()) | 
					
						
							|  |  |  |     except socket.gaierror: | 
					
						
							|  |  |  |         # if hostname or resolvconf is not set up properly, this may fail | 
					
						
							|  |  |  |         warnings.warn("Could not resolve own hostname, falling back to ::1") | 
					
						
							|  |  |  |         ip = "::1" | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2021-11-13 23:14:26 +01:00
										 |  |  |     ctx = get_cert_none_ssl_context() | 
					
						
							| 
									
										
										
										
											2020-06-14 09:06:37 +02:00
										 |  |  |     try: | 
					
						
							| 
									
										
										
										
											2023-03-09 21:31:00 +01:00
										 |  |  |         ip = urllib.request.urlopen("https://v6.ident.me", context=ctx, timeout=10).read().decode("utf8").strip() | 
					
						
							| 
									
										
										
										
											2020-06-14 09:06:37 +02:00
										 |  |  |     except Exception as e: | 
					
						
							|  |  |  |         logging.exception(e) | 
					
						
							| 
									
										
										
										
											2020-06-21 16:13:42 +02:00
										 |  |  |         pass  # we could be offline, in a local game, or ipv6 may not be available | 
					
						
							| 
									
										
										
										
											2020-06-14 09:06:37 +02:00
										 |  |  |     return ip | 
					
						
							| 
									
										
										
										
											2020-03-15 19:32:00 +01:00
										 |  |  | 
 | 
					
						
							| 
									
										
										
										
											2021-07-31 01:40:27 +02:00
										 |  |  | 
 | 
					
						
							| 
									
										
										
										
											2023-10-28 19:32:12 +02:00
										 |  |  | OptionsType = Settings  # TODO: remove when removing get_options | 
					
						
							| 
									
										
										
										
											2022-09-28 14:54:10 -07:00
										 |  |  | 
 | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2023-10-28 19:32:12 +02:00
										 |  |  | def get_options() -> Settings: | 
					
						
							|  |  |  |     # TODO: switch to Utils.deprecate after 0.4.4 | 
					
						
							|  |  |  |     warnings.warn("Utils.get_options() is deprecated. Use the settings API instead.", DeprecationWarning) | 
					
						
							|  |  |  |     return get_settings() | 
					
						
							| 
									
										
										
										
											2020-04-14 20:22:42 +02:00
										 |  |  | 
 | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2024-05-23 10:55:45 -07:00
										 |  |  | def persistent_store(category: str, key: str, value: typing.Any): | 
					
						
							| 
									
										
										
										
											2022-03-31 05:08:15 +02:00
										 |  |  |     path = user_path("_persistent_storage.yaml") | 
					
						
							| 
									
										
										
										
											2024-05-23 10:55:45 -07:00
										 |  |  |     storage = persistent_load() | 
					
						
							|  |  |  |     category_dict = storage.setdefault(category, {}) | 
					
						
							|  |  |  |     category_dict[key] = value | 
					
						
							| 
									
										
										
										
											2020-04-24 05:29:02 +02:00
										 |  |  |     with open(path, "wt") as f: | 
					
						
							| 
									
										
										
										
											2022-08-12 00:32:37 +02:00
										 |  |  |         f.write(dump(storage, Dumper=Dumper)) | 
					
						
							| 
									
										
										
										
											2020-04-24 05:29:02 +02:00
										 |  |  | 
 | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2024-05-23 10:55:45 -07:00
										 |  |  | def persistent_load() -> Dict[str, Dict[str, Any]]: | 
					
						
							|  |  |  |     storage: Union[Dict[str, Dict[str, Any]], None] = getattr(persistent_load, "storage", None) | 
					
						
							| 
									
										
										
										
											2020-06-04 21:27:29 +02:00
										 |  |  |     if storage: | 
					
						
							|  |  |  |         return storage | 
					
						
							| 
									
										
										
										
											2022-03-31 05:08:15 +02:00
										 |  |  |     path = user_path("_persistent_storage.yaml") | 
					
						
							| 
									
										
										
										
											2024-05-23 10:55:45 -07:00
										 |  |  |     storage = {} | 
					
						
							| 
									
										
										
										
											2020-04-24 05:29:02 +02:00
										 |  |  |     if os.path.exists(path): | 
					
						
							|  |  |  |         try: | 
					
						
							|  |  |  |             with open(path, "r") as f: | 
					
						
							| 
									
										
										
										
											2020-07-05 02:06:00 +02:00
										 |  |  |                 storage = unsafe_parse_yaml(f.read()) | 
					
						
							| 
									
										
										
										
											2020-04-24 05:29:02 +02:00
										 |  |  |         except Exception as e: | 
					
						
							|  |  |  |             logging.debug(f"Could not read store: {e}") | 
					
						
							| 
									
										
										
										
											2020-04-29 22:42:26 -07:00
										 |  |  |     if storage is None: | 
					
						
							|  |  |  |         storage = {} | 
					
						
							| 
									
										
										
										
											2024-05-23 10:55:45 -07:00
										 |  |  |     setattr(persistent_load, "storage", storage) | 
					
						
							| 
									
										
										
										
											2020-04-24 05:29:02 +02:00
										 |  |  |     return storage | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2023-03-20 11:01:08 -05:00
										 |  |  | def get_file_safe_name(name: str) -> str: | 
					
						
							|  |  |  |     return "".join(c for c in name if c not in '<>:"/\\|?*') | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | def load_data_package_for_checksum(game: str, checksum: typing.Optional[str]) -> Dict[str, Any]: | 
					
						
							|  |  |  |     if checksum and game: | 
					
						
							|  |  |  |         if checksum != get_file_safe_name(checksum): | 
					
						
							|  |  |  |             raise ValueError(f"Bad symbols in checksum: {checksum}") | 
					
						
							|  |  |  |         path = cache_path("datapackage", get_file_safe_name(game), f"{checksum}.json") | 
					
						
							|  |  |  |         if os.path.exists(path): | 
					
						
							|  |  |  |             try: | 
					
						
							|  |  |  |                 with open(path, "r", encoding="utf-8-sig") as f: | 
					
						
							|  |  |  |                     return json.load(f) | 
					
						
							|  |  |  |             except Exception as e: | 
					
						
							|  |  |  |                 logging.debug(f"Could not load data package: {e}") | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |     # fall back to old cache | 
					
						
							|  |  |  |     cache = persistent_load().get("datapackage", {}).get("games", {}).get(game, {}) | 
					
						
							|  |  |  |     if cache.get("checksum") == checksum: | 
					
						
							|  |  |  |         return cache | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |     # cache does not match | 
					
						
							|  |  |  |     return {} | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | def store_data_package_for_checksum(game: str, data: typing.Dict[str, Any]) -> None: | 
					
						
							|  |  |  |     checksum = data.get("checksum") | 
					
						
							|  |  |  |     if checksum and game: | 
					
						
							|  |  |  |         if checksum != get_file_safe_name(checksum): | 
					
						
							|  |  |  |             raise ValueError(f"Bad symbols in checksum: {checksum}") | 
					
						
							|  |  |  |         game_folder = cache_path("datapackage", get_file_safe_name(game)) | 
					
						
							|  |  |  |         os.makedirs(game_folder, exist_ok=True) | 
					
						
							|  |  |  |         try: | 
					
						
							|  |  |  |             with open(os.path.join(game_folder, f"{checksum}.json"), "w", encoding="utf-8-sig") as f: | 
					
						
							|  |  |  |                 json.dump(data, f, ensure_ascii=False, separators=(",", ":")) | 
					
						
							|  |  |  |         except Exception as e: | 
					
						
							|  |  |  |             logging.debug(f"Could not store data package: {e}") | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2024-05-23 10:55:45 -07:00
										 |  |  | 
 | 
					
						
							| 
									
										
										
										
											2023-08-25 13:25:02 -07:00
										 |  |  | def get_default_adjuster_settings(game_name: str) -> Namespace: | 
					
						
							|  |  |  |     import LttPAdjuster | 
					
						
							|  |  |  |     adjuster_settings = Namespace() | 
					
						
							|  |  |  |     if game_name == LttPAdjuster.GAME_ALTTP: | 
					
						
							|  |  |  |         return LttPAdjuster.get_argparser().parse_known_args(args=[])[0] | 
					
						
							| 
									
										
										
										
											2023-03-20 11:01:08 -05:00
										 |  |  | 
 | 
					
						
							| 
									
										
										
										
											2022-01-20 04:19:58 +01:00
										 |  |  |     return adjuster_settings | 
					
						
							| 
									
										
										
										
											2020-06-07 12:04:33 -07:00
										 |  |  | 
 | 
					
						
							| 
									
										
										
										
											2021-07-31 15:13:55 +02:00
										 |  |  | 
 | 
					
						
							| 
									
										
										
										
											2023-08-25 13:25:02 -07:00
										 |  |  | def get_adjuster_settings_no_defaults(game_name: str) -> Namespace: | 
					
						
							|  |  |  |     return persistent_load().get("adjuster", {}).get(game_name, Namespace()) | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | def get_adjuster_settings(game_name: str) -> Namespace: | 
					
						
							|  |  |  |     adjuster_settings = get_adjuster_settings_no_defaults(game_name) | 
					
						
							|  |  |  |     default_settings = get_default_adjuster_settings(game_name) | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |     # Fill in any arguments from the argparser that we haven't seen before | 
					
						
							| 
									
										
										
										
											2024-05-23 10:55:45 -07:00
										 |  |  |     return Namespace(**vars(adjuster_settings), **{ | 
					
						
							|  |  |  |         k: v for k, v in vars(default_settings).items() if k not in vars(adjuster_settings) | 
					
						
							|  |  |  |     }) | 
					
						
							| 
									
										
										
										
											2023-08-25 13:25:02 -07:00
										 |  |  | 
 | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2021-07-09 17:44:24 +02:00
										 |  |  | @cache_argsless | 
					
						
							| 
									
										
										
										
											2020-06-04 21:27:29 +02:00
										 |  |  | def get_unique_identifier(): | 
					
						
							| 
									
										
										
										
											2025-07-15 00:10:40 -05:00
										 |  |  |     common_path = cache_path("common.json") | 
					
						
							|  |  |  |     if os.path.exists(common_path): | 
					
						
							|  |  |  |         with open(common_path) as f: | 
					
						
							|  |  |  |             common_file = json.load(f) | 
					
						
							|  |  |  |             uuid = common_file.get("uuid", None) | 
					
						
							|  |  |  |     else: | 
					
						
							|  |  |  |         common_file = {} | 
					
						
							|  |  |  |         uuid = None | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2020-06-04 21:27:29 +02:00
										 |  |  |     if uuid: | 
					
						
							|  |  |  |         return uuid | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2025-07-15 00:10:40 -05:00
										 |  |  |     from uuid import uuid4 | 
					
						
							|  |  |  |     uuid = str(uuid4()) | 
					
						
							|  |  |  |     common_file["uuid"] = uuid | 
					
						
							|  |  |  |     with open(common_path, "w") as f: | 
					
						
							|  |  |  |         json.dump(common_file, f, separators=(",", ":")) | 
					
						
							| 
									
										
										
										
											2020-06-04 21:27:29 +02:00
										 |  |  |     return uuid | 
					
						
							| 
									
										
										
										
											2020-09-09 01:41:37 +02:00
										 |  |  | 
 | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2022-08-12 00:32:37 +02:00
										 |  |  | safe_builtins = frozenset(( | 
					
						
							| 
									
										
										
										
											2020-09-09 01:41:37 +02:00
										 |  |  |     'set', | 
					
						
							|  |  |  |     'frozenset', | 
					
						
							| 
									
										
										
										
											2022-08-12 00:32:37 +02:00
										 |  |  | )) | 
					
						
							| 
									
										
										
										
											2020-09-09 01:41:37 +02:00
										 |  |  | 
 | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | class RestrictedUnpickler(pickle.Unpickler): | 
					
						
							| 
									
										
										
										
											2023-09-20 16:05:56 +02:00
										 |  |  |     generic_properties_module: Optional[object] | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2024-05-23 10:55:45 -07:00
										 |  |  |     def __init__(self, *args: Any, **kwargs: Any) -> None: | 
					
						
							| 
									
										
										
										
											2021-09-18 01:02:26 +02:00
										 |  |  |         super(RestrictedUnpickler, self).__init__(*args, **kwargs) | 
					
						
							|  |  |  |         self.options_module = importlib.import_module("Options") | 
					
						
							|  |  |  |         self.net_utils_module = importlib.import_module("NetUtils") | 
					
						
							| 
									
										
										
										
											2023-09-20 16:05:56 +02:00
										 |  |  |         self.generic_properties_module = None | 
					
						
							| 
									
										
										
										
											2021-09-18 01:02:26 +02:00
										 |  |  | 
 | 
					
						
							| 
									
										
										
										
											2024-05-23 10:55:45 -07:00
										 |  |  |     def find_class(self, module: str, name: str) -> type: | 
					
						
							| 
									
										
										
										
											2020-09-09 01:41:37 +02:00
										 |  |  |         if module == "builtins" and name in safe_builtins: | 
					
						
							|  |  |  |             return getattr(builtins, name) | 
					
						
							| 
									
										
										
										
											2025-07-11 23:28:18 +02:00
										 |  |  |         # used by OptionCounter | 
					
						
							|  |  |  |         # necessary because the actual Options class instances are pickled when transfered to WebHost generation pool | 
					
						
							|  |  |  |         if module == "collections" and name == "Counter": | 
					
						
							|  |  |  |             return collections.Counter | 
					
						
							| 
									
										
										
										
											2021-09-18 01:02:26 +02:00
										 |  |  |         # used by MultiServer -> savegame/multidata | 
					
						
							| 
									
										
										
										
											2024-11-28 20:10:31 -05:00
										 |  |  |         if module == "NetUtils" and name in {"NetworkItem", "ClientStatus", "Hint", | 
					
						
							|  |  |  |                                              "SlotType", "NetworkSlot", "HintStatus"}: | 
					
						
							| 
									
										
										
										
											2021-09-18 01:02:26 +02:00
										 |  |  |             return getattr(self.net_utils_module, name) | 
					
						
							| 
									
										
										
										
											2021-09-23 02:29:24 +02:00
										 |  |  |         # Options and Plando are unpickled by WebHost -> Generate | 
					
						
							| 
									
										
										
										
											2024-10-22 14:08:25 -05:00
										 |  |  |         if module == "worlds.generic" and name == "PlandoItem": | 
					
						
							| 
									
										
										
										
											2023-09-20 16:05:56 +02:00
										 |  |  |             if not self.generic_properties_module: | 
					
						
							|  |  |  |                 self.generic_properties_module = importlib.import_module("worlds.generic") | 
					
						
							| 
									
										
										
										
											2021-09-23 02:29:24 +02:00
										 |  |  |             return getattr(self.generic_properties_module, name) | 
					
						
							| 
									
										
										
										
											2022-09-28 14:54:10 -07:00
										 |  |  |         # pep 8 specifies that modules should have "all-lowercase names" (options, not Options) | 
					
						
							|  |  |  |         if module.lower().endswith("options"): | 
					
						
							| 
									
										
										
										
											2021-09-18 01:02:26 +02:00
										 |  |  |             if module == "Options": | 
					
						
							|  |  |  |                 mod = self.options_module | 
					
						
							|  |  |  |             else: | 
					
						
							|  |  |  |                 mod = importlib.import_module(module) | 
					
						
							|  |  |  |             obj = getattr(mod, name) | 
					
						
							| 
									
										
										
										
											2025-03-09 14:00:00 -05:00
										 |  |  |             if issubclass(obj, (self.options_module.Option, self.options_module.PlandoConnection, | 
					
						
							|  |  |  |                                 self.options_module.PlandoText)): | 
					
						
							| 
									
										
										
										
											2021-05-16 22:59:45 +02:00
										 |  |  |                 return obj | 
					
						
							| 
									
										
										
										
											2020-09-09 01:41:37 +02:00
										 |  |  |         # Forbid everything else. | 
					
						
							| 
									
										
										
										
											2022-08-12 00:32:37 +02:00
										 |  |  |         raise pickle.UnpicklingError(f"global '{module}.{name}' is forbidden") | 
					
						
							| 
									
										
										
										
											2020-09-09 01:41:37 +02:00
										 |  |  | 
 | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2024-05-23 10:55:45 -07:00
										 |  |  | def restricted_loads(s: bytes) -> Any: | 
					
						
							| 
									
										
										
										
											2020-09-09 01:41:37 +02:00
										 |  |  |     """Helper function analogous to pickle.loads().""" | 
					
						
							| 
									
										
										
										
											2021-07-07 10:14:58 +02:00
										 |  |  |     return RestrictedUnpickler(io.BytesIO(s)).load() | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2021-07-09 17:44:24 +02:00
										 |  |  | 
 | 
					
						
							| 
									
										
										
										
											2025-07-26 15:01:40 -06:00
										 |  |  | def restricted_dumps(obj: Any) -> bytes: | 
					
						
							|  |  |  |     """Helper function analogous to pickle.dumps().""" | 
					
						
							|  |  |  |     s = pickle.dumps(obj) | 
					
						
							|  |  |  |     # Assert that the string can be successfully loaded by restricted_loads | 
					
						
							|  |  |  |     try: | 
					
						
							|  |  |  |         restricted_loads(s) | 
					
						
							|  |  |  |     except pickle.UnpicklingError as e: | 
					
						
							|  |  |  |         raise pickle.PicklingError(e) from e | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |     return s | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2023-05-02 08:23:39 +02:00
										 |  |  | class ByValue: | 
					
						
							|  |  |  |     """
 | 
					
						
							|  |  |  |     Mixin for enums to pickle value instead of name (restores pre-3.11 behavior). Use as left-most parent. | 
					
						
							|  |  |  |     See https://github.com/python/cpython/pull/26658 for why this exists. | 
					
						
							|  |  |  |     """
 | 
					
						
							|  |  |  |     def __reduce_ex__(self, prot): | 
					
						
							|  |  |  |         return self.__class__, (self._value_, ) | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2021-07-07 10:14:58 +02:00
										 |  |  | class KeyedDefaultDict(collections.defaultdict): | 
					
						
							| 
									
										
										
										
											2022-08-12 06:52:01 +02:00
										 |  |  |     """defaultdict variant that uses the missing key as argument to default_factory""" | 
					
						
							|  |  |  |     default_factory: typing.Callable[[typing.Any], typing.Any] | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2024-06-03 03:45:01 -05:00
										 |  |  |     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) | 
					
						
							| 
									
										
										
										
											2024-06-01 06:07:13 -05:00
										 |  |  | 
 | 
					
						
							| 
									
										
										
										
											2021-07-07 10:14:58 +02:00
										 |  |  |     def __missing__(self, key): | 
					
						
							|  |  |  |         self[key] = value = self.default_factory(key) | 
					
						
							| 
									
										
										
										
											2021-10-16 19:40:27 +02:00
										 |  |  |         return value | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | def get_text_between(text: str, start: str, end: str) -> str: | 
					
						
							|  |  |  |     return text[text.index(start) + len(start): text.rindex(end)] | 
					
						
							| 
									
										
										
										
											2021-11-10 15:35:43 +01:00
										 |  |  | 
 | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2022-08-18 00:27:37 +02:00
										 |  |  | def get_text_after(text: str, start: str) -> str: | 
					
						
							|  |  |  |     return text[text.index(start) + len(start):] | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2021-11-10 15:35:43 +01:00
										 |  |  | loglevel_mapping = {'error': logging.ERROR, 'info': logging.INFO, 'warning': logging.WARNING, 'debug': logging.DEBUG} | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2024-11-30 22:33:36 -05:00
										 |  |  | def init_logging(name: str, loglevel: typing.Union[str, int] = logging.INFO, | 
					
						
							|  |  |  |                  write_mode: str = "w", log_format: str = "[%(name)s at %(asctime)s]: %(message)s", | 
					
						
							|  |  |  |                  add_timestamp: bool = False, exception_logger: typing.Optional[str] = None): | 
					
						
							| 
									
										
										
										
											2022-11-17 21:27:44 +01:00
										 |  |  |     import datetime | 
					
						
							| 
									
										
										
										
											2021-11-10 15:35:43 +01:00
										 |  |  |     loglevel: int = loglevel_mapping.get(loglevel, loglevel) | 
					
						
							| 
									
										
										
										
											2022-03-31 05:08:15 +02:00
										 |  |  |     log_folder = user_path("logs") | 
					
						
							| 
									
										
										
										
											2021-11-10 15:35:43 +01:00
										 |  |  |     os.makedirs(log_folder, exist_ok=True) | 
					
						
							|  |  |  |     root_logger = logging.getLogger() | 
					
						
							|  |  |  |     for handler in root_logger.handlers[:]: | 
					
						
							|  |  |  |         root_logger.removeHandler(handler) | 
					
						
							|  |  |  |         handler.close() | 
					
						
							|  |  |  |     root_logger.setLevel(loglevel) | 
					
						
							| 
									
										
										
										
											2023-05-20 19:18:25 +02:00
										 |  |  |     logging.getLogger("websockets").setLevel(loglevel)  # make sure level is applied for websockets | 
					
						
							| 
									
										
										
										
											2022-11-17 21:27:44 +01:00
										 |  |  |     if "a" not in write_mode: | 
					
						
							|  |  |  |         name += f"_{datetime.datetime.now().strftime('%Y_%m_%d_%H_%M_%S')}" | 
					
						
							| 
									
										
										
										
											2021-11-10 15:35:43 +01:00
										 |  |  |     file_handler = logging.FileHandler( | 
					
						
							|  |  |  |         os.path.join(log_folder, f"{name}.txt"), | 
					
						
							|  |  |  |         write_mode, | 
					
						
							|  |  |  |         encoding="utf-8-sig") | 
					
						
							|  |  |  |     file_handler.setFormatter(logging.Formatter(log_format)) | 
					
						
							| 
									
										
										
										
											2023-10-08 13:26:14 +02:00
										 |  |  | 
 | 
					
						
							|  |  |  |     class Filter(logging.Filter): | 
					
						
							| 
									
										
										
										
											2024-05-23 10:55:45 -07:00
										 |  |  |         def __init__(self, filter_name: str, condition: typing.Callable[[logging.LogRecord], bool]) -> None: | 
					
						
							| 
									
										
										
										
											2023-10-08 13:26:14 +02:00
										 |  |  |             super().__init__(filter_name) | 
					
						
							|  |  |  |             self.condition = condition | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |         def filter(self, record: logging.LogRecord) -> bool: | 
					
						
							|  |  |  |             return self.condition(record) | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2025-01-16 18:35:07 +01:00
										 |  |  |     file_handler.addFilter(Filter("NoStream", lambda record: not getattr(record, "NoFile", False))) | 
					
						
							|  |  |  |     file_handler.addFilter(Filter("NoCarriageReturn", lambda record: '\r' not in record.getMessage())) | 
					
						
							| 
									
										
										
										
											2021-11-10 15:35:43 +01:00
										 |  |  |     root_logger.addHandler(file_handler) | 
					
						
							|  |  |  |     if sys.stdout: | 
					
						
							| 
									
										
										
										
											2024-11-28 22:16:54 -08:00
										 |  |  |         formatter = logging.Formatter(fmt='[%(asctime)s] %(message)s', datefmt='%Y-%m-%d %H:%M:%S') | 
					
						
							| 
									
										
										
										
											2023-10-08 13:26:14 +02:00
										 |  |  |         stream_handler = logging.StreamHandler(sys.stdout) | 
					
						
							|  |  |  |         stream_handler.addFilter(Filter("NoFile", lambda record: not getattr(record, "NoStream", False))) | 
					
						
							| 
									
										
										
										
											2024-11-30 22:33:36 -05:00
										 |  |  |         if add_timestamp: | 
					
						
							|  |  |  |             stream_handler.setFormatter(formatter) | 
					
						
							| 
									
										
										
										
											2023-10-08 13:26:14 +02:00
										 |  |  |         root_logger.addHandler(stream_handler) | 
					
						
							| 
									
										
										
										
											2025-05-21 23:22:55 +00:00
										 |  |  |         if hasattr(sys.stdout, "reconfigure"): | 
					
						
							|  |  |  |             sys.stdout.reconfigure(encoding="utf-8", errors="replace") | 
					
						
							| 
									
										
										
										
											2021-11-17 22:46:32 +01:00
										 |  |  | 
 | 
					
						
							|  |  |  |     # Relay unhandled exceptions to logger. | 
					
						
							|  |  |  |     if not getattr(sys.excepthook, "_wrapped", False):  # skip if already modified | 
					
						
							|  |  |  |         orig_hook = sys.excepthook | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |         def handle_exception(exc_type, exc_value, exc_traceback): | 
					
						
							|  |  |  |             if issubclass(exc_type, KeyboardInterrupt): | 
					
						
							|  |  |  |                 sys.__excepthook__(exc_type, exc_value, exc_traceback) | 
					
						
							|  |  |  |                 return | 
					
						
							|  |  |  |             logging.getLogger(exception_logger).exception("Uncaught exception", | 
					
						
							| 
									
										
										
										
											2024-12-15 13:29:56 -08:00
										 |  |  |                                                           exc_info=(exc_type, exc_value, exc_traceback), | 
					
						
							|  |  |  |                                                           extra={"NoStream": exception_logger is None}) | 
					
						
							| 
									
										
										
										
											2021-11-17 22:46:32 +01:00
										 |  |  |             return orig_hook(exc_type, exc_value, exc_traceback) | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |         handle_exception._wrapped = True | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |         sys.excepthook = handle_exception | 
					
						
							| 
									
										
										
										
											2021-11-28 04:06:30 +01:00
										 |  |  | 
 | 
					
						
							| 
									
										
										
										
											2022-11-17 21:27:44 +01:00
										 |  |  |     def _cleanup(): | 
					
						
							|  |  |  |         for file in os.scandir(log_folder): | 
					
						
							|  |  |  |             if file.name.endswith(".txt"): | 
					
						
							|  |  |  |                 last_change = datetime.datetime.fromtimestamp(file.stat().st_mtime) | 
					
						
							|  |  |  |                 if datetime.datetime.now() - last_change > datetime.timedelta(days=7): | 
					
						
							|  |  |  |                     try: | 
					
						
							|  |  |  |                         os.unlink(file.path) | 
					
						
							|  |  |  |                     except Exception as e: | 
					
						
							|  |  |  |                         logging.exception(e) | 
					
						
							|  |  |  |                     else: | 
					
						
							| 
									
										
										
										
											2023-01-23 02:23:16 +01:00
										 |  |  |                         logging.debug(f"Deleted old logfile {file.path}") | 
					
						
							| 
									
										
										
										
											2022-11-17 21:27:44 +01:00
										 |  |  |     import threading | 
					
						
							|  |  |  |     threading.Thread(target=_cleanup, name="LogCleaner").start() | 
					
						
							| 
									
										
										
										
											2022-11-28 02:52:36 +01:00
										 |  |  |     import platform | 
					
						
							|  |  |  |     logging.info( | 
					
						
							|  |  |  |         f"Archipelago ({__version__}) logging initialized" | 
					
						
							| 
									
										
										
										
											2024-12-03 03:00:56 +01:00
										 |  |  |         f" on {platform.platform()} process {os.getpid()}" | 
					
						
							| 
									
										
										
										
											2022-11-28 02:52:36 +01:00
										 |  |  |         f" running Python {sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}" | 
					
						
							| 
									
										
										
										
											2024-06-09 03:13:27 +02:00
										 |  |  |         f"{' (frozen)' if is_frozen() else ''}" | 
					
						
							| 
									
										
										
										
											2022-11-28 02:52:36 +01:00
										 |  |  |     ) | 
					
						
							| 
									
										
										
										
											2022-06-08 00:34:45 +02:00
										 |  |  | 
 | 
					
						
							| 
									
										
										
										
											2021-11-28 04:06:30 +01:00
										 |  |  | 
 | 
					
						
							| 
									
										
										
										
											2024-05-23 10:55:45 -07:00
										 |  |  | def stream_input(stream: typing.TextIO, queue: "asyncio.Queue[str]"): | 
					
						
							| 
									
										
										
										
											2021-11-28 04:06:30 +01:00
										 |  |  |     def queuer(): | 
					
						
							|  |  |  |         while 1: | 
					
						
							| 
									
										
										
										
											2022-08-03 14:53:14 +02:00
										 |  |  |             try: | 
					
						
							|  |  |  |                 text = stream.readline().strip() | 
					
						
							|  |  |  |             except UnicodeDecodeError as e: | 
					
						
							|  |  |  |                 logging.exception(e) | 
					
						
							|  |  |  |             else: | 
					
						
							|  |  |  |                 if text: | 
					
						
							|  |  |  |                     queue.put_nowait(text) | 
					
						
							| 
									
										
										
										
											2024-11-18 15:59:17 +01:00
										 |  |  |                 else: | 
					
						
							|  |  |  |                     sleep(0.01)  # non-blocking stream | 
					
						
							| 
									
										
										
										
											2021-11-28 04:06:30 +01:00
										 |  |  | 
 | 
					
						
							|  |  |  |     from threading import Thread | 
					
						
							|  |  |  |     thread = Thread(target=queuer, name=f"Stream handler for {stream.name}", daemon=True) | 
					
						
							|  |  |  |     thread.start() | 
					
						
							|  |  |  |     return thread | 
					
						
							| 
									
										
										
										
											2022-01-18 08:23:38 +01:00
										 |  |  | 
 | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2022-08-12 00:32:37 +02:00
										 |  |  | def tkinter_center_window(window: "tkinter.Tk") -> None: | 
					
						
							| 
									
										
										
										
											2022-01-20 04:19:58 +01:00
										 |  |  |     window.update() | 
					
						
							| 
									
										
										
										
											2022-08-12 00:32:37 +02:00
										 |  |  |     x = int(window.winfo_screenwidth() / 2 - window.winfo_reqwidth() / 2) | 
					
						
							|  |  |  |     y = int(window.winfo_screenheight() / 2 - window.winfo_reqheight() / 2) | 
					
						
							|  |  |  |     window.geometry(f"+{x}+{y}") | 
					
						
							| 
									
										
										
										
											2022-01-20 04:19:58 +01:00
										 |  |  | 
 | 
					
						
							| 
									
										
										
										
											2022-02-24 04:47:01 +01:00
										 |  |  | 
 | 
					
						
							| 
									
										
										
										
											2022-01-18 08:23:38 +01:00
										 |  |  | class VersionException(Exception): | 
					
						
							|  |  |  |     pass | 
					
						
							| 
									
										
										
										
											2022-01-20 04:19:58 +01:00
										 |  |  | 
 | 
					
						
							| 
									
										
										
										
											2022-02-24 04:47:01 +01:00
										 |  |  | 
 | 
					
						
							| 
									
										
										
										
											2024-05-23 10:55:45 -07:00
										 |  |  | def chaining_prefix(index: int, labels: typing.Sequence[str]) -> str: | 
					
						
							| 
									
										
										
										
											2022-06-21 20:50:40 +02:00
										 |  |  |     text = "" | 
					
						
							|  |  |  |     max_label = len(labels) - 1 | 
					
						
							|  |  |  |     while index > max_label: | 
					
						
							|  |  |  |         text += labels[-1] | 
					
						
							|  |  |  |         index -= max_label | 
					
						
							|  |  |  |     return labels[index] + text | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2022-04-30 04:39:08 +02:00
										 |  |  | # noinspection PyPep8Naming | 
					
						
							| 
									
										
										
										
											2022-08-12 00:46:11 +02:00
										 |  |  | def format_SI_prefix(value, power=1000, power_labels=("", "k", "M", "G", "T", "P", "E", "Z", "Y")) -> str: | 
					
						
							| 
									
										
										
										
											2022-06-21 20:50:40 +02:00
										 |  |  |     """Formats a value into a value + metric/si prefix. More info at https://en.wikipedia.org/wiki/Metric_prefix""" | 
					
						
							| 
									
										
										
										
											2022-08-12 23:02:56 +02:00
										 |  |  |     import decimal | 
					
						
							| 
									
										
										
										
											2022-02-24 04:47:01 +01:00
										 |  |  |     n = 0 | 
					
						
							| 
									
										
										
										
											2022-06-21 20:50:40 +02:00
										 |  |  |     value = decimal.Decimal(value) | 
					
						
							| 
									
										
										
										
											2022-08-12 00:46:11 +02:00
										 |  |  |     limit = power - decimal.Decimal("0.005") | 
					
						
							|  |  |  |     while value >= limit: | 
					
						
							| 
									
										
										
										
											2022-02-24 04:47:01 +01:00
										 |  |  |         value /= power | 
					
						
							|  |  |  |         n += 1 | 
					
						
							| 
									
										
										
										
											2022-06-21 20:50:40 +02:00
										 |  |  | 
 | 
					
						
							|  |  |  |     return f"{value.quantize(decimal.Decimal('1.00'))} {chaining_prefix(n, power_labels)}" | 
					
						
							| 
									
										
										
										
											2022-05-09 07:18:50 +02:00
										 |  |  | 
 | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2024-05-23 10:55:45 -07:00
										 |  |  | def get_fuzzy_results(input_word: str, word_list: typing.Collection[str], limit: typing.Optional[int] = None) \ | 
					
						
							| 
									
										
										
										
											2022-05-09 17:03:16 +02:00
										 |  |  |         -> typing.List[typing.Tuple[str, int]]: | 
					
						
							| 
									
										
										
										
											2022-08-12 00:32:37 +02:00
										 |  |  |     import jellyfish | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |     def get_fuzzy_ratio(word1: str, word2: str) -> float: | 
					
						
							| 
									
										
										
										
											2025-05-05 19:18:20 -04:00
										 |  |  |         if word1 == word2: | 
					
						
							|  |  |  |             return 1.01 | 
					
						
							| 
									
										
										
										
											2022-08-12 00:32:37 +02:00
										 |  |  |         return (1 - jellyfish.damerau_levenshtein_distance(word1.lower(), word2.lower()) | 
					
						
							|  |  |  |                 / max(len(word1), len(word2))) | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2024-05-23 10:55:45 -07:00
										 |  |  |     limit = limit if limit else len(word_list) | 
					
						
							| 
									
										
										
										
											2022-05-09 17:03:16 +02:00
										 |  |  |     return list( | 
					
						
							|  |  |  |         map( | 
					
						
							|  |  |  |             lambda container: (container[0], int(container[1]*100)),  # convert up to limit to int % | 
					
						
							|  |  |  |             sorted( | 
					
						
							| 
									
										
										
										
											2024-05-23 10:55:45 -07:00
										 |  |  |                 map(lambda candidate: (candidate, get_fuzzy_ratio(input_word, candidate)), word_list), | 
					
						
							| 
									
										
										
										
											2022-05-09 17:03:16 +02:00
										 |  |  |                 key=lambda element: element[1], | 
					
						
							| 
									
										
										
										
											2024-05-23 10:55:45 -07:00
										 |  |  |                 reverse=True | 
					
						
							|  |  |  |             )[0:limit] | 
					
						
							| 
									
										
										
										
											2022-05-09 17:03:16 +02:00
										 |  |  |         ) | 
					
						
							|  |  |  |     ) | 
					
						
							| 
									
										
										
										
											2022-06-04 17:02:02 +02:00
										 |  |  | 
 | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2024-06-01 14:32:41 +02:00
										 |  |  | def get_intended_text(input_text: str, possible_answers) -> typing.Tuple[str, bool, str]: | 
					
						
							|  |  |  |     picks = get_fuzzy_results(input_text, possible_answers, limit=2) | 
					
						
							|  |  |  |     if len(picks) > 1: | 
					
						
							|  |  |  |         dif = picks[0][1] - picks[1][1] | 
					
						
							| 
									
										
										
										
											2025-05-05 19:18:20 -04:00
										 |  |  |         if picks[0][1] == 101: | 
					
						
							| 
									
										
										
										
											2024-06-01 14:32:41 +02:00
										 |  |  |             return picks[0][0], True, "Perfect Match" | 
					
						
							| 
									
										
										
										
											2025-05-05 19:18:20 -04:00
										 |  |  |         elif picks[0][1] == 100: | 
					
						
							|  |  |  |             return picks[0][0], True, "Case Insensitive Perfect Match" | 
					
						
							| 
									
										
										
										
											2024-06-01 14:32:41 +02:00
										 |  |  |         elif picks[0][1] < 75: | 
					
						
							|  |  |  |             return picks[0][0], False, f"Didn't find something that closely matches '{input_text}', " \ | 
					
						
							|  |  |  |                                        f"did you mean '{picks[0][0]}'? ({picks[0][1]}% sure)" | 
					
						
							|  |  |  |         elif dif > 5: | 
					
						
							|  |  |  |             return picks[0][0], True, "Close Match" | 
					
						
							|  |  |  |         else: | 
					
						
							|  |  |  |             return picks[0][0], False, f"Too many close matches for '{input_text}', " \ | 
					
						
							|  |  |  |                                        f"did you mean '{picks[0][0]}'? ({picks[0][1]}% sure)" | 
					
						
							|  |  |  |     else: | 
					
						
							|  |  |  |         if picks[0][1] > 90: | 
					
						
							|  |  |  |             return picks[0][0], True, "Only Option Match" | 
					
						
							|  |  |  |         else: | 
					
						
							|  |  |  |             return picks[0][0], False, f"Didn't find something that closely matches '{input_text}', " \ | 
					
						
							|  |  |  |                                        f"did you mean '{picks[0][0]}'? ({picks[0][1]}% sure)" | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | def get_input_text_from_response(text: str, command: str) -> typing.Optional[str]: | 
					
						
							|  |  |  |     if "did you mean " in text: | 
					
						
							|  |  |  |         for question in ("Didn't find something that closely matches", | 
					
						
							|  |  |  |                          "Too many close matches"): | 
					
						
							|  |  |  |             if text.startswith(question): | 
					
						
							|  |  |  |                 name = get_text_between(text, "did you mean '", | 
					
						
							|  |  |  |                                         "'? (") | 
					
						
							|  |  |  |                 return f"!{command} {name}" | 
					
						
							|  |  |  |     elif text.startswith("Missing: "): | 
					
						
							|  |  |  |         return text.replace("Missing: ", "!hint_location ") | 
					
						
							|  |  |  |     return None | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2024-10-28 08:41:36 +01:00
										 |  |  | def is_kivy_running() -> bool: | 
					
						
							|  |  |  |     if "kivy" in sys.modules: | 
					
						
							|  |  |  |         from kivy.app import App | 
					
						
							|  |  |  |         return App.get_running_app() is not None | 
					
						
							|  |  |  |     return False | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | def _mp_open_filename(res: "multiprocessing.Queue[typing.Optional[str]]", *args: Any) -> None: | 
					
						
							|  |  |  |     if is_kivy_running(): | 
					
						
							|  |  |  |         raise RuntimeError("kivy should not be running in multiprocess") | 
					
						
							|  |  |  |     res.put(open_filename(*args)) | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2025-06-03 10:42:37 +00:00
										 |  |  | def _run_for_stdout(*args: str): | 
					
						
							|  |  |  |     env = os.environ | 
					
						
							|  |  |  |     if "LD_LIBRARY_PATH" in env: | 
					
						
							|  |  |  |         env = env.copy() | 
					
						
							|  |  |  |         del env["LD_LIBRARY_PATH"]  # exe is a system binary, so reset LD_LIBRARY_PATH | 
					
						
							|  |  |  |     return subprocess.run(args, capture_output=True, text=True, env=env).stdout.split("\n", 1)[0] or None | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2024-05-23 10:55:45 -07:00
										 |  |  | def open_filename(title: str, filetypes: typing.Iterable[typing.Tuple[str, typing.Iterable[str]]], suggest: str = "") \ | 
					
						
							| 
									
										
										
										
											2022-06-04 18:36:50 +02:00
										 |  |  |         -> typing.Optional[str]: | 
					
						
							| 
									
										
										
										
											2024-04-23 19:05:03 +02:00
										 |  |  |     logging.info(f"Opening file input dialog for {title}.") | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2022-06-04 18:36:50 +02:00
										 |  |  |     if is_linux: | 
					
						
							|  |  |  |         # prefer native dialog | 
					
						
							| 
									
										
										
										
											2022-08-12 00:32:37 +02:00
										 |  |  |         from shutil import which | 
					
						
							|  |  |  |         kdialog = which("kdialog") | 
					
						
							| 
									
										
										
										
											2022-06-04 18:36:50 +02:00
										 |  |  |         if kdialog: | 
					
						
							|  |  |  |             k_filters = '|'.join((f'{text} (*{" *".join(ext)})' for (text, ext) in filetypes)) | 
					
						
							| 
									
										
										
										
											2025-06-03 10:42:37 +00:00
										 |  |  |             return _run_for_stdout(kdialog, f"--title={title}", "--getopenfilename", suggest or ".", k_filters) | 
					
						
							| 
									
										
										
										
											2022-08-12 00:32:37 +02:00
										 |  |  |         zenity = which("zenity") | 
					
						
							| 
									
										
										
										
											2022-06-04 18:36:50 +02:00
										 |  |  |         if zenity: | 
					
						
							|  |  |  |             z_filters = (f'--file-filter={text} ({", ".join(ext)}) | *{" *".join(ext)}' for (text, ext) in filetypes) | 
					
						
							| 
									
										
										
										
											2023-09-24 10:30:33 +02:00
										 |  |  |             selection = (f"--filename={suggest}",) if suggest else () | 
					
						
							| 
									
										
										
										
											2025-06-03 10:42:37 +00:00
										 |  |  |             return _run_for_stdout(zenity, f"--title={title}", "--file-selection", *z_filters, *selection) | 
					
						
							| 
									
										
										
										
											2023-07-05 22:39:35 +02:00
										 |  |  | 
 | 
					
						
							|  |  |  |     # fall back to tk | 
					
						
							|  |  |  |     try: | 
					
						
							|  |  |  |         import tkinter | 
					
						
							|  |  |  |         import tkinter.filedialog | 
					
						
							|  |  |  |     except Exception as e: | 
					
						
							|  |  |  |         logging.error('Could not load tkinter, which is likely not installed. ' | 
					
						
							|  |  |  |                       f'This attempt was made because open_filename was used for "{title}".') | 
					
						
							|  |  |  |         raise e | 
					
						
							|  |  |  |     else: | 
					
						
							| 
									
										
										
										
											2024-10-28 08:41:36 +01:00
										 |  |  |         if is_macos and is_kivy_running(): | 
					
						
							|  |  |  |             # on macOS, mixing kivy and tk does not work, so spawn a new process | 
					
						
							|  |  |  |             # FIXME: performance of this is pretty bad, and we should (also) look into alternatives | 
					
						
							|  |  |  |             from multiprocessing import Process, Queue | 
					
						
							|  |  |  |             res: "Queue[typing.Optional[str]]" = Queue() | 
					
						
							|  |  |  |             Process(target=_mp_open_filename, args=(res, title, filetypes, suggest)).start() | 
					
						
							|  |  |  |             return res.get() | 
					
						
							| 
									
										
										
										
											2023-09-24 10:30:33 +02:00
										 |  |  |         try: | 
					
						
							|  |  |  |             root = tkinter.Tk() | 
					
						
							|  |  |  |         except tkinter.TclError: | 
					
						
							|  |  |  |             return None  # GUI not available. None is the same as a user clicking "cancel" | 
					
						
							| 
									
										
										
										
											2023-07-05 22:39:35 +02:00
										 |  |  |         root.withdraw() | 
					
						
							|  |  |  |         return tkinter.filedialog.askopenfilename(title=title, filetypes=((t[0], ' '.join(t[1])) for t in filetypes), | 
					
						
							|  |  |  |                                                   initialfile=suggest or None) | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2024-10-28 08:41:36 +01:00
										 |  |  | def _mp_open_directory(res: "multiprocessing.Queue[typing.Optional[str]]", *args: Any) -> None: | 
					
						
							|  |  |  |     if is_kivy_running(): | 
					
						
							|  |  |  |         raise RuntimeError("kivy should not be running in multiprocess") | 
					
						
							|  |  |  |     res.put(open_directory(*args)) | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2023-07-05 22:39:35 +02:00
										 |  |  | def open_directory(title: str, suggest: str = "") -> typing.Optional[str]: | 
					
						
							|  |  |  |     if is_linux: | 
					
						
							|  |  |  |         # prefer native dialog | 
					
						
							|  |  |  |         from shutil import which | 
					
						
							| 
									
										
										
										
											2023-09-24 10:30:33 +02:00
										 |  |  |         kdialog = which("kdialog") | 
					
						
							| 
									
										
										
										
											2023-07-05 22:39:35 +02:00
										 |  |  |         if kdialog: | 
					
						
							| 
									
										
										
										
											2025-06-03 10:42:37 +00:00
										 |  |  |             return _run_for_stdout(kdialog, f"--title={title}", "--getexistingdirectory", | 
					
						
							| 
									
										
										
										
											2023-09-24 10:30:33 +02:00
										 |  |  |                        os.path.abspath(suggest) if suggest else ".") | 
					
						
							|  |  |  |         zenity = which("zenity") | 
					
						
							| 
									
										
										
										
											2023-07-05 22:39:35 +02:00
										 |  |  |         if zenity: | 
					
						
							|  |  |  |             z_filters = ("--directory",) | 
					
						
							| 
									
										
										
										
											2023-09-24 10:30:33 +02:00
										 |  |  |             selection = (f"--filename={os.path.abspath(suggest)}/",) if suggest else () | 
					
						
							| 
									
										
										
										
											2025-06-03 10:42:37 +00:00
										 |  |  |             return _run_for_stdout(zenity, f"--title={title}", "--file-selection", *z_filters, *selection) | 
					
						
							| 
									
										
										
										
											2022-06-04 18:36:50 +02:00
										 |  |  | 
 | 
					
						
							|  |  |  |     # fall back to tk | 
					
						
							|  |  |  |     try: | 
					
						
							|  |  |  |         import tkinter | 
					
						
							|  |  |  |         import tkinter.filedialog | 
					
						
							|  |  |  |     except Exception as e: | 
					
						
							|  |  |  |         logging.error('Could not load tkinter, which is likely not installed. ' | 
					
						
							| 
									
										
										
										
											2024-10-28 08:41:36 +01:00
										 |  |  |                       f'This attempt was made because open_directory was used for "{title}".') | 
					
						
							| 
									
										
										
										
											2022-06-04 18:36:50 +02:00
										 |  |  |         raise e | 
					
						
							|  |  |  |     else: | 
					
						
							| 
									
										
										
										
											2024-10-28 08:41:36 +01:00
										 |  |  |         if is_macos and is_kivy_running(): | 
					
						
							|  |  |  |             # on macOS, mixing kivy and tk does not work, so spawn a new process | 
					
						
							|  |  |  |             # FIXME: performance of this is pretty bad, and we should (also) look into alternatives | 
					
						
							|  |  |  |             from multiprocessing import Process, Queue | 
					
						
							|  |  |  |             res: "Queue[typing.Optional[str]]" = Queue() | 
					
						
							|  |  |  |             Process(target=_mp_open_directory, args=(res, title, suggest)).start() | 
					
						
							|  |  |  |             return res.get() | 
					
						
							| 
									
										
										
										
											2023-09-24 10:30:33 +02:00
										 |  |  |         try: | 
					
						
							|  |  |  |             root = tkinter.Tk() | 
					
						
							|  |  |  |         except tkinter.TclError: | 
					
						
							|  |  |  |             return None  # GUI not available. None is the same as a user clicking "cancel" | 
					
						
							| 
									
										
										
										
											2022-06-04 18:36:50 +02:00
										 |  |  |         root.withdraw() | 
					
						
							| 
									
										
										
										
											2023-07-05 22:39:35 +02:00
										 |  |  |         return tkinter.filedialog.askdirectory(title=title, mustexist=True, initialdir=suggest or None) | 
					
						
							| 
									
										
										
										
											2022-06-04 18:36:50 +02:00
										 |  |  | 
 | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2022-06-04 17:02:02 +02:00
										 |  |  | def messagebox(title: str, text: str, error: bool = False) -> None: | 
					
						
							|  |  |  |     if is_kivy_running(): | 
					
						
							|  |  |  |         from kvui import MessageBox | 
					
						
							|  |  |  |         MessageBox(title, text, error).open() | 
					
						
							|  |  |  |         return | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2022-08-12 00:32:37 +02:00
										 |  |  |     if is_linux and "tkinter" not in sys.modules: | 
					
						
							| 
									
										
										
										
											2022-06-23 19:26:30 +02:00
										 |  |  |         # prefer native dialog | 
					
						
							| 
									
										
										
										
											2022-08-12 00:32:37 +02:00
										 |  |  |         from shutil import which | 
					
						
							|  |  |  |         kdialog = which("kdialog") | 
					
						
							| 
									
										
										
										
											2022-06-23 19:26:30 +02:00
										 |  |  |         if kdialog: | 
					
						
							| 
									
										
										
										
											2025-06-03 10:42:37 +00:00
										 |  |  |             return _run_for_stdout(kdialog, f"--title={title}", "--error" if error else "--msgbox", text) | 
					
						
							| 
									
										
										
										
											2022-08-12 00:32:37 +02:00
										 |  |  |         zenity = which("zenity") | 
					
						
							| 
									
										
										
										
											2022-06-23 19:26:30 +02:00
										 |  |  |         if zenity: | 
					
						
							| 
									
										
										
										
											2025-06-03 10:42:37 +00:00
										 |  |  |             return _run_for_stdout(zenity, f"--title={title}", f"--text={text}", "--error" if error else "--info") | 
					
						
							| 
									
										
										
										
											2022-06-23 19:26:30 +02:00
										 |  |  | 
 | 
					
						
							| 
									
										
										
										
											2023-10-08 22:14:28 +02:00
										 |  |  |     elif is_windows: | 
					
						
							|  |  |  |         import ctypes | 
					
						
							|  |  |  |         style = 0x10 if error else 0x0 | 
					
						
							|  |  |  |         return ctypes.windll.user32.MessageBoxW(0, text, title, style) | 
					
						
							| 
									
										
										
										
											2024-03-11 19:30:14 -04:00
										 |  |  | 
 | 
					
						
							| 
									
										
										
										
											2022-06-04 17:02:02 +02:00
										 |  |  |     # fall back to tk | 
					
						
							|  |  |  |     try: | 
					
						
							|  |  |  |         import tkinter | 
					
						
							|  |  |  |         from tkinter.messagebox import showerror, showinfo | 
					
						
							|  |  |  |     except Exception as e: | 
					
						
							|  |  |  |         logging.error('Could not load tkinter, which is likely not installed. ' | 
					
						
							|  |  |  |                       f'This attempt was made because messagebox was used for "{title}".') | 
					
						
							|  |  |  |         raise e | 
					
						
							|  |  |  |     else: | 
					
						
							|  |  |  |         root = tkinter.Tk() | 
					
						
							|  |  |  |         root.withdraw() | 
					
						
							|  |  |  |         showerror(title, text) if error else showinfo(title, text) | 
					
						
							|  |  |  |         root.update() | 
					
						
							| 
									
										
										
										
											2022-08-09 22:21:45 +02:00
										 |  |  | 
 | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2024-05-23 10:55:45 -07:00
										 |  |  | def title_sorted(data: typing.Iterable, key=None, ignore: typing.AbstractSet[str] = frozenset(("a", "the"))): | 
					
						
							| 
									
										
										
										
											2022-08-09 22:21:45 +02:00
										 |  |  |     """Sorts a sequence of text ignoring typical articles like "a" or "the" in the beginning.""" | 
					
						
							| 
									
										
										
										
											2023-02-17 19:16:37 +01:00
										 |  |  |     def sorter(element: Union[str, Dict[str, Any]]) -> str: | 
					
						
							|  |  |  |         if (not isinstance(element, str)): | 
					
						
							|  |  |  |             element = element["title"] | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2022-08-09 22:21:45 +02:00
										 |  |  |         parts = element.split(maxsplit=1) | 
					
						
							|  |  |  |         if parts[0].lower() in ignore: | 
					
						
							| 
									
										
										
										
											2022-08-26 14:44:09 +00:00
										 |  |  |             return parts[1].lower() | 
					
						
							| 
									
										
										
										
											2022-08-09 22:21:45 +02:00
										 |  |  |         else: | 
					
						
							| 
									
										
										
										
											2022-08-26 14:44:09 +00:00
										 |  |  |             return element.lower() | 
					
						
							| 
									
										
										
										
											2022-08-09 22:21:45 +02:00
										 |  |  |     return sorted(data, key=lambda i: sorter(key(i)) if key else sorter(i)) | 
					
						
							| 
									
										
										
										
											2022-09-30 00:36:30 +02:00
										 |  |  | 
 | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | def read_snes_rom(stream: BinaryIO, strip_header: bool = True) -> bytearray: | 
					
						
							|  |  |  |     """Reads rom into bytearray and optionally strips off any smc header""" | 
					
						
							|  |  |  |     buffer = bytearray(stream.read()) | 
					
						
							|  |  |  |     if strip_header and len(buffer) % 0x400 == 0x200: | 
					
						
							|  |  |  |         return buffer[0x200:] | 
					
						
							|  |  |  |     return buffer | 
					
						
							| 
									
										
										
										
											2022-11-02 07:51:35 -07:00
										 |  |  | 
 | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2023-06-29 15:06:58 +02:00
										 |  |  | _faf_tasks: "Set[asyncio.Task[typing.Any]]" = set() | 
					
						
							| 
									
										
										
										
											2022-11-02 07:51:35 -07:00
										 |  |  | 
 | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2023-06-29 15:06:58 +02:00
										 |  |  | def async_start(co: Coroutine[None, None, typing.Any], name: Optional[str] = None) -> None: | 
					
						
							| 
									
										
										
										
											2022-11-02 07:51:35 -07:00
										 |  |  |     """
 | 
					
						
							|  |  |  |     Use this to start a task when you don't keep a reference to it or immediately await it, | 
					
						
							|  |  |  |     to prevent early garbage collection. "fire-and-forget" | 
					
						
							|  |  |  |     """
 | 
					
						
							|  |  |  |     # https://docs.python.org/3.10/library/asyncio-task.html#asyncio.create_task | 
					
						
							|  |  |  |     # Python docs: | 
					
						
							|  |  |  |     # ``` | 
					
						
							|  |  |  |     # Important: Save a reference to the result of [asyncio.create_task], | 
					
						
							|  |  |  |     # to avoid a task disappearing mid-execution. | 
					
						
							|  |  |  |     # ``` | 
					
						
							|  |  |  |     # This implementation follows the pattern given in that documentation. | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2023-06-29 15:06:58 +02:00
										 |  |  |     task: asyncio.Task[typing.Any] = asyncio.create_task(co, name=name) | 
					
						
							| 
									
										
										
										
											2022-11-02 07:51:35 -07:00
										 |  |  |     _faf_tasks.add(task) | 
					
						
							|  |  |  |     task.add_done_callback(_faf_tasks.discard) | 
					
						
							| 
									
										
										
										
											2023-06-19 09:57:17 +02:00
										 |  |  | 
 | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2024-11-29 11:40:02 -08:00
										 |  |  | def deprecate(message: str, add_stacklevels: int = 0): | 
					
						
							| 
									
										
										
										
											2023-06-19 09:57:17 +02:00
										 |  |  |     if __debug__: | 
					
						
							|  |  |  |         raise Exception(message) | 
					
						
							| 
									
										
										
										
											2024-11-29 11:40:02 -08:00
										 |  |  |     warnings.warn(message, stacklevel=2 + add_stacklevels) | 
					
						
							| 
									
										
										
										
											2023-06-25 02:24:43 +02:00
										 |  |  | 
 | 
					
						
							| 
									
										
										
										
											2023-12-16 15:21:05 -06:00
										 |  |  | 
 | 
					
						
							|  |  |  | class DeprecateDict(dict): | 
					
						
							|  |  |  |     log_message: str | 
					
						
							|  |  |  |     should_error: bool | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2024-05-23 10:55:45 -07:00
										 |  |  |     def __init__(self, message: str, error: bool = False) -> None: | 
					
						
							| 
									
										
										
										
											2023-12-16 15:21:05 -06:00
										 |  |  |         self.log_message = message | 
					
						
							|  |  |  |         self.should_error = error | 
					
						
							|  |  |  |         super().__init__() | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |     def __getitem__(self, item: Any) -> Any: | 
					
						
							|  |  |  |         if self.should_error: | 
					
						
							| 
									
										
										
										
											2024-11-29 11:40:02 -08:00
										 |  |  |             deprecate(self.log_message, add_stacklevels=1) | 
					
						
							| 
									
										
										
										
											2023-12-16 15:21:05 -06:00
										 |  |  |         elif __debug__: | 
					
						
							| 
									
										
										
										
											2024-11-29 11:40:02 -08:00
										 |  |  |             warnings.warn(self.log_message, stacklevel=2) | 
					
						
							| 
									
										
										
										
											2023-12-16 15:21:05 -06:00
										 |  |  |         return super().__getitem__(item) | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2023-06-25 02:24:43 +02:00
										 |  |  | def _extend_freeze_support() -> None: | 
					
						
							|  |  |  |     """Extend multiprocessing.freeze_support() to also work on Non-Windows for spawn.""" | 
					
						
							|  |  |  |     # upstream issue: https://github.com/python/cpython/issues/76327 | 
					
						
							|  |  |  |     # code based on https://github.com/pyinstaller/pyinstaller/blob/develop/PyInstaller/hooks/rthooks/pyi_rth_multiprocessing.py#L26 | 
					
						
							|  |  |  |     import multiprocessing | 
					
						
							|  |  |  |     import multiprocessing.spawn | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |     def _freeze_support() -> None: | 
					
						
							|  |  |  |         """Minimal freeze_support. Only apply this if frozen.""" | 
					
						
							|  |  |  |         from subprocess import _args_from_interpreter_flags | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |         # Prevent `spawn` from trying to read `__main__` in from the main script | 
					
						
							|  |  |  |         multiprocessing.process.ORIGINAL_DIR = None | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |         # Handle the first process that MP will create | 
					
						
							|  |  |  |         if ( | 
					
						
							|  |  |  |             len(sys.argv) >= 2 and sys.argv[-2] == '-c' and sys.argv[-1].startswith(( | 
					
						
							| 
									
										
										
										
											2025-07-31 14:33:56 -06:00
										 |  |  |                 'from multiprocessing.resource_tracker import main', | 
					
						
							| 
									
										
										
										
											2023-06-25 02:24:43 +02:00
										 |  |  |                 'from multiprocessing.forkserver import main' | 
					
						
							|  |  |  |             )) and set(sys.argv[1:-2]) == set(_args_from_interpreter_flags()) | 
					
						
							|  |  |  |         ): | 
					
						
							|  |  |  |             exec(sys.argv[-1]) | 
					
						
							|  |  |  |             sys.exit() | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |         # Handle the second process that MP will create | 
					
						
							|  |  |  |         if multiprocessing.spawn.is_forking(sys.argv): | 
					
						
							|  |  |  |             kwargs = {} | 
					
						
							|  |  |  |             for arg in sys.argv[2:]: | 
					
						
							|  |  |  |                 name, value = arg.split('=') | 
					
						
							|  |  |  |                 if value == 'None': | 
					
						
							|  |  |  |                     kwargs[name] = None | 
					
						
							|  |  |  |                 else: | 
					
						
							|  |  |  |                     kwargs[name] = int(value) | 
					
						
							|  |  |  |             multiprocessing.spawn.spawn_main(**kwargs) | 
					
						
							|  |  |  |             sys.exit() | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |     if not is_windows and is_frozen(): | 
					
						
							|  |  |  |         multiprocessing.freeze_support = multiprocessing.spawn.freeze_support = _freeze_support | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | def freeze_support() -> None: | 
					
						
							|  |  |  |     """This behaves like multiprocessing.freeze_support but also works on Non-Windows.""" | 
					
						
							|  |  |  |     import multiprocessing | 
					
						
							|  |  |  |     _extend_freeze_support() | 
					
						
							|  |  |  |     multiprocessing.freeze_support() | 
					
						
							| 
									
										
										
										
											2023-10-02 01:56:55 +02:00
										 |  |  | 
 | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | def visualize_regions(root_region: Region, file_name: str, *, | 
					
						
							|  |  |  |                       show_entrance_names: bool = False, show_locations: bool = True, show_other_regions: bool = True, | 
					
						
							| 
									
										
										
										
											2025-01-14 04:45:59 -05:00
										 |  |  |                       linetype_ortho: bool = True, regions_to_highlight: set[Region] | None = None) -> None: | 
					
						
							| 
									
										
										
										
											2023-10-02 01:56:55 +02:00
										 |  |  |     """Visualize the layout of a world as a PlantUML diagram.
 | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |     :param root_region: The region from which to start the diagram from. (Usually the "Menu" region of your world.) | 
					
						
							|  |  |  |     :param file_name: The name of the destination .puml file. | 
					
						
							|  |  |  |     :param show_entrance_names: (default False) If enabled, the name of the entrance will be shown near each connection. | 
					
						
							|  |  |  |     :param show_locations: (default True) If enabled, the locations will be listed inside each region. | 
					
						
							|  |  |  |             Priority locations will be shown in bold. | 
					
						
							|  |  |  |             Excluded locations will be stricken out. | 
					
						
							|  |  |  |             Locations without ID will be shown in italics. | 
					
						
							|  |  |  |             Locked locations will be shown with a padlock icon. | 
					
						
							|  |  |  |             For filled locations, the item name will be shown after the location name. | 
					
						
							|  |  |  |             Progression items will be shown in bold. | 
					
						
							|  |  |  |             Items without ID will be shown in italics. | 
					
						
							|  |  |  |     :param show_other_regions: (default True) If enabled, regions that can't be reached by traversing exits are shown. | 
					
						
							|  |  |  |     :param linetype_ortho: (default True) If enabled, orthogonal straight line parts will be used; otherwise polylines. | 
					
						
							| 
									
										
										
										
											2025-01-14 04:45:59 -05:00
										 |  |  |     :param regions_to_highlight: Regions that will be highlighted in green if they are reachable. | 
					
						
							| 
									
										
										
										
											2023-10-02 01:56:55 +02:00
										 |  |  | 
 | 
					
						
							|  |  |  |     Example usage in World code: | 
					
						
							|  |  |  |     from Utils import visualize_regions | 
					
						
							| 
									
										
										
										
											2025-01-14 04:45:59 -05:00
										 |  |  |     state = self.multiworld.get_all_state(False) | 
					
						
							|  |  |  |     state.update_reachable_regions(self.player) | 
					
						
							|  |  |  |     visualize_regions(self.get_region("Menu"), "my_world.puml", show_entrance_names=True, | 
					
						
							|  |  |  |                       regions_to_highlight=state.reachable_regions[self.player]) | 
					
						
							| 
									
										
										
										
											2023-10-02 01:56:55 +02:00
										 |  |  | 
 | 
					
						
							|  |  |  |     Example usage in Main code: | 
					
						
							|  |  |  |     from Utils import visualize_regions | 
					
						
							| 
									
										
										
										
											2024-02-04 18:38:00 -05:00
										 |  |  |     for player in multiworld.player_ids: | 
					
						
							|  |  |  |         visualize_regions(multiworld.get_region("Menu", player), f"{multiworld.get_out_file_name_base(player)}.puml") | 
					
						
							| 
									
										
										
										
											2023-10-02 01:56:55 +02:00
										 |  |  |     """
 | 
					
						
							| 
									
										
										
										
											2025-01-14 04:45:59 -05:00
										 |  |  |     if regions_to_highlight is None: | 
					
						
							|  |  |  |         regions_to_highlight = set() | 
					
						
							| 
									
										
										
										
											2023-10-02 01:56:55 +02:00
										 |  |  |     assert root_region.multiworld, "The multiworld attribute of root_region has to be filled" | 
					
						
							|  |  |  |     from BaseClasses import Entrance, Item, Location, LocationProgressType, MultiWorld, Region | 
					
						
							|  |  |  |     from collections import deque | 
					
						
							|  |  |  |     import re | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |     uml: typing.List[str] = list() | 
					
						
							|  |  |  |     seen: typing.Set[Region] = set() | 
					
						
							|  |  |  |     regions: typing.Deque[Region] = deque((root_region,)) | 
					
						
							|  |  |  |     multiworld: MultiWorld = root_region.multiworld | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |     def fmt(obj: Union[Entrance, Item, Location, Region]) -> str: | 
					
						
							|  |  |  |         name = obj.name | 
					
						
							|  |  |  |         if isinstance(obj, Item): | 
					
						
							|  |  |  |             name = multiworld.get_name_string_for_object(obj) | 
					
						
							|  |  |  |             if obj.advancement: | 
					
						
							|  |  |  |                 name = f"**{name}**" | 
					
						
							|  |  |  |             if obj.code is None: | 
					
						
							|  |  |  |                 name = f"//{name}//" | 
					
						
							|  |  |  |         if isinstance(obj, Location): | 
					
						
							|  |  |  |             if obj.progress_type == LocationProgressType.PRIORITY: | 
					
						
							|  |  |  |                 name = f"**{name}**" | 
					
						
							|  |  |  |             elif obj.progress_type == LocationProgressType.EXCLUDED: | 
					
						
							|  |  |  |                 name = f"--{name}--" | 
					
						
							|  |  |  |             if obj.address is None: | 
					
						
							|  |  |  |                 name = f"//{name}//" | 
					
						
							|  |  |  |         return re.sub("[\".:]", "", name) | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |     def visualize_exits(region: Region) -> None: | 
					
						
							|  |  |  |         for exit_ in region.exits: | 
					
						
							|  |  |  |             if exit_.connected_region: | 
					
						
							|  |  |  |                 if show_entrance_names: | 
					
						
							|  |  |  |                     uml.append(f"\"{fmt(region)}\" --> \"{fmt(exit_.connected_region)}\" : \"{fmt(exit_)}\"") | 
					
						
							|  |  |  |                 else: | 
					
						
							|  |  |  |                     try: | 
					
						
							|  |  |  |                         uml.remove(f"\"{fmt(exit_.connected_region)}\" --> \"{fmt(region)}\"") | 
					
						
							|  |  |  |                         uml.append(f"\"{fmt(exit_.connected_region)}\" <--> \"{fmt(region)}\"") | 
					
						
							|  |  |  |                     except ValueError: | 
					
						
							|  |  |  |                         uml.append(f"\"{fmt(region)}\" --> \"{fmt(exit_.connected_region)}\"") | 
					
						
							|  |  |  |             else: | 
					
						
							|  |  |  |                 uml.append(f"circle \"unconnected exit:\\n{fmt(exit_)}\"") | 
					
						
							|  |  |  |                 uml.append(f"\"{fmt(region)}\" --> \"unconnected exit:\\n{fmt(exit_)}\"") | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |     def visualize_locations(region: Region) -> None: | 
					
						
							|  |  |  |         any_lock = any(location.locked for location in region.locations) | 
					
						
							|  |  |  |         for location in region.locations: | 
					
						
							|  |  |  |             lock = "<&lock-locked> " if location.locked else "<&lock-unlocked,color=transparent> " if any_lock else "" | 
					
						
							|  |  |  |             if location.item: | 
					
						
							|  |  |  |                 uml.append(f"\"{fmt(region)}\" : {{method}} {lock}{fmt(location)}: {fmt(location.item)}") | 
					
						
							|  |  |  |             else: | 
					
						
							|  |  |  |                 uml.append(f"\"{fmt(region)}\" : {{field}} {lock}{fmt(location)}") | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |     def visualize_region(region: Region) -> None: | 
					
						
							| 
									
										
										
										
											2025-01-14 04:45:59 -05:00
										 |  |  |         uml.append(f"class \"{fmt(region)}\" {'#00FF00' if region in regions_to_highlight else ''}") | 
					
						
							| 
									
										
										
										
											2023-10-02 01:56:55 +02:00
										 |  |  |         if show_locations: | 
					
						
							|  |  |  |             visualize_locations(region) | 
					
						
							|  |  |  |         visualize_exits(region) | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |     def visualize_other_regions() -> None: | 
					
						
							|  |  |  |         if other_regions := [region for region in multiworld.get_regions(root_region.player) if region not in seen]: | 
					
						
							|  |  |  |             uml.append("package \"other regions\" <<Cloud>> {") | 
					
						
							|  |  |  |             for region in other_regions: | 
					
						
							|  |  |  |                 uml.append(f"class \"{fmt(region)}\"") | 
					
						
							|  |  |  |             uml.append("}") | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |     uml.append("@startuml") | 
					
						
							|  |  |  |     uml.append("hide circle") | 
					
						
							|  |  |  |     uml.append("hide empty members") | 
					
						
							|  |  |  |     if linetype_ortho: | 
					
						
							|  |  |  |         uml.append("skinparam linetype ortho") | 
					
						
							|  |  |  |     while regions: | 
					
						
							|  |  |  |         if (current_region := regions.popleft()) not in seen: | 
					
						
							|  |  |  |             seen.add(current_region) | 
					
						
							|  |  |  |             visualize_region(current_region) | 
					
						
							|  |  |  |             regions.extend(exit_.connected_region for exit_ in current_region.exits if exit_.connected_region) | 
					
						
							|  |  |  |     if show_other_regions: | 
					
						
							|  |  |  |         visualize_other_regions() | 
					
						
							|  |  |  |     uml.append("@enduml") | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |     with open(file_name, "wt", encoding="utf-8") as f: | 
					
						
							|  |  |  |         f.write("\n".join(uml)) | 
					
						
							| 
									
										
										
										
											2023-10-29 19:47:37 +01:00
										 |  |  | 
 | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | class RepeatableChain: | 
					
						
							|  |  |  |     def __init__(self, iterable: typing.Iterable): | 
					
						
							|  |  |  |         self.iterable = iterable | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |     def __iter__(self): | 
					
						
							|  |  |  |         return itertools.chain.from_iterable(self.iterable) | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |     def __bool__(self): | 
					
						
							|  |  |  |         return any(sub_iterable for sub_iterable in self.iterable) | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |     def __len__(self): | 
					
						
							|  |  |  |         return sum(len(iterable) for iterable in self.iterable) | 
					
						
							| 
									
										
										
										
											2024-03-03 16:30:51 -05:00
										 |  |  | 
 | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2024-03-11 19:30:14 -04:00
										 |  |  | def is_iterable_except_str(obj: object) -> TypeGuard[typing.Iterable[typing.Any]]: | 
					
						
							|  |  |  |     """ `str` is `Iterable`, but that's not what we want """ | 
					
						
							| 
									
										
										
										
											2024-03-03 16:30:51 -05:00
										 |  |  |     if isinstance(obj, str): | 
					
						
							|  |  |  |         return False | 
					
						
							| 
									
										
										
										
											2024-03-11 19:30:14 -04:00
										 |  |  |     return isinstance(obj, typing.Iterable) |