diff --git a/alas.py b/alas.py index eba3d97ac..4883097ed 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 @@ -590,7 +590,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') diff --git a/assets/en/handler/SUBMARINE_MOVE_CANCEL.png b/assets/en/handler/SUBMARINE_MOVE_CANCEL.png index cd4678910..152cce9c2 100644 Binary files a/assets/en/handler/SUBMARINE_MOVE_CANCEL.png and b/assets/en/handler/SUBMARINE_MOVE_CANCEL.png differ diff --git a/assets/en/handler/SUBMARINE_MOVE_CONFIRM.png b/assets/en/handler/SUBMARINE_MOVE_CONFIRM.png index 96c854ea3..863283624 100644 Binary files a/assets/en/handler/SUBMARINE_MOVE_CONFIRM.png and b/assets/en/handler/SUBMARINE_MOVE_CONFIRM.png differ diff --git a/assets/jp/meta_reward/SYNC_ENTER.png b/assets/jp/meta_reward/SYNC_ENTER.png index d555105e7..e6c54863d 100644 Binary files a/assets/jp/meta_reward/SYNC_ENTER.png and b/assets/jp/meta_reward/SYNC_ENTER.png differ 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/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 9bfad00db..ac207abec 100644 --- a/deploy/installer.py +++ b/deploy/installer.py @@ -13,6 +13,8 @@ from deploy.Windows.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/awaken/awaken.py b/module/awaken/awaken.py index 6e4de4715..c53f47c48 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') diff --git a/module/campaign/campaign_event.py b/module/campaign/campaign_event.py index e9abb5223..1f07dc187 100644 --- a/module/campaign/campaign_event.py +++ b/module/campaign/campaign_event.py @@ -32,7 +32,7 @@ class CampaignEvent(CampaignStatus): continue name = self.config.cross_get(keys=f'{task}.Campaign.Name', default='2-4') if not self.stage_is_main(name): - from module.config.utils import deep_get + from module.config.deep import deep_get _gg_on = deep_get(self.config.data, keys='GameManager.GGHandler.Enabled') if _gg_on: campaign_to_go = '15-1' @@ -113,7 +113,7 @@ class CampaignEvent(CampaignStatus): Pages: in: page_event or page_sp """ - from module.config.utils import deep_get + from module.config.deep import deep_get limit = self.config.TaskBalancer_CoinLimit coin = deep_get(self.config.data, 'Dashboard.Coin.Value') logger.attr('Coin Count', coin) diff --git a/module/campaign/run.py b/module/campaign/run.py index 729bebf30..817882784 100644 --- a/module/campaign/run.py +++ b/module/campaign/run.py @@ -13,7 +13,7 @@ from module.handler.fast_forward import map_files, to_map_file_name from module.logger import logger from module.notify import handle_notify from module.ui.page import page_campaign -from module.config.utils import deep_get, deep_set +from module.config.deep import deep_get, deep_set from datetime import datetime, timedelta class CampaignRun(CampaignEvent, ShopStatus): diff --git a/module/config/argument/args.json b/module/config/argument/args.json index d5705e3a6..ad1a4771d 100644 --- a/module/config/argument/args.json +++ b/module/config/argument/args.json @@ -2140,13 +2140,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", @@ -5250,13 +5250,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", @@ -6166,13 +6166,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", @@ -7588,13 +7588,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", @@ -8063,13 +8063,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", @@ -8538,13 +8538,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", @@ -9013,13 +9013,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", @@ -9478,13 +9478,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/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/config.py b/module/config/config.py index c1c0d6feb..47befd534 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 93eec4f39..c51a83e1e 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.Windows.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 @@ -643,8 +644,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') @@ -654,9 +654,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/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/i18n/en-US.json b/module/config/i18n/en-US.json index 1fe34b82e..906cc32c4 100644 --- a/module/config/i18n/en-US.json +++ b/module/config/i18n/en-US.json @@ -982,7 +982,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 a949482df..8d1ff0ebe 100644 --- a/module/config/i18n/ja-JP.json +++ b/module/config/i18n/ja-JP.json @@ -982,7 +982,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 e01fef702..bb0d42e1a 100644 --- a/module/config/i18n/zh-CN.json +++ b/module/config/i18n/zh-CN.json @@ -982,7 +982,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 b4dbeff16..85bc8c26d 100644 --- a/module/config/i18n/zh-TW.json +++ b/module/config/i18n/zh-TW.json @@ -992,7 +992,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": "埋葬於彼岸之花", diff --git a/module/config/utils.py b/module/config/utils.py index 4b02a8ba8..04fd381e7 100644 --- a/module/config/utils.py +++ b/module/config/utils.py @@ -1,15 +1,13 @@ import json -import os import random import string import calendar 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'] @@ -80,33 +78,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): @@ -117,28 +105,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): @@ -202,101 +182,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/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 diff --git a/module/device/connection.py b/module/device/connection.py index dcb1bdb15..b5d058bc2 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: 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/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 diff --git a/module/gg_handler/gg_handler.py b/module/gg_handler/gg_handler.py index 06f12cdd7..d54535843 100644 --- a/module/gg_handler/gg_handler.py +++ b/module/gg_handler/gg_handler.py @@ -1,7 +1,7 @@ from module.gg_handler.gg_data import GGData from module.gg_handler.gg_u2 import GGU2 # from module.gg_handler.gg_screenshot import GGScreenshot -from module.config.utils import deep_get, deep_set +from module.config.deep import deep_get, deep_set from module.logger import logger from module.base.timer import timeout @@ -189,7 +189,7 @@ class GGHandler: self.device.screenshot() OCR_CHECK = Digit(OCR_PRE_BATTLE_CHECK, letter=(255, 255, 255), threshold=128) ocr = OCR_CHECK.ocr(self.device.image) - from module.config.utils import deep_get + from module.config.deep import deep_get limit = deep_get(self.config.data, keys=f'GameManager.PowerLimit.{task}', default=17000) logger.attr('Power Limit', limit) if ocr >= limit: diff --git a/module/gg_handler/gg_screenshot.py b/module/gg_handler/gg_screenshot.py index ea573df1f..ebca71f6d 100644 --- a/module/gg_handler/gg_screenshot.py +++ b/module/gg_handler/gg_screenshot.py @@ -1,4 +1,4 @@ -from module.config.utils import deep_get +from module.config.deep import deep_get from module.logger import logger from module.gg_handler.assets import * from module.base.base import ModuleBase as Base diff --git a/module/handler/assets.py b/module/handler/assets.py index 3d12fbb1d..b784ad3e0 100644 --- a/module/handler/assets.py +++ b/module/handler/assets.py @@ -90,8 +90,8 @@ STRATEGY_OPEN = Button(area={'cn': (1198, 411, 1269, 471), 'en': (1198, 410, 127 STRATEGY_OPENED = Button(area={'cn': (1176, 366, 1275, 393), 'en': (1176, 366, 1276, 393), 'jp': (1178, 367, 1273, 391), 'tw': (1176, 366, 1275, 392)}, color={'cn': (128, 155, 218), 'en': (108, 139, 210), 'jp': (156, 176, 223), 'tw': (126, 153, 218)}, button={'cn': (968, 403, 994, 488), 'en': (968, 403, 994, 488), 'jp': (968, 403, 994, 488), 'tw': (1057, 393, 1092, 496)}, file={'cn': './assets/cn/handler/STRATEGY_OPENED.png', 'en': './assets/en/handler/STRATEGY_OPENED.png', 'jp': './assets/jp/handler/STRATEGY_OPENED.png', 'tw': './assets/tw/handler/STRATEGY_OPENED.png'}) SUBMARINE_HUNT_OFF = Button(area={'cn': (1200, 415, 1262, 477), 'en': (1200, 415, 1262, 477), 'jp': (1200, 415, 1262, 477), 'tw': (1200, 415, 1262, 477)}, color={'cn': (125, 127, 132), 'en': (125, 127, 132), 'jp': (125, 127, 132), 'tw': (125, 127, 132)}, button={'cn': (1200, 415, 1262, 477), 'en': (1200, 415, 1262, 477), 'jp': (1200, 415, 1262, 477), 'tw': (1200, 415, 1262, 477)}, file={'cn': './assets/cn/handler/SUBMARINE_HUNT_OFF.png', 'en': './assets/en/handler/SUBMARINE_HUNT_OFF.png', 'jp': './assets/jp/handler/SUBMARINE_HUNT_OFF.png', 'tw': './assets/tw/handler/SUBMARINE_HUNT_OFF.png'}) SUBMARINE_HUNT_ON = Button(area={'cn': (1200, 415, 1262, 477), 'en': (1200, 415, 1262, 477), 'jp': (1200, 415, 1262, 477), 'tw': (1200, 415, 1262, 477)}, color={'cn': (124, 125, 132), 'en': (124, 125, 132), 'jp': (124, 125, 132), 'tw': (124, 125, 132)}, button={'cn': (1200, 415, 1262, 477), 'en': (1200, 415, 1262, 477), 'jp': (1200, 415, 1262, 477), 'tw': (1200, 415, 1262, 477)}, file={'cn': './assets/cn/handler/SUBMARINE_HUNT_ON.png', 'en': './assets/en/handler/SUBMARINE_HUNT_ON.png', 'jp': './assets/jp/handler/SUBMARINE_HUNT_ON.png', 'tw': './assets/tw/handler/SUBMARINE_HUNT_ON.png'}) -SUBMARINE_MOVE_CANCEL = Button(area={'cn': (891, 647, 1005, 673), 'en': (911, 650, 984, 667), 'jp': (951, 646, 1006, 673), 'tw': (889, 646, 1006, 674)}, color={'cn': (219, 172, 167), 'en': (211, 162, 158), 'jp': (208, 148, 147), 'tw': (215, 164, 161)}, button={'cn': (891, 647, 1005, 673), 'en': (911, 650, 984, 667), 'jp': (951, 646, 1006, 673), 'tw': (889, 646, 1006, 674)}, file={'cn': './assets/cn/handler/SUBMARINE_MOVE_CANCEL.png', 'en': './assets/en/handler/SUBMARINE_MOVE_CANCEL.png', 'jp': './assets/jp/handler/SUBMARINE_MOVE_CANCEL.png', 'tw': './assets/tw/handler/SUBMARINE_MOVE_CANCEL.png'}) -SUBMARINE_MOVE_CONFIRM = Button(area={'cn': (1103, 646, 1218, 674), 'en': (1101, 650, 1222, 667), 'jp': (1163, 646, 1219, 673), 'tw': (1102, 646, 1220, 674)}, color={'cn': (157, 185, 222), 'en': (163, 184, 219), 'jp': (147, 186, 231), 'tw': (156, 182, 220)}, button={'cn': (1103, 646, 1218, 674), 'en': (1101, 650, 1222, 667), 'jp': (1163, 646, 1219, 673), 'tw': (1102, 646, 1220, 674)}, file={'cn': './assets/cn/handler/SUBMARINE_MOVE_CONFIRM.png', 'en': './assets/en/handler/SUBMARINE_MOVE_CONFIRM.png', 'jp': './assets/jp/handler/SUBMARINE_MOVE_CONFIRM.png', 'tw': './assets/tw/handler/SUBMARINE_MOVE_CONFIRM.png'}) +SUBMARINE_MOVE_CANCEL = Button(area={'cn': (891, 647, 1005, 673), 'en': (894, 643, 1002, 675), 'jp': (951, 646, 1006, 673), 'tw': (889, 646, 1006, 674)}, color={'cn': (219, 172, 167), 'en': (195, 134, 128), 'jp': (208, 148, 147), 'tw': (215, 164, 161)}, button={'cn': (891, 647, 1005, 673), 'en': (894, 643, 1002, 675), 'jp': (951, 646, 1006, 673), 'tw': (889, 646, 1006, 674)}, file={'cn': './assets/cn/handler/SUBMARINE_MOVE_CANCEL.png', 'en': './assets/en/handler/SUBMARINE_MOVE_CANCEL.png', 'jp': './assets/jp/handler/SUBMARINE_MOVE_CANCEL.png', 'tw': './assets/tw/handler/SUBMARINE_MOVE_CANCEL.png'}) +SUBMARINE_MOVE_CONFIRM = Button(area={'cn': (1103, 646, 1218, 674), 'en': (1094, 631, 1228, 689), 'jp': (1163, 646, 1219, 673), 'tw': (1102, 646, 1220, 674)}, color={'cn': (157, 185, 222), 'en': (123, 160, 207), 'jp': (147, 186, 231), 'tw': (156, 182, 220)}, button={'cn': (1103, 646, 1218, 674), 'en': (1094, 631, 1228, 689), 'jp': (1163, 646, 1219, 673), 'tw': (1102, 646, 1220, 674)}, file={'cn': './assets/cn/handler/SUBMARINE_MOVE_CONFIRM.png', 'en': './assets/en/handler/SUBMARINE_MOVE_CONFIRM.png', 'jp': './assets/jp/handler/SUBMARINE_MOVE_CONFIRM.png', 'tw': './assets/tw/handler/SUBMARINE_MOVE_CONFIRM.png'}) SUBMARINE_MOVE_ENTER = Button(area={'cn': (1109, 511, 1169, 571), 'en': (1109, 511, 1169, 571), 'jp': (1109, 511, 1169, 571), 'tw': (1109, 511, 1169, 571)}, color={'cn': (106, 107, 114), 'en': (106, 107, 114), 'jp': (106, 107, 114), 'tw': (106, 107, 114)}, button={'cn': (1109, 511, 1169, 571), 'en': (1109, 511, 1169, 571), 'jp': (1109, 511, 1169, 571), 'tw': (1109, 511, 1169, 571)}, file={'cn': './assets/cn/handler/SUBMARINE_MOVE_ENTER.png', 'en': './assets/en/handler/SUBMARINE_MOVE_ENTER.png', 'jp': './assets/jp/handler/SUBMARINE_MOVE_ENTER.png', 'tw': './assets/tw/handler/SUBMARINE_MOVE_ENTER.png'}) SUBMARINE_VIEW_OFF = Button(area={'cn': (1140, 435, 1170, 468), 'en': (1140, 435, 1170, 468), 'jp': (1140, 435, 1170, 468), 'tw': (1140, 435, 1170, 468)}, color={'cn': (156, 156, 158), 'en': (156, 156, 158), 'jp': (156, 156, 158), 'tw': (156, 156, 158)}, button={'cn': (1140, 435, 1170, 468), 'en': (1140, 435, 1170, 468), 'jp': (1140, 435, 1170, 468), 'tw': (1140, 435, 1170, 468)}, file={'cn': './assets/cn/handler/SUBMARINE_VIEW_OFF.png', 'en': './assets/en/handler/SUBMARINE_VIEW_OFF.png', 'jp': './assets/jp/handler/SUBMARINE_VIEW_OFF.png', 'tw': './assets/tw/handler/SUBMARINE_VIEW_OFF.png'}) SUBMARINE_VIEW_ON = Button(area={'cn': (1140, 435, 1170, 468), 'en': (1140, 435, 1170, 468), 'jp': (1140, 435, 1170, 468), 'tw': (1140, 435, 1170, 468)}, color={'cn': (177, 178, 179), 'en': (177, 178, 179), 'jp': (177, 178, 179), 'tw': (177, 178, 179)}, button={'cn': (1140, 435, 1170, 468), 'en': (1140, 435, 1170, 468), 'jp': (1140, 435, 1170, 468), 'tw': (1140, 435, 1170, 468)}, file={'cn': './assets/cn/handler/SUBMARINE_VIEW_ON.png', 'en': './assets/en/handler/SUBMARINE_VIEW_ON.png', 'jp': './assets/jp/handler/SUBMARINE_VIEW_ON.png', 'tw': './assets/tw/handler/SUBMARINE_VIEW_ON.png'}) diff --git a/module/log_res/log_res.py b/module/log_res/log_res.py index f2c2dc692..fafe5e1f5 100644 --- a/module/log_res/log_res.py +++ b/module/log_res/log_res.py @@ -1,6 +1,6 @@ from cached_property import cached_property from module.logger import logger -from module.config.utils import deep_get +from module.config.deep import deep_get from datetime import datetime diff --git a/module/luahook/crack.py b/module/luahook/crack.py index 3f034a39f..8653d055f 100644 --- a/module/luahook/crack.py +++ b/module/luahook/crack.py @@ -8,7 +8,7 @@ import requests from pydantic import BaseModel from module.config.config import AzurLaneConfig -from module.config.utils import deep_get +from module.config.deep import deep_get from module.device.device import Device from module.exception import CrackerError from module.logger import logger diff --git a/module/map/map_fleet_preparation.py b/module/map/map_fleet_preparation.py index 11e045226..107e54867 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) diff --git a/module/meta_reward/assets.py b/module/meta_reward/assets.py index f4657b919..06c2db31f 100644 --- a/module/meta_reward/assets.py +++ b/module/meta_reward/assets.py @@ -11,6 +11,6 @@ META_REWARD_NOTICE = Button(area={'cn': (1070, 508, 1075, 523), 'en': (1070, 508 REWARD_CHECK = Button(area={'cn': (31, 486, 64, 543), 'en': (35, 487, 62, 541), 'jp': (31, 486, 64, 543), 'tw': (31, 486, 64, 543)}, color={'cn': (199, 164, 165), 'en': (203, 169, 170), 'jp': (206, 172, 174), 'tw': (199, 164, 165)}, button={'cn': (31, 486, 64, 543), 'en': (35, 487, 62, 541), 'jp': (31, 486, 64, 543), 'tw': (31, 486, 64, 543)}, file={'cn': './assets/cn/meta_reward/REWARD_CHECK.png', 'en': './assets/en/meta_reward/REWARD_CHECK.png', 'jp': './assets/jp/meta_reward/REWARD_CHECK.png', 'tw': './assets/cn/meta_reward/REWARD_CHECK.png'}) REWARD_ENTER = Button(area={'cn': (1109, 535, 1187, 554), 'en': (1106, 532, 1199, 544), 'jp': (1108, 535, 1188, 554), 'tw': (1109, 535, 1187, 554)}, color={'cn': (199, 195, 201), 'en': (213, 212, 217), 'jp': (215, 207, 214), 'tw': (199, 195, 201)}, button={'cn': (1109, 535, 1187, 554), 'en': (1106, 532, 1199, 544), 'jp': (1108, 535, 1188, 554), 'tw': (1109, 535, 1187, 554)}, file={'cn': './assets/cn/meta_reward/REWARD_ENTER.png', 'en': './assets/en/meta_reward/REWARD_ENTER.png', 'jp': './assets/jp/meta_reward/REWARD_ENTER.png', 'tw': './assets/cn/meta_reward/REWARD_ENTER.png'}) REWARD_RECEIVE = Button(area={'cn': (1031, 601, 1215, 638), 'en': (1067, 608, 1182, 633), 'jp': (1043, 604, 1203, 635), 'tw': (1031, 601, 1215, 638)}, color={'cn': (149, 62, 62), 'en': (164, 92, 93), 'jp': (150, 64, 64), 'tw': (149, 62, 62)}, button={'cn': (1031, 601, 1215, 638), 'en': (1067, 608, 1182, 633), 'jp': (1043, 604, 1203, 635), 'tw': (1031, 601, 1215, 638)}, file={'cn': './assets/cn/meta_reward/REWARD_RECEIVE.png', 'en': './assets/en/meta_reward/REWARD_RECEIVE.png', 'jp': './assets/jp/meta_reward/REWARD_RECEIVE.png', 'tw': './assets/cn/meta_reward/REWARD_RECEIVE.png'}) -SYNC_ENTER = Button(area={'cn': (866, 351, 944, 370), 'en': (866, 351, 943, 370), 'jp': (866, 351, 944, 370), 'tw': (866, 351, 944, 370)}, color={'cn': (185, 176, 179), 'en': (132, 117, 119), 'jp': (185, 176, 179), 'tw': (185, 176, 179)}, button={'cn': (866, 351, 944, 370), 'en': (866, 351, 943, 370), 'jp': (866, 351, 944, 370), 'tw': (866, 351, 944, 370)}, file={'cn': './assets/cn/meta_reward/SYNC_ENTER.png', 'en': './assets/en/meta_reward/SYNC_ENTER.png', 'jp': './assets/jp/meta_reward/SYNC_ENTER.png', 'tw': './assets/cn/meta_reward/SYNC_ENTER.png'}) +SYNC_ENTER = Button(area={'cn': (866, 351, 944, 370), 'en': (866, 351, 943, 370), 'jp': (866, 351, 944, 370), 'tw': (866, 351, 944, 370)}, color={'cn': (185, 176, 179), 'en': (132, 117, 119), 'jp': (205, 197, 200), 'tw': (185, 176, 179)}, button={'cn': (866, 351, 944, 370), 'en': (866, 351, 943, 370), 'jp': (866, 351, 944, 370), 'tw': (866, 351, 944, 370)}, file={'cn': './assets/cn/meta_reward/SYNC_ENTER.png', 'en': './assets/en/meta_reward/SYNC_ENTER.png', 'jp': './assets/jp/meta_reward/SYNC_ENTER.png', 'tw': './assets/cn/meta_reward/SYNC_ENTER.png'}) SYNC_REWARD_NOTICE = Button(area={'cn': (977, 337, 981, 352), 'en': (977, 337, 981, 352), 'jp': (977, 337, 981, 352), 'tw': (977, 337, 981, 352)}, color={'cn': (250, 182, 57), 'en': (250, 182, 57), 'jp': (250, 182, 57), 'tw': (250, 182, 57)}, button={'cn': (977, 337, 981, 352), 'en': (977, 337, 981, 352), 'jp': (977, 337, 981, 352), 'tw': (977, 337, 981, 352)}, file={'cn': './assets/cn/meta_reward/SYNC_REWARD_NOTICE.png', 'en': './assets/cn/meta_reward/SYNC_REWARD_NOTICE.png', 'jp': './assets/cn/meta_reward/SYNC_REWARD_NOTICE.png', 'tw': './assets/cn/meta_reward/SYNC_REWARD_NOTICE.png'}) SYNC_TAP = Button(area={'cn': (581, 339, 707, 377), 'en': (581, 339, 707, 377), 'jp': (564, 340, 720, 377), 'tw': (581, 339, 707, 377)}, color={'cn': (168, 112, 111), 'en': (129, 80, 80), 'jp': (176, 119, 119), 'tw': (168, 112, 111)}, button={'cn': (581, 339, 707, 377), 'en': (581, 339, 707, 377), 'jp': (564, 340, 720, 377), 'tw': (581, 339, 707, 377)}, file={'cn': './assets/cn/meta_reward/SYNC_TAP.png', 'en': './assets/en/meta_reward/SYNC_TAP.png', 'jp': './assets/jp/meta_reward/SYNC_TAP.png', 'tw': './assets/cn/meta_reward/SYNC_TAP.png'}) 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): 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 diff --git a/module/os_handler/action_point.py b/module/os_handler/action_point.py index 3fcd91e11..a3b92827c 100644 --- a/module/os_handler/action_point.py +++ b/module/os_handler/action_point.py @@ -12,7 +12,7 @@ from module.os_handler.map_event import MapEventHandler from module.statistics.item import Item, ItemGrid from module.ui.assets import OS_CHECK from module.ui.ui import UI -from module.config.utils import deep_get +from module.config.deep import deep_get from module.log_res.log_res import LogRes OCR_ACTION_POINT_REMAIN = Digit(ACTION_POINT_REMAIN, letter=(255, 219, 66), name='OCR_ACTION_POINT_REMAIN') diff --git a/module/research_farming/farming.py b/module/research_farming/farming.py index 92311586e..d3bba3532 100644 --- a/module/research_farming/farming.py +++ b/module/research_farming/farming.py @@ -1,5 +1,5 @@ from module.base.utils import color_bar_percentage -from module.config.utils import deep_get +from module.config.deep import deep_get from module.logger import logger from module.base.base import ModuleBase from module.ui.ui import UI diff --git a/module/retire/retirement.py b/module/retire/retirement.py index a7f9d65bb..7b0bc5ecb 100644 --- a/module/retire/retirement.py +++ b/module/retire/retirement.py @@ -2,7 +2,7 @@ from module.base.button import ButtonGrid from module.base.timer import Timer from module.base.utils import color_similar, get_color, resize from module.combat.assets import GET_ITEMS_1 -from module.config.utils import deep_get +from module.config.deep import deep_get from module.exception import RequestHumanTakeover, ScriptError from module.handler.assets import AUTO_SEARCH_MAP_OPTION_OFF, AUTO_SEARCH_MAP_OPTION_ON from module.logger import logger 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/ui/ui.py b/module/ui/ui.py index 8745292e6..42dda1b06 100644 --- a/module/ui/ui.py +++ b/module/ui/ui.py @@ -4,7 +4,7 @@ from module.base.button import Button from module.base.decorator import run_once from module.base.timer import Timer from module.combat.assets import GET_ITEMS_1, GET_ITEMS_2, GET_SHIP -from module.config.utils import deep_get +from module.config.deep import deep_get from module.exception import (GameNotRunningError, GamePageUnknownError, GameTooManyClickError) from module.exercise.assets import EXERCISE_PREPARATION diff --git a/module/webui/app.py b/module/webui/app.py index e3237e511..5537bd219 100644 --- a/module/webui/app.py +++ b/module/webui/app.py @@ -1,17 +1,17 @@ +import argparse import re import sys import json -import time import queue -import argparse 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 @@ -39,17 +39,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, @@ -1680,6 +1678,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') + global g_instance_watcher if g_instance_watcher is None: g_instance_watcher = threading.Thread(target=instance_watcher_thread) diff --git a/module/webui/config.py b/module/webui/config.py index 64e48c69f..d9e9a09d6 100644 --- a/module/webui/config.py +++ b/module/webui/config.py @@ -1,57 +1,10 @@ -import copy - -from filelock import FileLock - -from deploy.Windows.config import DeployConfig as _DeployConfig -from deploy.Windows.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) +from deploy.config import DeployConfig as _DeployConfig 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. 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/process_manager.py b/module/webui/process_manager.py index 027bd8822..028c9dcc8 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, \ @@ -35,6 +33,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: @@ -70,7 +69,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() 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/updater.py b/module/webui/updater.py index 5c60b2427..91a330f68 100644 --- a/module/webui/updater.py +++ b/module/webui/updater.py @@ -5,10 +5,10 @@ import time from typing import Generator, List, Tuple import requests -from deploy.Windows.config import ExecutionError -from deploy.Windows.git import GitManager -from deploy.Windows.pip import PipManager -from deploy.Windows.utils import DEPLOY_CONFIG +from deploy.config import ExecutionError +from deploy.git import GitManager +from deploy.pip import PipManager +from deploy.utils import DEPLOY_CONFIG from module.base.retry import retry from module.logger import logger from module.webui.config import DeployConfig 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..d7c35b341 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 * @@ -84,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 6e777ae3b..5281e9ec5 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 * @@ -93,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) diff --git a/submodule/AlasMaaBridge/module/handler/handler.py b/submodule/AlasMaaBridge/module/handler/handler.py index d36d0c853..45cb4dd32 100644 --- a/submodule/AlasMaaBridge/module/handler/handler.py +++ b/submodule/AlasMaaBridge/module/handler/handler.py @@ -12,7 +12,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