From 2a02cb4aa23f66c6c5dd9dc119ad8034abf17e90 Mon Sep 17 00:00:00 2001 From: dragonheart107 <153553566+dragonheart107@users.noreply.github.com> Date: Sat, 15 Mar 2025 14:27:28 +0100 Subject: [PATCH 01/15] Merge pull request #4662 from dragonheart107/Fix--Submarine-asset-en Upd: Submarine EN assets --- assets/en/handler/SUBMARINE_MOVE_CANCEL.png | Bin 4855 -> 10058 bytes assets/en/handler/SUBMARINE_MOVE_CONFIRM.png | Bin 5902 -> 13207 bytes module/handler/assets.py | 4 ++-- 3 files changed, 2 insertions(+), 2 deletions(-) diff --git a/assets/en/handler/SUBMARINE_MOVE_CANCEL.png b/assets/en/handler/SUBMARINE_MOVE_CANCEL.png index cd4678910955414a7dab9b1701ad83974f85bc86..152cce9c28427c953f2ebf5e16fc4fbc7fb656a0 100644 GIT binary patch literal 10058 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 Rt4eOMJ;BK~=ezGu{STYKzcv5> delta 2217 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 delta 3300 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 Date: Sun, 16 Mar 2025 11:40:46 +0800 Subject: [PATCH 02/15] Upd: [JP] SYNC_ENTER --- assets/jp/meta_reward/SYNC_ENTER.png | Bin 6500 -> 8664 bytes module/meta_reward/assets.py | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/assets/jp/meta_reward/SYNC_ENTER.png b/assets/jp/meta_reward/SYNC_ENTER.png index d555105e72884d015eb0352e013797d9f819e6e2..e6c54863d76aa1680b92375117719c8a4deabcbe 100644 GIT binary patch delta 5999 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 Date: Mon, 17 Mar 2025 00:24:46 +0800 Subject: [PATCH 03/15] Fix: Lower SHIP_LEVEL_CHECK similarity to 0.7 to handle random background --- module/awaken/awaken.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/module/awaken/awaken.py b/module/awaken/awaken.py index 463c7ef94..ae7829b2a 100644 --- a/module/awaken/awaken.py +++ b/module/awaken/awaken.py @@ -87,7 +87,7 @@ class Awaken(Dock): return self.appear_then_click(AWAKEN_FINISH, offset=(20, 20), interval=1) def is_in_awaken(self): - return SHIP_LEVEL_CHECK.match_luma(self.device.image) + return SHIP_LEVEL_CHECK.match_luma(self.device.image, similarity=0.7) def awaken_popup_close(self, skip_first_screenshot=True): logger.info('Awaken popup close') From 93644384cf02eb175895f80904fd0502b604294d Mon Sep 17 00:00:00 2001 From: LmeSzinc <37934724+LmeSzinc@users.noreply.github.com> Date: Mon, 17 Mar 2025 01:17:15 +0800 Subject: [PATCH 04/15] Refactor: Atomic file read/write for better performance - Drop dependency atomicwrites --- deploy/atomic.py | 201 +++++++++++++++++++++++++++++ deploy/config.py | 3 + deploy/installer.py | 2 + deploy/utils.py | 48 ++++--- module/config/atomicwrites.py | 236 ---------------------------------- module/config/utils.py | 80 +++++------- module/webui/app.py | 12 +- module/webui/config.py | 47 ------- 8 files changed, 266 insertions(+), 363 deletions(-) create mode 100644 deploy/atomic.py delete mode 100644 module/config/atomicwrites.py diff --git a/deploy/atomic.py b/deploy/atomic.py new file mode 100644 index 000000000..41b6ca76b --- /dev/null +++ b/deploy/atomic.py @@ -0,0 +1,201 @@ +import os +import random +import re +import string +import time +from typing import Union + + +def random_id(length=6): + """ + Args: + length (int): 6 random letter (62^6 combinations) would be enough + + Returns: + str: Random ID, like "sTD2kF" + """ + return ''.join(random.sample(string.ascii_letters + string.digits, length)) + + +def atomic_write( + file: str, + data: Union[str, bytes], + max_attempt=5, + retry_delay=0.05, +): + """ + Atomic file write with minimal IO operation + and handles cases where file might be read by another process. + + os.replace() is an atomic operation among all OS, + we write to temp file then do os.replace() + + Args: + file: + data: + max_attempt: Max attempt if another process is reading, + effective only on Windows + retry_delay: Base time to wait between retries (seconds) + """ + suffix = random_id(6) + temp = f'{file}.{suffix}.tmp' + if isinstance(data, str): + mode = 'w' + encoding = 'utf-8' + newline = '' + elif isinstance(data, bytes): + mode = 'wb' + encoding = None + newline = None + else: + mode = 'w' + encoding = 'utf-8' + newline = '' + + try: + # Write temp file + with open(temp, mode=mode, encoding=encoding, newline=newline) as f: + f.write(data) + # Ensure data flush to disk + f.flush() + os.fsync(f.fileno()) + except FileNotFoundError: + # Create parent directory + directory = os.path.dirname(file) + if directory: + os.makedirs(directory, exist_ok=True) + # Write again + with open(temp, mode=mode, encoding=encoding, newline=newline) as f: + f.write(data) + # Ensure data flush to disk + f.flush() + os.fsync(f.fileno()) + + if os.name == 'nt': + # PermissionError on Windows if another process is reading + last_error = None + if max_attempt < 1: + max_attempt = 1 + for trial in range(max_attempt): + try: + # Atomic operation + os.replace(temp, file) + # success + return + except PermissionError as e: + last_error = e + delay = 2 ** trial * retry_delay + time.sleep(delay) + continue + except Exception as e: + last_error = e + break + else: + # Linux and Mac allow existing reading + try: + # Atomic operation + os.replace(temp, file) + # success + return + except Exception as e: + last_error = e + + # Clean up temp file on failure + try: + os.unlink(temp) + except: + pass + if last_error is not None: + raise last_error from None + + +def atomic_read( + file: str, + mode: str = 'r', + errors: str = 'strict', + max_attempt=5, + retry_delay=0.05, +): + """ + Atomic file read with minimal IO operation + Since os.replace() is atomic, atomic reading is just plain read. + + Args: + file: + mode: 'r' or 'rb' + errors: 'strict', 'ignore', 'replace' and any other errors mode in open() + max_attempt: Max attempt if another process is reading, + effective only on Windows + retry_delay: Base time to wait between retries (seconds) + + Returns: + str if mode is 'r' + bytes if mode is 'rb' + """ + if 'b' in mode: + encoding = None + errors = None + else: + encoding = 'utf-8' + + if os.name == 'nt': + # PermissionError on Windows if another process is replacing + last_error = None + if max_attempt < 1: + max_attempt = 1 + for trial in range(max_attempt): + try: + with open(file, mode=mode, encoding=encoding, errors=errors) as f: + # success + return f.read() + except FileNotFoundError: + return '' + except PermissionError as e: + last_error = e + delay = 2 ** trial * retry_delay + time.sleep(delay) + continue + except Exception as e: + last_error = e + break + if last_error is not None: + raise last_error from None + else: + # Linux and Mac allow reading while replacing + try: + with open(file, mode=mode, encoding=encoding, errors=errors) as f: + # success + return f.read() + except FileNotFoundError: + return '' + + +def atomic_failure_cleanup(path: str): + """ + Cleanup remaining temp file under given path. + In most cases there should be no remaining temp files unless write process get interrupted. + + This method should only be called at startup + to avoid deleting temp files that another process is writing. + """ + with os.scandir(path) as entries: + for entry in entries: + if not entry.is_file(): + continue + # Check suffix first to reduce regex calls + name = entry.name + if not name.endswith('.tmp'): + continue + # Check temp file format + res = re.match(r'.*\.[a-zA-Z0-9]{6,}\.tmp$', name) + if not res: + continue + # Delete temp file + file = f'{path}{os.sep}{name}' + try: + os.unlink(file) + except PermissionError: + # Another process is reading/writing + pass + except: + pass diff --git a/deploy/config.py b/deploy/config.py index c99d72997..03bf4c1b5 100644 --- a/deploy/config.py +++ b/deploy/config.py @@ -90,6 +90,9 @@ class DeployConfig(ConfigModel): logger.info(f"Rest of the configs are the same as default") def read(self): + """ + Read and update deploy config, copy `self.configs` to properties. + """ self.config = poor_yaml_read(DEPLOY_TEMPLATE) self.config_template = copy.deepcopy(self.config) origin = poor_yaml_read(self.file) diff --git a/deploy/installer.py b/deploy/installer.py index 805c359e4..7c98c20a4 100644 --- a/deploy/installer.py +++ b/deploy/installer.py @@ -12,6 +12,8 @@ from deploy.pip import PipManager class Installer(GitManager, PipManager, AdbManager, AppManager, AlasManager): def install(self): + from deploy.atomic import atomic_failure_cleanup + atomic_failure_cleanup('./config') try: self.git_install() self.alas_kill() diff --git a/deploy/utils.py b/deploy/utils.py index f7741b9f2..2a3bd8823 100644 --- a/deploy/utils.py +++ b/deploy/utils.py @@ -2,6 +2,8 @@ import os import re from typing import Callable, Generic, TypeVar +from deploy.atomic import atomic_read, atomic_write + T = TypeVar("T") DEPLOY_CONFIG = './config/deploy.yaml' @@ -63,29 +65,26 @@ def poor_yaml_read(file): Returns: dict: """ - if not os.path.exists(file): - return {} - + content = atomic_read(file) data = {} regex = re.compile(r'^(.*?):(.*?)$') - with open(file, 'r', encoding='utf-8') as f: - for line in f.readlines(): - line = line.strip('\n\r\t ').replace('\\', '/') - if line.startswith('#'): - continue - result = re.match(regex, line) - if result: - k, v = result.group(1), result.group(2).strip('\n\r\t\' ') - if v: - if v.lower() == 'null': - v = None - elif v.lower() == 'false': - v = False - elif v.lower() == 'true': - v = True - elif v.isdigit(): - v = int(v) - data[k] = v + for line in content.splitlines(): + line = line.strip('\n\r\t ').replace('\\', '/') + if line.startswith('#'): + continue + result = re.match(regex, line) + if result: + k, v = result.group(1), result.group(2).strip('\n\r\t\' ') + if v: + if v.lower() == 'null': + v = None + elif v.lower() == 'false': + v = False + elif v.lower() == 'true': + v = True + elif v.isdigit(): + v = int(v) + data[k] = v return data @@ -97,8 +96,8 @@ def poor_yaml_write(data, file, template_file=DEPLOY_TEMPLATE): file (str): template_file (str): """ - with open(template_file, 'r', encoding='utf-8') as f: - text = f.read().replace('\\', '/') + text = atomic_read(template_file) + text = text.replace('\\', '/') for key, value in data.items(): if value is None: @@ -109,5 +108,4 @@ def poor_yaml_write(data, file, template_file=DEPLOY_TEMPLATE): value = "false" text = re.sub(f'{key}:.*?\n', f'{key}: {value}\n', text) - with open(file, 'w', encoding='utf-8', newline='') as f: - f.write(text) + atomic_write(file, text) diff --git a/module/config/atomicwrites.py b/module/config/atomicwrites.py deleted file mode 100644 index 9922f1a0b..000000000 --- a/module/config/atomicwrites.py +++ /dev/null @@ -1,236 +0,0 @@ -""" -Copy-pasted from -https://github.com/untitaker/python-atomicwrites -""" -import contextlib -import io -import os -import sys -import tempfile - -try: - import fcntl -except ImportError: - fcntl = None - -# `fspath` was added in Python 3.6 -try: - from os import fspath -except ImportError: - fspath = None - -__version__ = '1.4.1' - -PY2 = sys.version_info[0] == 2 - -text_type = unicode if PY2 else str # noqa - - -def _path_to_unicode(x): - if not isinstance(x, text_type): - return x.decode(sys.getfilesystemencoding()) - return x - - -DEFAULT_MODE = "wb" if PY2 else "w" - -_proper_fsync = os.fsync - -if sys.platform != 'win32': - if hasattr(fcntl, 'F_FULLFSYNC'): - def _proper_fsync(fd): - # https://lists.apple.com/archives/darwin-dev/2005/Feb/msg00072.html - # https://developer.apple.com/library/mac/documentation/Darwin/Reference/ManPages/man2/fsync.2.html - # https://github.com/untitaker/python-atomicwrites/issues/6 - fcntl.fcntl(fd, fcntl.F_FULLFSYNC) - - - def _sync_directory(directory): - # Ensure that filenames are written to disk - fd = os.open(directory, 0) - try: - _proper_fsync(fd) - finally: - os.close(fd) - - - def _replace_atomic(src, dst): - os.rename(src, dst) - _sync_directory(os.path.normpath(os.path.dirname(dst))) - - - def _move_atomic(src, dst): - os.link(src, dst) - os.unlink(src) - - src_dir = os.path.normpath(os.path.dirname(src)) - dst_dir = os.path.normpath(os.path.dirname(dst)) - _sync_directory(dst_dir) - if src_dir != dst_dir: - _sync_directory(src_dir) -else: - from ctypes import windll, WinError - - _MOVEFILE_REPLACE_EXISTING = 0x1 - _MOVEFILE_WRITE_THROUGH = 0x8 - _windows_default_flags = _MOVEFILE_WRITE_THROUGH - - - def _handle_errors(rv): - if not rv: - raise WinError() - - - def _replace_atomic(src, dst): - _handle_errors(windll.kernel32.MoveFileExW( - _path_to_unicode(src), _path_to_unicode(dst), - _windows_default_flags | _MOVEFILE_REPLACE_EXISTING - )) - - - def _move_atomic(src, dst): - _handle_errors(windll.kernel32.MoveFileExW( - _path_to_unicode(src), _path_to_unicode(dst), - _windows_default_flags - )) - - -def replace_atomic(src, dst): - ''' - Move ``src`` to ``dst``. If ``dst`` exists, it will be silently - overwritten. - - Both paths must reside on the same filesystem for the operation to be - atomic. - ''' - return _replace_atomic(src, dst) - - -def move_atomic(src, dst): - ''' - Move ``src`` to ``dst``. There might a timewindow where both filesystem - entries exist. If ``dst`` already exists, :py:exc:`FileExistsError` will be - raised. - - Both paths must reside on the same filesystem for the operation to be - atomic. - ''' - return _move_atomic(src, dst) - - -class AtomicWriter(object): - ''' - A helper class for performing atomic writes. Usage:: - - with AtomicWriter(path).open() as f: - f.write(...) - - :param path: The destination filepath. May or may not exist. - :param mode: The filemode for the temporary file. This defaults to `wb` in - Python 2 and `w` in Python 3. - :param overwrite: If set to false, an error is raised if ``path`` exists. - Errors are only raised after the file has been written to. Either way, - the operation is atomic. - :param open_kwargs: Keyword-arguments to pass to the underlying - :py:func:`open` call. This can be used to set the encoding when opening - files in text-mode. - - If you need further control over the exact behavior, you are encouraged to - subclass. - ''' - - def __init__(self, path, mode=DEFAULT_MODE, overwrite=False, - **open_kwargs): - if 'a' in mode: - raise ValueError( - 'Appending to an existing file is not supported, because that ' - 'would involve an expensive `copy`-operation to a temporary ' - 'file. Open the file in normal `w`-mode and copy explicitly ' - 'if that\'s what you\'re after.' - ) - if 'x' in mode: - raise ValueError('Use the `overwrite`-parameter instead.') - if 'w' not in mode: - raise ValueError('AtomicWriters can only be written to.') - - # Attempt to convert `path` to `str` or `bytes` - if fspath is not None: - path = fspath(path) - - self._path = path - self._mode = mode - self._overwrite = overwrite - self._open_kwargs = open_kwargs - - def open(self): - ''' - Open the temporary file. - ''' - return self._open(self.get_fileobject) - - @contextlib.contextmanager - def _open(self, get_fileobject): - f = None # make sure f exists even if get_fileobject() fails - try: - success = False - with get_fileobject(**self._open_kwargs) as f: - yield f - self.sync(f) - self.commit(f) - success = True - finally: - if not success: - try: - self.rollback(f) - except Exception: - pass - - def get_fileobject(self, suffix="", prefix=tempfile.gettempprefix(), - dir=None, **kwargs): - '''Return the temporary file to use.''' - if dir is None: - dir = os.path.normpath(os.path.dirname(self._path)) - descriptor, name = tempfile.mkstemp(suffix=suffix, prefix=prefix, - dir=dir) - # io.open() will take either the descriptor or the name, but we need - # the name later for commit()/replace_atomic() and couldn't find a way - # to get the filename from the descriptor. - os.close(descriptor) - kwargs['mode'] = self._mode - kwargs['file'] = name - return io.open(**kwargs) - - def sync(self, f): - '''responsible for clearing as many file caches as possible before - commit''' - f.flush() - _proper_fsync(f.fileno()) - - def commit(self, f): - '''Move the temporary file to the target location.''' - if self._overwrite: - replace_atomic(f.name, self._path) - else: - move_atomic(f.name, self._path) - - def rollback(self, f): - '''Clean up all temporary resources.''' - os.unlink(f.name) - - -def atomic_write(path, writer_cls=AtomicWriter, **cls_kwargs): - ''' - Simple atomic writes. This wraps :py:class:`AtomicWriter`:: - - with atomic_write(path) as f: - f.write(...) - - :param path: The target path to write to. - :param writer_cls: The writer class to use. This parameter is useful if you - subclassed :py:class:`AtomicWriter` to change some behavior and want to - use that new subclass. - - Additional keyword arguments are passed to the writer class. See - :py:class:`AtomicWriter`. - ''' - return writer_cls(path, **cls_kwargs).open() diff --git a/module/config/utils.py b/module/config/utils.py index 07bcd3234..d42269448 100644 --- a/module/config/utils.py +++ b/module/config/utils.py @@ -1,14 +1,12 @@ import json -import os import random import string from datetime import datetime, timedelta, timezone import yaml -from filelock import FileLock import module.config.server as server_ -from module.config.atomicwrites import atomic_write +from deploy.atomic import atomic_read, atomic_write from module.submodule.utils import * LANGUAGES = ['zh-CN', 'en-US', 'ja-JP', 'zh-TW'] @@ -79,33 +77,23 @@ def read_file(file): Returns: dict, list: """ - folder = os.path.dirname(file) - if not os.path.exists(folder): - os.mkdir(folder) - - if not os.path.exists(file): - return {} - - _, ext = os.path.splitext(file) - lock = FileLock(f"{file}.lock") - with lock: - print(f'read: {file}') - if ext == '.yaml': - with open(file, mode='r', encoding='utf-8') as f: - s = f.read() - data = list(yaml.safe_load_all(s)) - if len(data) == 1: - data = data[0] - if not data: - data = {} - return data - elif ext == '.json': - with open(file, mode='r', encoding='utf-8') as f: - s = f.read() - return json.loads(s) - else: - print(f'Unsupported config file extension: {ext}') + print(f'read: {file}') + if file.endswith('.json'): + content = atomic_read(file, mode='rb') + if not content: return {} + return json.loads(content) + elif file.endswith('.yaml'): + content = atomic_read(file, mode='r') + data = list(yaml.safe_load_all(content)) + if len(data) == 1: + data = data[0] + if not data: + data = {} + return data + else: + print(f'Unsupported config file extension: {file}') + return {} def write_file(file, data): @@ -116,28 +104,20 @@ def write_file(file, data): file (str): data (dict, list): """ - folder = os.path.dirname(file) - if not os.path.exists(folder): - os.mkdir(folder) - - _, ext = os.path.splitext(file) - lock = FileLock(f"{file}.lock") - with lock: - print(f'write: {file}') - if ext == '.yaml': - with atomic_write(file, overwrite=True, encoding='utf-8', newline='') as f: - if isinstance(data, list): - yaml.safe_dump_all(data, f, default_flow_style=False, encoding='utf-8', allow_unicode=True, - sort_keys=False) - else: - yaml.safe_dump(data, f, default_flow_style=False, encoding='utf-8', allow_unicode=True, - sort_keys=False) - elif ext == '.json': - with atomic_write(file, overwrite=True, encoding='utf-8', newline='') as f: - s = json.dumps(data, indent=2, ensure_ascii=False, sort_keys=False, default=str) - f.write(s) + print(f'write: {file}') + if file.endswith('.json'): + content = json.dumps(data, indent=2, ensure_ascii=False, sort_keys=False, default=str) + atomic_write(file, content) + elif file.endswith('.yaml'): + if isinstance(data, list): + content = yaml.safe_dump_all( + data, default_flow_style=False, encoding='utf-8', allow_unicode=True, sort_keys=False) else: - print(f'Unsupported config file extension: {ext}') + content = yaml.safe_dump( + data, default_flow_style=False, encoding='utf-8', allow_unicode=True, sort_keys=False) + atomic_write(file, content) + else: + print(f'Unsupported config file extension: {file}') def iter_folder(folder, is_dir=False, ext=None): diff --git a/module/webui/app.py b/module/webui/app.py index 2a7895a00..d7d6ba1f4 100644 --- a/module/webui/app.py +++ b/module/webui/app.py @@ -1,16 +1,15 @@ -import sys -import json -import time -import queue import argparse +import json +import queue import threading - +import time from datetime import datetime from functools import partial from typing import Dict, List, Optional # Import fake module before import pywebio to avoid importing unnecessary module PIL from module.webui.fake_pil_module import import_fake_pil_module + import_fake_pil_module() from pywebio import config as webconfig @@ -1440,6 +1439,9 @@ def app(): logger.attr("CDN", cdn) logger.attr("IS_ON_PHONE_CLOUD", IS_ON_PHONE_CLOUD) + from deploy.atomic import atomic_failure_cleanup + atomic_failure_cleanup('./config') + def index(): if key is not None and not login(key): logger.warning(f"{info.user_ip} login failed.") diff --git a/module/webui/config.py b/module/webui/config.py index d7cb735a8..d9e9a09d6 100644 --- a/module/webui/config.py +++ b/module/webui/config.py @@ -1,57 +1,10 @@ -import copy - -from filelock import FileLock - from deploy.config import DeployConfig as _DeployConfig -from deploy.utils import * - - -def poor_yaml_read_with_lock(file): - if not os.path.exists(file): - return {} - - with FileLock(f"{file}.lock"): - return poor_yaml_read(file) - - -def poor_yaml_write_with_lock(data, file, template_file=DEPLOY_TEMPLATE): - folder = os.path.dirname(file) - if not os.path.exists(folder): - os.mkdir(folder) - - with FileLock(f"{file}.lock"): - with FileLock(f"{DEPLOY_TEMPLATE}.lock"): - return poor_yaml_write(data, file, template_file) class DeployConfig(_DeployConfig): def show_config(self): pass - def read(self): - """ - Read and update deploy config, copy `self.configs` to properties. - """ - self.config = poor_yaml_read_with_lock(DEPLOY_TEMPLATE) - self.config_template = copy.deepcopy(self.config) - origin = poor_yaml_read_with_lock(self.file) - self.config.update(origin) - - for key, value in self.config.items(): - if hasattr(self, key): - super().__setattr__(key, value) - - self.config_redirect() - - if self.config != origin: - self.write() - - def write(self): - """ - Write `self.config` into deploy config. - """ - poor_yaml_write_with_lock(self.config, self.file) - def __setattr__(self, key: str, value): """ Catch __setattr__, copy to `self.config`, write deploy config. From 477f917262be7a9243854033690c355d51b2aea5 Mon Sep 17 00:00:00 2001 From: LmeSzinc <37934724+LmeSzinc@users.noreply.github.com> Date: Mon, 17 Mar 2025 01:38:51 +0800 Subject: [PATCH 05/15] Refactor: deep methods reworked for better performance --- alas.py | 2 +- module/config/config.py | 7 +- module/config/config_updater.py | 3 +- module/config/deep.py | 533 ++++++++++++++++++ module/config/utils.py | 95 ---- module/device/connection_attr.py | 2 +- module/device/method/nemu_ipc.py | 2 +- module/statistics/azurstats.py | 2 +- module/webui/app.py | 6 +- module/webui/lang.py | 5 +- module/webui/translate.py | 4 +- module/webui/utils.py | 12 +- .../module/config/config_updater.py | 1 + .../module/config/config_updater.py | 1 + .../AlasMaaBridge/module/handler/handler.py | 3 +- submodule/AlasMaaBridge/module/logger.py | 2 +- 16 files changed, 560 insertions(+), 120 deletions(-) create mode 100644 module/config/deep.py diff --git a/alas.py b/alas.py index b1b0cde09..90173a17e 100644 --- a/alas.py +++ b/alas.py @@ -9,7 +9,7 @@ from cached_property import cached_property from module.base.decorator import del_cached_property from module.config.config import AzurLaneConfig, TaskEnd -from module.config.utils import deep_get, deep_set +from module.config.deep import deep_get, deep_set from module.exception import * from module.logger import logger from module.notify import handle_notify diff --git a/module/config/config.py b/module/config/config.py index f57aa0be0..97631fca1 100644 --- a/module/config/config.py +++ b/module/config/config.py @@ -1,16 +1,17 @@ import copy -import datetime import operator import threading +from datetime import datetime, timedelta import pywebio from module.base.filter import Filter from module.config.config_generated import GeneratedConfig from module.config.config_manual import ManualConfig, OutputConfig -from module.config.config_updater import ConfigUpdater +from module.config.config_updater import ConfigUpdater, ensure_time, get_server_next_update, nearest_future +from module.config.deep import deep_get, deep_set +from module.config.utils import DEFAULT_TIME, dict_to_kv, filepath_config, get_os_reset_remain, path_to_arg from module.config.watcher import ConfigWatcher -from module.config.utils import * from module.exception import RequestHumanTakeover, ScriptError from module.logger import logger from module.map.map_grids import SelectedGrids diff --git a/module/config/config_updater.py b/module/config/config_updater.py index 2d2c395a2..9fa8f8381 100644 --- a/module/config/config_updater.py +++ b/module/config/config_updater.py @@ -6,10 +6,11 @@ from cached_property import cached_property from deploy.utils import DEPLOY_TEMPLATE, poor_yaml_read, poor_yaml_write from module.base.timer import timer +from module.config.deep import deep_default, deep_get, deep_iter, deep_pop, deep_set from module.config.env import IS_ON_PHONE_CLOUD -from module.config.redirect_utils.utils import * from module.config.server import VALID_CHANNEL_PACKAGE, VALID_PACKAGE, VALID_SERVER_LIST, to_package, to_server from module.config.utils import * +from module.config.redirect_utils.utils import * CONFIG_IMPORT = ''' import datetime diff --git a/module/config/deep.py b/module/config/deep.py new file mode 100644 index 000000000..3f4bb9e04 --- /dev/null +++ b/module/config/deep.py @@ -0,0 +1,533 @@ +from collections import deque + +# deep_* functions are used for access nested dictionary. +# They target for high performance so code are complicated to read +# In general performance practise, time costs are as below: +# - When key exists +# try: dict[key] except KeyError << dict.get(key) < if key in dict: dict[key] +# - When not key exists +# if key in dict: dict[key] < dict.get(key) <<< try: dict[key] except KeyError + +OP_ADD = 'add' +OP_SET = 'set' +OP_DEL = 'del' + + +def deep_get(d, keys, default=None): + """ + Get value from nested dict and list + https://stackoverflow.com/questions/25833613/safe-method-to-get-value-of-nested-dictionary + + Args: + d (dict): + keys (list[str], str): Such as ['Scheduler', 'NextRun', 'value'] + default: Default return if key not found. + + Returns: + Value on given keys + """ + # 240 + 30 * depth (ns) + if type(keys) is str: + keys = keys.split('.') + + try: + for k in keys: + d = d[k] + return d + # No such key + except KeyError: + return default + # No such key + except IndexError: + return default + # Input `keys` is not iterable or input `d` is not dict + # list indices must be integers or slices, not str + except TypeError: + return default + + +def deep_get_with_error(d, keys): + """ + Get value from nested dict and list, raise KeyError if key not exists + + Args: + d (dict): + keys (list[str], str): Such as ['Scheduler', 'NextRun', 'value'] + + Returns: + Value on given keys + + Raises: + KeyError: If key not exists + """ + # 240 + 30 * depth (ns) + if type(keys) is str: + keys = keys.split('.') + + try: + for k in keys: + d = d[k] + return d + # No such key + # except KeyError: + # raise + # No such key + except IndexError: + raise KeyError + # Input `keys` is not iterable or input `d` is not dict + # list indices must be integers or slices, not str + except TypeError: + raise KeyError + + +def deep_exist(d, keys): + """ + Check if keys exists in nested dict or list + + Args: + d (dict): + keys (str, list): Such as `Scheduler.NextRun.value` + + Returns: + bool: If key exists + """ + # 240 + 30 * depth (ns) + if type(keys) is str: + keys = keys.split('.') + + try: + for k in keys: + d = d[k] + return True + # No such key + except KeyError: + return False + # No such key + except IndexError: + return False + # Input `keys` is not iterable or input `d` is not dict + # list indices must be integers or slices, not str + except TypeError: + return False + + +def deep_set(d, keys, value): + """ + Set value into nested dict safely, imitating deep_get(). + Can only set dict + """ + # 150 * depth (ns) + if type(keys) is str: + keys = keys.split('.') + + first = True + exist = True + prev_d = None + prev_k = None + prev_k2 = None + try: + for k in keys: + if first: + prev_d = d + prev_k = k + first = False + continue + try: + # if key in dict: dict[key] > dict.get > dict.setdefault > try dict[key] except + if exist and prev_k in d: + prev_d = d + d = d[prev_k] + else: + exist = False + new = {} + d[prev_k] = new + d = new + except TypeError: + # `d` is not dict + exist = False + d = {} + prev_d[prev_k2] = {prev_k: d} + + prev_k2 = prev_k + prev_k = k + # prev_k2, prev_k = prev_k, k + # Input `keys` is not iterable + except TypeError: + return + + # Last key, set value + try: + d[prev_k] = value + return + # Last value `d` is not dict + except TypeError: + prev_d[prev_k2] = {prev_k: value} + return + + +def deep_default(d, keys, value): + """ + Set value into nested dict safely, imitating deep_get(). + Can only set dict + """ + # 150 * depth (ns) + if type(keys) is str: + keys = keys.split('.') + + first = True + exist = True + prev_d = None + prev_k = None + prev_k2 = None + try: + for k in keys: + if first: + prev_d = d + prev_k = k + first = False + continue + try: + # if key in dict: dict[key] > dict.get > dict.setdefault > try dict[key] except + if exist and prev_k in d: + prev_d = d + d = d[prev_k] + else: + exist = False + new = {} + d[prev_k] = new + d = new + except TypeError: + # `d` is not dict + exist = False + d = {} + prev_d[prev_k2] = {prev_k: d} + + prev_k2 = prev_k + prev_k = k + # prev_k2, prev_k = prev_k, k + # Input `keys` is not iterable + except TypeError: + return + + # Last key, set value + try: + d.setdefault(prev_k, value) + return + # Last value `d` is not dict + except AttributeError: + prev_d[prev_k2] = {prev_k: value} + return + + +def deep_pop(d, keys, default=None): + """ + Pop value from nested dict and list + """ + if type(keys) is str: + keys = keys.split('.') + + try: + for k in keys[:-1]: + d = d[k] + # No `pop(k, default)` so it can pop list + return d.pop(keys[-1]) + # No such key + except KeyError: + return default + # Input `keys` is not iterable or input `d` is not dict + # list indices must be integers or slices, not str + except TypeError: + return default + # Input `keys` out of index + except IndexError: + return default + # Last `d` is not dict + except AttributeError: + return default + + +def deep_iter_depth1(data): + """ + Equivalent to data.items() but suppress error if data is not a dict + + Args: + data: + + Yields: + Any: Key + Any: Value + """ + try: + for k, v in data.items(): + yield k, v + return + except AttributeError: + # `data` is not dict + return + + +def deep_iter_depth2(data): + """ + Iter key and value in nested dict of depth 2 + A simplified deep_iter + + Args: + data: + + Yields: + Any: Key1 + Any: Key2 + Any: Value + """ + try: + for k1, v1 in data.items(): + if type(v1) is dict: + for k2, v2 in v1.items(): + yield k1, k2, v2 + except AttributeError: + # `data` is not dict + return + + +def deep_iter(data, min_depth=None, depth=3): + """ + Iter key and value in nested dict + 300us on alas.json depth=3 (530+ rows) + Can only iter dict + + Args: + data: + min_depth: + depth: + + Yields: + list[str]: Key path + Any: Value + """ + if min_depth is None: + min_depth = depth + assert 1 <= min_depth <= depth + + # Equivalent to dict.items() + try: + if depth == 1: + for k, v in data.items(): + yield [k], v + return + # Iter first depth + elif min_depth == 1: + q = deque() + for k, v in data.items(): + key = [k] + if type(v) is dict: + q.append((key, v)) + else: + yield key, v + # Iter target depth only + else: + q = deque() + for k, v in data.items(): + key = [k] + if type(v) is dict: + q.append((key, v)) + except AttributeError: + # `data` is not dict + return + + # Iter depths + current = 2 + while current <= depth: + new_q = deque() + # max depth + if current == depth: + for key, data in q: + for k, v in data.items(): + yield key + [k], v + # in target depth + elif min_depth <= current < depth: + for key, data in q: + for k, v in data.items(): + subkey = key + [k] + if type(v) is dict: + new_q.append((subkey, v)) + else: + yield subkey, v + # Haven't reached min depth + else: + for key, data in q: + for k, v in data.items(): + subkey = key + [k] + if type(v) is dict: + new_q.append((subkey, v)) + q = new_q + current += 1 + + +def deep_values(data, min_depth=None, depth=3): + """ + Iter value in nested dict + 300us on alas.json depth=3 (530+ rows) + Can only iter dict + + Args: + data: + min_depth: + depth: + + Yields: + Any: Value + """ + if min_depth is None: + min_depth = depth + assert 1 <= min_depth <= depth + + # Equivalent to dict.items() + try: + if depth == 1: + for v in data.values(): + yield v + return + # Iter first depth + elif min_depth == 1: + q = deque() + for v in data.values(): + if type(v) is dict: + q.append(v) + else: + yield v + # Iter target depth only + else: + q = deque() + for v in data.values(): + if type(v) is dict: + q.append(v) + except AttributeError: + # `data` is not dict + return + + # Iter depths + current = 2 + while current <= depth: + new_q = deque() + # max depth + if current == depth: + for data in q: + for v in data.values(): + yield v + # in target depth + elif min_depth <= current < depth: + for data in q: + for v in data.values(): + if type(v) is dict: + new_q.append(v) + else: + yield v + # Haven't reached min depth + else: + for data in q: + for v in data.values(): + if type(v) is dict: + new_q.append(v) + q = new_q + current += 1 + + +def deep_iter_diff(before, after): + """ + Iter diff between 2 dict. + Pretty fast to compare 2 deeply nested dict, + time cost increases with the number of differences. + + Args: + before: + after: + + Yields: + list[str]: Key path + Any: Value in before, or None if not exists + Any: Value in after, or None if not exists + """ + if before == after: + return + if type(before) is not dict or type(after) is not dict: + yield [], before, after + return + + queue = deque([([], before, after)]) + while True: + new_queue = deque() + for path, d1, d2 in queue: + keys1 = set(d1.keys()) + keys2 = set(d2.keys()) + for key in keys1.union(keys2): + try: + val2 = d2[key] + except KeyError: + # Safe to access d1[key], because key came from the union of both + # If it's not in d2 then it's in d1 + yield path + [key], d1[key], None + continue + try: + val1 = d1[key] + except KeyError: + yield path + [key], None, val2 + continue + # Compare dict first, which is pretty fast + if val1 != val2: + if type(val1) is dict and type(val2) is dict: + new_queue.append((path + [key], val1, val2)) + else: + yield path + [key], val1, val2 + queue = new_queue + if not queue: + break + + +def deep_iter_patch(before, after): + """ + Iter patch event from before to after, like creating a json-patch + Pretty fast to compare 2 deeply nested dict, + time cost increases with the number of differences. + + Args: + before: + after: + + Yields: + str: OP_ADD, OP_SET, OP_DEL + list[str]: Key path + Any: Value in after, + or None of event is OP_DEL + """ + if before == after: + return + if type(before) is not dict or type(after) is not dict: + yield OP_SET, [], after + return + + queue = deque([([], before, after)]) + while True: + new_queue = deque() + for path, d1, d2 in queue: + keys1 = set(d1.keys()) + keys2 = set(d2.keys()) + for key in keys1.union(keys2): + try: + val2 = d2[key] + except KeyError: + yield OP_DEL, path + [key], None + continue + try: + val1 = d1[key] + except KeyError: + yield OP_ADD, path + [key], val2 + continue + # Compare dict first, which is pretty fast + if val1 != val2: + if type(val1) is dict and type(val2) is dict: + new_queue.append((path + [key], val1, val2)) + else: + yield OP_SET, path + [key], val2 + queue = new_queue + if not queue: + break diff --git a/module/config/utils.py b/module/config/utils.py index d42269448..423e6fa22 100644 --- a/module/config/utils.py +++ b/module/config/utils.py @@ -181,101 +181,6 @@ def alas_instance(): return out -def deep_get(d, keys, default=None): - """ - Get values in dictionary safely. - https://stackoverflow.com/questions/25833613/safe-method-to-get-value-of-nested-dictionary - - Args: - d (dict): - keys (str, list): Such as `Scheduler.NextRun.value` - default: Default return if key not found. - - Returns: - - """ - if isinstance(keys, str): - keys = keys.split('.') - assert type(keys) is list - if d is None: - return default - if not keys: - return d - return deep_get(d.get(keys[0]), keys[1:], default) - - -def deep_set(d, keys, value): - """ - Set value into dictionary safely, imitating deep_get(). - """ - if isinstance(keys, str): - keys = keys.split('.') - assert type(keys) is list - if not keys: - return value - if not isinstance(d, dict): - d = {} - d[keys[0]] = deep_set(d.get(keys[0], {}), keys[1:], value) - return d - - -def deep_pop(d, keys, default=None): - """ - Pop value from dictionary safely, imitating deep_get(). - """ - if isinstance(keys, str): - keys = keys.split('.') - assert type(keys) is list - if not isinstance(d, dict): - return default - if not keys: - return default - elif len(keys) == 1: - return d.pop(keys[0], default) - return deep_pop(d.get(keys[0]), keys[1:], default) - - -def deep_default(d, keys, value): - """ - Set default value into dictionary safely, imitating deep_get(). - Value is set only when the dict doesn't contain such keys. - """ - if isinstance(keys, str): - keys = keys.split('.') - assert type(keys) is list - if not keys: - if d: - return d - else: - return value - if not isinstance(d, dict): - d = {} - d[keys[0]] = deep_default(d.get(keys[0], {}), keys[1:], value) - return d - - -def deep_iter(data, depth=0, current_depth=1): - """ - Iter a dictionary safely. - - Args: - data (dict): - depth (int): Maximum depth to iter - current_depth (int): - - Returns: - list: Key path - Any: - """ - if isinstance(data, dict) \ - and (depth and current_depth <= depth): - for key, value in data.items(): - for child_path, child_value in deep_iter(value, depth=depth, current_depth=current_depth + 1): - yield [key] + child_path, child_value - else: - yield [], data - - def parse_value(value, data): """ Convert a string to float, int, datetime, if possible. diff --git a/module/device/connection_attr.py b/module/device/connection_attr.py index 0814b9524..ef8b343dc 100644 --- a/module/device/connection_attr.py +++ b/module/device/connection_attr.py @@ -8,7 +8,7 @@ from adbutils import AdbClient, AdbDevice from module.base.decorator import cached_property from module.config.config import AzurLaneConfig from module.config.env import IS_ON_PHONE_CLOUD -from module.config.utils import deep_iter +from module.config.deep import deep_iter from module.device.method.utils import get_serial_pair from module.exception import RequestHumanTakeover from module.logger import logger diff --git a/module/device/method/nemu_ipc.py b/module/device/method/nemu_ipc.py index b91a6b531..e0a8c927a 100644 --- a/module/device/method/nemu_ipc.py +++ b/module/device/method/nemu_ipc.py @@ -11,7 +11,7 @@ import numpy as np from module.base.decorator import cached_property, del_cached_property, has_cached_property from module.base.timer import Timer from module.base.utils import ensure_time -from module.config.utils import deep_get +from module.config.deep import deep_get from module.device.env import IS_WINDOWS from module.device.method.minitouch import insert_swipe, random_rectangle_point from module.device.method.pool import JobTimeout, WORKER_POOL diff --git a/module/statistics/azurstats.py b/module/statistics/azurstats.py index c815479dd..695da5f91 100644 --- a/module/statistics/azurstats.py +++ b/module/statistics/azurstats.py @@ -10,7 +10,7 @@ from requests.adapters import HTTPAdapter from module.base.utils import save_image from module.config.config import AzurLaneConfig -from module.config.utils import deep_get +from module.config.deep import deep_get from module.exception import ScriptError from module.logger import logger from module.statistics.utils import pack diff --git a/module/webui/app.py b/module/webui/app.py index d7d6ba1f4..0d8d0f31b 100644 --- a/module/webui/app.py +++ b/module/webui/app.py @@ -37,17 +37,15 @@ from pywebio.output import ( use_scope, ) from pywebio.pin import pin, pin_on_change -from pywebio.session import (download, go_app, info, local, register_thread, run_js, set_env) +from pywebio.session import download, go_app, info, local, register_thread, run_js, set_env import module.webui.lang as lang from module.config.config import AzurLaneConfig, Function +from module.config.deep import deep_get, deep_iter, deep_set from module.config.env import IS_ON_PHONE_CLOUD from module.config.utils import ( alas_instance, alas_template, - deep_get, - deep_iter, - deep_set, dict_to_kv, filepath_args, filepath_config, diff --git a/module/webui/lang.py b/module/webui/lang.py index 0a5cd3999..a47331333 100644 --- a/module/webui/lang.py +++ b/module/webui/lang.py @@ -1,8 +1,9 @@ from typing import Dict -from module.config.utils import * -from module.webui.setting import State +from module.config.deep import deep_iter +from module.config.utils import LANGUAGES, filepath_i18n, read_file from module.submodule.utils import list_mod_dir +from module.webui.setting import State LANG = "zh-CN" TRANSLATE_MODE = False diff --git a/module/webui/translate.py b/module/webui/translate.py index 7953673b3..970c76433 100644 --- a/module/webui/translate.py +++ b/module/webui/translate.py @@ -6,8 +6,8 @@ from pywebio.output import put_buttons, put_markdown from pywebio.session import defer_call, hold, run_js, set_env import module.webui.lang as lang -from module.config.utils import (LANGUAGES, deep_get, deep_iter, deep_set, - filepath_i18n, read_file, write_file) +from module.config.deep import deep_get, deep_iter, deep_set +from module.config.utils import LANGUAGES, filepath_i18n, read_file, write_file def translate(): diff --git a/module/webui/utils.py b/module/webui/utils.py index e813c99ac..6426e76fa 100644 --- a/module/webui/utils.py +++ b/module/webui/utils.py @@ -9,17 +9,15 @@ from queue import Queue from typing import Callable, Generator, List import pywebio -from module.config.utils import deep_iter -from module.logger import logger -from module.webui.setting import State from pywebio.input import PASSWORD, input from pywebio.output import PopupSize, popup, put_html, toast -from pywebio.session import eval_js -from pywebio.session import info as session_info -from pywebio.session import register_thread, run_js -from rich.console import Console, ConsoleOptions +from pywebio.session import eval_js, info as session_info, register_thread, run_js +from rich.console import Console from rich.terminal_theme import TerminalTheme +from module.config.deep import deep_iter +from module.logger import logger +from module.webui.setting import State RE_DATETIME = ( r"\d{4}\-(0\d|1[0-2])\-([0-2]\d|[3][0-1]) " diff --git a/submodule/AlasFpyBridge/module/config/config_updater.py b/submodule/AlasFpyBridge/module/config/config_updater.py index 490408ebe..bbfd3d4ec 100644 --- a/submodule/AlasFpyBridge/module/config/config_updater.py +++ b/submodule/AlasFpyBridge/module/config/config_updater.py @@ -2,6 +2,7 @@ from cached_property import cached_property from module.base.timer import timer from module.config import config_updater +from module.config.deep import deep_get, deep_set, deep_iter from module.config.utils import * diff --git a/submodule/AlasMaaBridge/module/config/config_updater.py b/submodule/AlasMaaBridge/module/config/config_updater.py index 6e777ae3b..21f066e13 100644 --- a/submodule/AlasMaaBridge/module/config/config_updater.py +++ b/submodule/AlasMaaBridge/module/config/config_updater.py @@ -2,6 +2,7 @@ from cached_property import cached_property from module.base.timer import timer from module.config import config_updater +from module.config.deep import deep_get, deep_iter, deep_set from module.config.utils import * diff --git a/submodule/AlasMaaBridge/module/handler/handler.py b/submodule/AlasMaaBridge/module/handler/handler.py index bb48e7c3d..b61dd03d2 100644 --- a/submodule/AlasMaaBridge/module/handler/handler.py +++ b/submodule/AlasMaaBridge/module/handler/handler.py @@ -10,7 +10,8 @@ from cached_property import cached_property from deploy.config import DeployConfig from module.base.timer import Timer -from module.config.utils import read_file, deep_get, get_server_last_update +from module.config.deep import deep_get +from module.config.utils import read_file, get_server_last_update from module.device.connection_attr import ConnectionAttr from module.exception import RequestHumanTakeover from module.logger import logger diff --git a/submodule/AlasMaaBridge/module/logger.py b/submodule/AlasMaaBridge/module/logger.py index a3139a9bb..2d2c06480 100644 --- a/submodule/AlasMaaBridge/module/logger.py +++ b/submodule/AlasMaaBridge/module/logger.py @@ -1,7 +1,7 @@ import typing as t from module.base.decorator import cached_property -from module.config.utils import deep_get +from module.config.deep import deep_get from module.logger import logger From dfb2db800f3ff85bb41e4784784206e0fbbdf7ee Mon Sep 17 00:00:00 2001 From: LmeSzinc <37934724+LmeSzinc@users.noreply.github.com> Date: Mon, 17 Mar 2025 02:08:19 +0800 Subject: [PATCH 06/15] Pref: Reduce deep_get in config_update since deep_iter yields the value already --- module/config/config_updater.py | 6 +----- .../module/config/config_updater.py | 16 ++++++---------- .../module/config/config_updater.py | 6 +----- 3 files changed, 8 insertions(+), 20 deletions(-) diff --git a/module/config/config_updater.py b/module/config/config_updater.py index 9fa8f8381..9c92d8fa0 100644 --- a/module/config/config_updater.py +++ b/module/config/config_updater.py @@ -616,8 +616,7 @@ class ConfigUpdater: """ new = {} - def deep_load(keys): - data = deep_get(self.args, keys=keys, default={}) + for keys, data in deep_iter(self.args, depth=3): value = deep_get(old, keys=keys, default=data['value']) typ = data['type'] display = data.get('display') @@ -627,9 +626,6 @@ class ConfigUpdater: value = parse_value(value, data=data) deep_set(new, keys=keys, value=value) - for path, _ in deep_iter(self.args, depth=3): - deep_load(path) - # AzurStatsID if is_template: deep_set(new, 'Alas.DropRecord.AzurStatsID', None) diff --git a/submodule/AlasFpyBridge/module/config/config_updater.py b/submodule/AlasFpyBridge/module/config/config_updater.py index bbfd3d4ec..d7c35b341 100644 --- a/submodule/AlasFpyBridge/module/config/config_updater.py +++ b/submodule/AlasFpyBridge/module/config/config_updater.py @@ -85,23 +85,19 @@ class ConfigUpdater(config_updater.ConfigUpdater): """ new = {} - def deep_load(keys): - data = deep_get(self.args, keys=keys, default={}) + for keys, data in deep_iter(self.args, depth=3): value = deep_get(old, keys=keys, default=data["value"]) if ( - is_template - or value is None - or value == "" - or data["type"] == "lock" - or data.get("display") == "hide" + is_template + or value is None + or value == "" + or data["type"] == "lock" + or data.get("display") == "hide" ): value = data["value"] value = parse_value(value, data=data) deep_set(new, keys=keys, value=value) - for path, _ in deep_iter(self.args, depth=3): - deep_load(path) - if not is_template: new = self.config_redirect(old, new) diff --git a/submodule/AlasMaaBridge/module/config/config_updater.py b/submodule/AlasMaaBridge/module/config/config_updater.py index 21f066e13..5281e9ec5 100644 --- a/submodule/AlasMaaBridge/module/config/config_updater.py +++ b/submodule/AlasMaaBridge/module/config/config_updater.py @@ -94,17 +94,13 @@ class ConfigUpdater(config_updater.ConfigUpdater): """ new = {} - def deep_load(keys): - data = deep_get(self.args, keys=keys, default={}) + for keys, data in deep_iter(self.args, depth=3): value = deep_get(old, keys=keys, default=data['value']) if is_template or value is None or value == '' or data['type'] == 'lock' or data.get('display') == 'hide': value = data['value'] value = parse_value(value, data=data) deep_set(new, keys=keys, value=value) - for path, _ in deep_iter(self.args, depth=3): - deep_load(path) - if not is_template: new = self.config_redirect(old, new) From b914705d01024698347a49c00192aa0a08c4c366 Mon Sep 17 00:00:00 2001 From: LmeSzinc <37934724+LmeSzinc@users.noreply.github.com> Date: Mon, 17 Mar 2025 02:19:52 +0800 Subject: [PATCH 07/15] Pref: Replace filelock with threading lock to reduce IO operations --- module/webui/process_manager.py | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/module/webui/process_manager.py b/module/webui/process_manager.py index d1963c2f8..589267b46 100644 --- a/module/webui/process_manager.py +++ b/module/webui/process_manager.py @@ -1,22 +1,20 @@ -import os -import sys -import queue import argparse +import os +import queue import threading from multiprocessing import Process from typing import Dict, List, Union import inflection -from filelock import FileLock from rich.console import Console, ConsoleRenderable # Since this file does not run under the same process or subprocess of app.py # the following code needs to be repeated # Import fake module before import pywebio to avoid importing unnecessary module PIL from module.webui.fake_pil_module import * + import_fake_pil_module() -from module.config.utils import filepath_config from module.logger import logger, set_file_logger, set_func_logger from module.submodule.submodule import load_mod from module.submodule.utils import get_available_func, get_available_mod, get_available_mod_func, get_config_mod, \ @@ -34,6 +32,7 @@ class ProcessManager: self.renderables_max_length = 400 self.renderables_reduce_length = 80 self._process: Process = None + self._process_locks: Dict[str, threading.Lock] = {} self.thd_log_queue_handler: threading.Thread = None def start(self, func, ev: threading.Event = None) -> None: @@ -64,7 +63,12 @@ class ProcessManager: self.thd_log_queue_handler.start() def stop(self) -> None: - lock = FileLock(f"{filepath_config(self.config_name)}.lock") + try: + lock = self._process_locks[self.config_name] + except KeyError: + lock = threading.Lock() + self._process_locks[self.config_name] = lock + with lock: if self.alive: self._process.kill() From 6c7afee2ed343b4ea7f768e9d143c91940ba8042 Mon Sep 17 00:00:00 2001 From: LmeSzinc <37934724+LmeSzinc@users.noreply.github.com> Date: Wed, 19 Mar 2025 16:41:41 +0800 Subject: [PATCH 08/15] Fix: [ALAS] netcat loopback address on MuMu Pro --- module/device/connection.py | 24 +++++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/module/device/connection.py b/module/device/connection.py index 3c2cc21fd..b7e5f92bb 100644 --- a/module/device/connection.py +++ b/module/device/connection.py @@ -325,6 +325,17 @@ class Connection(ConnectionAttr): return True return False + @cached_property + @retry + def is_mumu_pro(self): + # MuMU Pro is the Mac version of MuMu + if not IS_MACINTOSH: + return False + if not self.is_mumu_family: + return False + logger.attr('is_mumu_pro', True) + return True + @cached_property @retry def nemud_app_keep_alive(self) -> str: @@ -410,23 +421,26 @@ class Connection(ConnectionAttr): return host, port, host, self.config.REVERSE_SERVER_PORT # For emulators, listen on current host if self.is_emulator or self.is_over_http: + # Mac emulators + if self.is_bluestacks_air or self.is_mumu_pro: + logger.info(f'Connecting to local emulator, using host 127.0.0.1') + port = random_port(self.config.FORWARD_PORT_RANGE) + return '127.0.0.1', port, "10.0.2.2", port + # Get host IP try: host = socket.gethostbyname(socket.gethostname()) except socket.gaierror as e: logger.error(e) logger.error(f'Unknown host name: {socket.gethostname()}') host = '127.0.0.1' + # Fixup linux AVD host if IS_LINUX and host == '127.0.1.1': host = '127.0.0.1' - if self.is_bluestacks_air: - host = '127.0.0.1' logger.info(f'Connecting to local emulator, using host {host}') port = random_port(self.config.FORWARD_PORT_RANGE) - # For AVD instance - if self.is_avd or self.is_bluestacks_air: + if self.is_avd: return host, port, "10.0.2.2", port - return host, port, host, port # For local network devices, listen on the host under the same network as target device if self.is_network_device: From fd6556d5b1a0cc36424d1dada0973fa14065bdaf Mon Sep 17 00:00:00 2001 From: LmeSzinc <37934724+LmeSzinc@users.noreply.github.com> Date: Wed, 19 Mar 2025 16:42:18 +0800 Subject: [PATCH 09/15] Fix: [ALAS] Starting game after task wait has no retry If it "start" fails next task would start --- alas.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/alas.py b/alas.py index 90173a17e..62bbdd384 100644 --- a/alas.py +++ b/alas.py @@ -476,7 +476,9 @@ class AzurLaneAutoScript: del_cached_property(self, 'config') continue if task.command != 'Restart': - self.run('start') + self.config.task_call('Restart') + del_cached_property(self, 'config') + continue elif method == 'goto_main': logger.info('Goto main page during wait') self.run('goto_main') From a55d7504f73dd831543671d8883f95c3394f23f7 Mon Sep 17 00:00:00 2001 From: LmeSzinc <37934724+LmeSzinc@users.noreply.github.com> Date: Thu, 20 Mar 2025 18:06:22 +0800 Subject: [PATCH 10/15] Fix: Override to disable submarine if stage don't allow submarine (#4668) --- module/map/map_fleet_preparation.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/module/map/map_fleet_preparation.py b/module/map/map_fleet_preparation.py index d8cfcae9b..8cab1537b 100644 --- a/module/map/map_fleet_preparation.py +++ b/module/map/map_fleet_preparation.py @@ -344,10 +344,14 @@ class FleetPreparation(InfoHandler): # Check if submarine is empty again. if submarine.allow(): + logger.attr('map_allow_submarine', True) if self.config.Submarine_Fleet: pass else: submarine.clear() + else: + logger.attr('map_allow_submarine', False) + self.config.SUBMARINE = 0 if self.appear(FLEET_1_CLEAR, offset=(-20, -80, 20, 5)): AUTO_SEARCH_SET_MOB.load_offset(FLEET_1_CLEAR) From 2a67d0a52e7437f54be952d1ac840fc267289066 Mon Sep 17 00:00:00 2001 From: LmeSzinc <37934724+LmeSzinc@users.noreply.github.com> Date: Thu, 20 Mar 2025 18:53:39 +0800 Subject: [PATCH 11/15] Upd: Event Revelations of Dust Rerun (event_20230223_cn) - [TW] Light of the Martyrium (event_20240521_cn) --- campaign/Readme.md | 2 + module/config/argument/args.json | 96 ++++++++++++++++---------------- module/config/i18n/en-US.json | 2 +- module/config/i18n/ja-JP.json | 2 +- module/config/i18n/zh-CN.json | 2 +- module/config/i18n/zh-TW.json | 2 +- 6 files changed, 54 insertions(+), 52 deletions(-) diff --git a/campaign/Readme.md b/campaign/Readme.md index c14e233eb..d85e5337c 100644 --- a/campaign/Readme.md +++ b/campaign/Readme.md @@ -234,3 +234,5 @@ To add a new event, add a new row in here, and run `python -m module.config.conf | 20250213 | event 20240815 cn | Windborne Steel Wings | - | - | - | 鐵翼擎風 | | 20250227 | event 20250227 cn | Paradiso of Shackled Light | 樊笼内的神光 | Paradiso of Shackled Light | 籠檻に囚われし神光 | - | | 20250227 | event 20240725 cn | Interlude of Illusions | - | - | - | 幻夢間奏曲 | +| 20250320 | event 20230223 cn | Revelations of Dust | 复刻湮烬尘墟 | Revelations of Dust Rerun | 黙示の遺構(復刻) | - | +| 20250320 | event 20240521 cn | Light of the Martyrium | - | - | - | 綻放於輝光之城 | diff --git a/module/config/argument/args.json b/module/config/argument/args.json index 8ab0bf724..3e5509549 100644 --- a/module/config/argument/args.json +++ b/module/config/argument/args.json @@ -1716,13 +1716,13 @@ ], "display": "hide", "option_bold": [ - "event_20240725_cn", - "event_20250227_cn" + "event_20230223_cn", + "event_20240521_cn" ], - "cn": "event_20250227_cn", - "en": "event_20250227_cn", - "jp": "event_20250227_cn", - "tw": "event_20240725_cn" + "cn": "event_20230223_cn", + "en": "event_20230223_cn", + "jp": "event_20230223_cn", + "tw": "event_20240521_cn" }, "Mode": { "type": "select", @@ -2059,13 +2059,13 @@ "event_20250227_cn" ], "option_bold": [ - "event_20240725_cn", - "event_20250227_cn" + "event_20230223_cn", + "event_20240521_cn" ], - "cn": "event_20250227_cn", - "en": "event_20250227_cn", - "jp": "event_20250227_cn", - "tw": "event_20240725_cn" + "cn": "event_20230223_cn", + "en": "event_20230223_cn", + "jp": "event_20230223_cn", + "tw": "event_20240521_cn" }, "Mode": { "type": "select", @@ -2517,13 +2517,13 @@ "event_20250227_cn" ], "option_bold": [ - "event_20240725_cn", - "event_20250227_cn" + "event_20230223_cn", + "event_20240521_cn" ], - "cn": "event_20250227_cn", - "en": "event_20250227_cn", - "jp": "event_20250227_cn", - "tw": "event_20240725_cn" + "cn": "event_20230223_cn", + "en": "event_20230223_cn", + "jp": "event_20230223_cn", + "tw": "event_20240521_cn" }, "Mode": { "type": "select", @@ -3939,13 +3939,13 @@ "event_20250227_cn" ], "option_bold": [ - "event_20240725_cn", - "event_20250227_cn" + "event_20230223_cn", + "event_20240521_cn" ], - "cn": "event_20250227_cn", - "en": "event_20250227_cn", - "jp": "event_20250227_cn", - "tw": "event_20240725_cn" + "cn": "event_20230223_cn", + "en": "event_20230223_cn", + "jp": "event_20230223_cn", + "tw": "event_20240521_cn" }, "Mode": { "type": "select", @@ -4414,13 +4414,13 @@ "event_20250227_cn" ], "option_bold": [ - "event_20240725_cn", - "event_20250227_cn" + "event_20230223_cn", + "event_20240521_cn" ], - "cn": "event_20250227_cn", - "en": "event_20250227_cn", - "jp": "event_20250227_cn", - "tw": "event_20240725_cn" + "cn": "event_20230223_cn", + "en": "event_20230223_cn", + "jp": "event_20230223_cn", + "tw": "event_20240521_cn" }, "Mode": { "type": "select", @@ -4889,13 +4889,13 @@ "event_20250227_cn" ], "option_bold": [ - "event_20240725_cn", - "event_20250227_cn" + "event_20230223_cn", + "event_20240521_cn" ], - "cn": "event_20250227_cn", - "en": "event_20250227_cn", - "jp": "event_20250227_cn", - "tw": "event_20240725_cn" + "cn": "event_20230223_cn", + "en": "event_20230223_cn", + "jp": "event_20230223_cn", + "tw": "event_20240521_cn" }, "Mode": { "type": "select", @@ -5364,13 +5364,13 @@ "event_20250227_cn" ], "option_bold": [ - "event_20240725_cn", - "event_20250227_cn" + "event_20230223_cn", + "event_20240521_cn" ], - "cn": "event_20250227_cn", - "en": "event_20250227_cn", - "jp": "event_20250227_cn", - "tw": "event_20240725_cn" + "cn": "event_20230223_cn", + "en": "event_20230223_cn", + "jp": "event_20230223_cn", + "tw": "event_20240521_cn" }, "Mode": { "type": "select", @@ -5829,13 +5829,13 @@ "event_20250227_cn" ], "option_bold": [ - "event_20240725_cn", - "event_20250227_cn" + "event_20230223_cn", + "event_20240521_cn" ], - "cn": "event_20250227_cn", - "en": "event_20250227_cn", - "jp": "event_20250227_cn", - "tw": "event_20240725_cn" + "cn": "event_20230223_cn", + "en": "event_20230223_cn", + "jp": "event_20230223_cn", + "tw": "event_20240521_cn" }, "Mode": { "type": "select", diff --git a/module/config/i18n/en-US.json b/module/config/i18n/en-US.json index 94db8f1fc..cf105dea6 100644 --- a/module/config/i18n/en-US.json +++ b/module/config/i18n/en-US.json @@ -725,7 +725,7 @@ "event_20220915_cn": "Violet Tempest Blooming Lycoris Rerun", "event_20221124_cn": "The Alchemist and the Archipelago of Secrets", "event_20221222_cn": "Parallel Superimposition Rerun", - "event_20230223_cn": "Revelations of Dust", + "event_20230223_cn": "Revelations of Dust Rerun", "event_20230525_cn": "Confluence of Nothingness", "event_20230803_cn": "Anthem of Remembrance", "event_20230817_cn": "The Fools Scales", diff --git a/module/config/i18n/ja-JP.json b/module/config/i18n/ja-JP.json index 19f2af975..706c8ddbf 100644 --- a/module/config/i18n/ja-JP.json +++ b/module/config/i18n/ja-JP.json @@ -725,7 +725,7 @@ "event_20220915_cn": "赫の涙月 菫の暁風(復刻)", "event_20221124_cn": "錬金術士と謎の遺跡群島", "event_20221222_cn": "積重なる事象の幻界(復刻)", - "event_20230223_cn": "黙示の遺構", + "event_20230223_cn": "黙示の遺構(復刻)", "event_20230525_cn": "覆天せし万象の塵", "event_20230803_cn": "燃ゆる聖都の回想曲", "event_20230817_cn": "愚者の天秤", diff --git a/module/config/i18n/zh-CN.json b/module/config/i18n/zh-CN.json index 76a320ab9..48f0f714b 100644 --- a/module/config/i18n/zh-CN.json +++ b/module/config/i18n/zh-CN.json @@ -725,7 +725,7 @@ "event_20220915_cn": "复刻紫绛槿岚", "event_20221124_cn": "炼金术士与秘密遗迹群岛", "event_20221222_cn": "复刻定向折叠", - "event_20230223_cn": "湮烬尘墟", + "event_20230223_cn": "复刻湮烬尘墟", "event_20230525_cn": "空相交汇点", "event_20230803_cn": "奏响鸢尾之歌", "event_20230817_cn": "愚者的天平", diff --git a/module/config/i18n/zh-TW.json b/module/config/i18n/zh-TW.json index 8b3843bdf..37864a2ad 100644 --- a/module/config/i18n/zh-TW.json +++ b/module/config/i18n/zh-TW.json @@ -735,7 +735,7 @@ "event_20231221_cn": "星海逐光", "event_20240229_cn": "雪境迷蹤", "event_20240425_cn": "共鳴的PASSION", - "event_20240521_cn": "Light of the Martyrium", + "event_20240521_cn": "綻放於輝光之城", "event_20240725_cn": "幻夢間奏曲", "event_20240815_cn": "鐵翼擎風", "event_20240829_cn": "埋葬於彼岸之花", From ab0137c87088ef5c1ee04ddc31c18501f4df5a93 Mon Sep 17 00:00:00 2001 From: LmeSzinc <37934724+LmeSzinc@users.noreply.github.com> Date: Thu, 20 Mar 2025 19:17:47 +0800 Subject: [PATCH 12/15] Fix: Wait the circle loading animation in meta reward page --- module/meta_reward/meta_reward.py | 39 +++++++++++++++++++++++++++++-- 1 file changed, 37 insertions(+), 2 deletions(-) diff --git a/module/meta_reward/meta_reward.py b/module/meta_reward/meta_reward.py index ac93beb16..b32460776 100644 --- a/module/meta_reward/meta_reward.py +++ b/module/meta_reward/meta_reward.py @@ -17,10 +17,8 @@ class BeaconReward(Combat, UI): in: page_meta """ if self.appear(META_REWARD_NOTICE, threshold=30): - logger.info('Found meta reward red dot') return True else: - logger.info('No meta reward red dot') return False def meta_reward_receive(self, skip_first_screenshot=True): @@ -140,6 +138,36 @@ class BeaconReward(Combat, UI): logger.info(f'Meta sync receive finished, received={received}') return received + def meta_wait_reward_page(self, skip_first_screenshot=True): + """ + Wait the circle loading animation + """ + timeout = Timer(2, count=6).start() + while 1: + if skip_first_screenshot: + skip_first_screenshot = False + else: + self.device.screenshot() + + if timeout.reached(): + logger.warning(f'meta_wait_reward_page timeout') + break + if self.appear(REWARD_ENTER, offset=(20, 20)): + logger.info(f'meta_wait_reward_page ends at {REWARD_ENTER}') + break + if self.appear(SYNC_ENTER, offset=(20, 20)): + logger.info(f'meta_wait_reward_page ends at {SYNC_ENTER}') + break + if self.appear(SYNC_TAP, offset=(20, 20)): + logger.info(f'meta_wait_reward_page ends at {SYNC_TAP}') + break + if self.meta_sync_notice_appear(): + logger.info('meta_wait_reward_page ends at sync red dot') + break + if self.meta_reward_notice_appear(): + logger.info('meta_wait_reward_page ends at reward red dot') + break + def run(self): if self.config.SERVER in ['cn', 'en', 'jp']: pass @@ -148,15 +176,22 @@ class BeaconReward(Combat, UI): return self.ui_ensure(page_meta) + self.meta_wait_reward_page() + # Sync rewards + # "sync" is the period that you gather meta points to 100% and get a meta ship if self.meta_sync_notice_appear(): logger.info('Found meta sync red dot') self.meta_sync_receive() else: logger.info('No meta sync red dot') + # Meta rewards if self.meta_reward_notice_appear(): + logger.info('Found meta reward red dot') self.meta_reward_receive() + else: + logger.info('No meta reward red dot') class DossierReward(Combat, UI): From 3eaac0999fe4d68c8202312ef41f94cc8299ea99 Mon Sep 17 00:00:00 2001 From: LmeSzinc <37934724+LmeSzinc@users.noreply.github.com> Date: Fri, 21 Mar 2025 03:20:41 +0800 Subject: [PATCH 13/15] Fix: No compress in u2.dump_hierarchy Donno why setting compressed=True may drop resource-id of nodes --- module/device/method/uiautomator_2.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/module/device/method/uiautomator_2.py b/module/device/method/uiautomator_2.py index 5fa41801a..fb5274356 100644 --- a/module/device/method/uiautomator_2.py +++ b/module/device/method/uiautomator_2.py @@ -376,7 +376,8 @@ class Uiautomator2(Connection): @retry def dump_hierarchy_uiautomator2(self) -> etree._Element: - content = self.u2.dump_hierarchy(compressed=True) + content = self.u2.dump_hierarchy(compressed=False) + # print(content) hierarchy = etree.fromstring(content.encode('utf-8')) return hierarchy From 6dfe9204c4b80a97f1bcf2ef8be8dd5185d3d27a Mon Sep 17 00:00:00 2001 From: LmeSzinc <37934724+LmeSzinc@users.noreply.github.com> Date: Fri, 21 Mar 2025 03:41:11 +0800 Subject: [PATCH 14/15] Chore: Abstract app_current() --- module/device/app_control.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/module/device/app_control.py b/module/device/app_control.py index b483e3440..8a681aa6a 100644 --- a/module/device/app_control.py +++ b/module/device/app_control.py @@ -14,7 +14,7 @@ class AppControl(Adb, WSA, Uiautomator2): _app_u2_family = ['uiautomator2', 'minitouch', 'scrcpy', 'MaaTouch', 'nemu_ipc'] _hierarchy_interval = Timer(0.1) - def app_is_running(self) -> bool: + def app_current(self) -> str: method = self.config.Emulator_ControlMethod if self.is_wsa: package = self.app_current_wsa() @@ -22,8 +22,11 @@ class AppControl(Adb, WSA, Uiautomator2): package = self.app_current_uiautomator2() else: package = self.app_current_adb() - package = package.strip(' \t\r\n') + return package + + def app_is_running(self) -> bool: + package = self.app_current() logger.attr('Package_name', package) return package == self.package From 24fc8e8b1519f530601a8bded7ee49afd4fbac94 Mon Sep 17 00:00:00 2001 From: guoh064 <50830808+guoh064@users.noreply.github.com> Date: Wed, 26 Mar 2025 00:47:34 +0800 Subject: [PATCH 15/15] Merge pull request #4684 from guoh064/jp_zone_name Upd: ocr params for get_zone_name in JP server --- module/os/map_operation.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/module/os/map_operation.py b/module/os/map_operation.py index 635452168..4703343bf 100644 --- a/module/os/map_operation.py +++ b/module/os/map_operation.py @@ -77,7 +77,7 @@ class OSMapOperation(MapOrderHandler, MissionHandler, PortHandler, StorageHandle @Config.when(SERVER='jp') def get_zone_name(self): # For JP only - ocr = Ocr(MAP_NAME, lang='jp', letter=(201, 218, 239), threshold=220, name='OCR_OS_MAP_NAME') + ocr = Ocr(MAP_NAME, lang='jp', letter=(157, 173, 192), threshold=127, name='OCR_OS_MAP_NAME') name = ocr.ocr(self.device.image) self.is_zone_name_hidden = '安全' in name # Remove punctuations