From 802b4618685469602994b9c78fc7474dc376bb68 Mon Sep 17 00:00:00 2001 From: chrisoro Date: Wed, 17 Jun 2026 19:07:43 +0200 Subject: [PATCH] fix: apply log level changes without restart Live log-level updates lived only in ScriptHandler, registered after the backend's wait-for-D4 loop. With D4 not running the listener never registered, so setting changes were dropped until restart. Move log-level handling to UnifiedMainWindow, which exists at startup regardless of D4. Extract reload-group helpers into src/config/reload_groups.py and a testable apply_log_level() in logger.py. Closes #768 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/config/reload_groups.py | 52 +++++++++++++++++++++ src/gui/unified_window.py | 14 +++++- src/logger.py | 21 +++++++++ src/scripts/handler.py | 75 ++++-------------------------- tests/config/reload_groups_test.py | 27 +++++++++++ tests/logger_test.py | 59 +++++++++++++++++++++++ 6 files changed, 182 insertions(+), 66 deletions(-) create mode 100644 src/config/reload_groups.py create mode 100644 tests/config/reload_groups_test.py create mode 100644 tests/logger_test.py diff --git a/src/config/reload_groups.py b/src/config/reload_groups.py new file mode 100644 index 00000000..eb4e3f0f --- /dev/null +++ b/src/config/reload_groups.py @@ -0,0 +1,52 @@ +"""Setting-key collections for live-reload handling. + +These helpers translate the live-reload metadata declared on settings models into +the flat ``section.field`` keys emitted by config change events, so multiple parts of +the app (the script handler, the main window) can react to the same setting changes. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from src.config.settings_models import IS_HOTKEY_KEY, LIVE_RELOAD_GROUP_KEY, AdvancedOptionsModel, GeneralModel + +if TYPE_CHECKING: + from collections.abc import Set as AbstractSet + + +def _setting_key(section: str, field_name: str) -> str: + return f"{section}.{field_name}" + + +def _field_metadata(model_class: type[Any], field_name: str) -> dict[str, Any]: + return model_class.model_fields[field_name].json_schema_extra or {} + + +def _collect_reload_group_keys(section: str, model_class: type[Any], group_name: str) -> set[str]: + return { + _setting_key(section, field_name) + for field_name in model_class.model_fields + if _field_metadata(model_class, field_name).get(LIVE_RELOAD_GROUP_KEY) == group_name + } + + +def _collect_hotkey_setting_keys() -> set[str]: + hotkey_keys = { + _setting_key("advanced_options", field_name) + for field_name in AdvancedOptionsModel.model_fields + if _field_metadata(AdvancedOptionsModel, field_name).get(IS_HOTKEY_KEY) == "True" + } + hotkey_keys.update(_collect_reload_group_keys("advanced_options", AdvancedOptionsModel, "hotkeys")) + return hotkey_keys + + +def has_any_changed(changed_keys: AbstractSet[str], relevant_keys: set[str]) -> bool: + return any(key in changed_keys for key in relevant_keys) + + +HOTKEY_SETTING_KEYS = _collect_hotkey_setting_keys() +LANGUAGE_SETTING_KEYS = _collect_reload_group_keys("general", GeneralModel, "language") +LOG_LEVEL_SETTING_KEYS = _collect_reload_group_keys("advanced_options", AdvancedOptionsModel, "log_level") +MANUAL_RESTART_SETTING_KEYS = _collect_reload_group_keys("general", GeneralModel, "restart_app") +VISION_MODE_TYPE_SETTING_KEY = _setting_key("general", "vision_mode_type") diff --git a/src/gui/unified_window.py b/src/gui/unified_window.py index 63e52732..52106b28 100644 --- a/src/gui/unified_window.py +++ b/src/gui/unified_window.py @@ -24,13 +24,14 @@ from src.autoupdater import notify_if_update from src.cam import Cam from src.config.loader import IniConfigLoader +from src.config.reload_groups import LOG_LEVEL_SETTING_KEYS, has_any_changed from src.gui.importer_window import ImporterWindow from src.gui.models.activity_log_widget import ActivityLogWidget from src.gui.profile_editor_window import ProfileEditorWindow from src.gui.settings_window import ConfigWindow from src.gui.themes import DARK_THEME_TEMPLATE, LIGHT_THEME_TEMPLATE from src.item.filter import Filter -from src.logger import ThreadNameFilter, create_formatter +from src.logger import ThreadNameFilter, apply_log_level, create_formatter from src.logger import setup as setup_logging from src.main import check_for_proper_tts_configuration from src.overlay import Overlay @@ -231,6 +232,17 @@ def _setup_logging(self): root_logger.addHandler(self.activity_handler) root_logger.setLevel(self._config.advanced_options.log_lvl.value.upper()) + # Apply log level changes live, independently of the backend's wait-for-D4 loop. + self._config.register_change_listener(self._on_config_changed_log_level) + + def _on_config_changed_log_level(self, changed_keys) -> None: + if not has_any_changed(changed_keys, LOG_LEVEL_SETTING_KEYS): + return + new_level = self._config.advanced_options.log_lvl.value.upper() + # Keep the activity log handler pinned to INFO to avoid dashboard clutter. + apply_log_level(new_level, skip_handler_names={"QT_ACTIVITY"}) + LOGGER.info("Updated log level to %s", new_level) + def _setup_ui(self): self.setWindowTitle(f"D4LF - Diablo 4 Loot Filter v{__version__}") self.setMinimumSize(800, 600) diff --git a/src/logger.py b/src/logger.py index 1af89f48..497b88e4 100644 --- a/src/logger.py +++ b/src/logger.py @@ -5,12 +5,16 @@ import logging.handlers import sys import threading +from typing import TYPE_CHECKING import colorama from src import __version__ from src.config import BASE_DIR +if TYPE_CHECKING: + from collections.abc import Container + logging.getLogger("httpcore").setLevel(logging.WARNING) logging.getLogger("httpx").setLevel(logging.WARNING) logging.getLogger("selenium").setLevel(logging.WARNING) @@ -72,6 +76,23 @@ def create_formatter(colored=False): return logging.Formatter(fmt) +def apply_log_level(log_level: str, *, skip_handler_names: Container[str] = ()) -> None: + """Apply a new log level to the root logger and its handlers at runtime. + + Args: + log_level: The new level name (case-insensitive), e.g. "DEBUG" or "INFO". + skip_handler_names: Names of handlers to leave untouched (e.g. the activity + log handler, which is pinned to INFO to avoid dashboard clutter). + """ + level = log_level.upper() + root_logger = logging.getLogger() + root_logger.setLevel(level) + for handler in root_logger.handlers: + if getattr(handler, "name", "") in skip_handler_names: + continue + handler.setLevel(level) + + def setup(log_level: str = "DEBUG", *, enable_stdout: bool = True) -> None: LOG_DIR.mkdir(exist_ok=True) diff --git a/src/scripts/handler.py b/src/scripts/handler.py index f6595108..1f376a9f 100644 --- a/src/scripts/handler.py +++ b/src/scripts/handler.py @@ -20,14 +20,14 @@ import src.tts from src.cam import Cam from src.config.loader import IniConfigLoader -from src.config.settings_models import ( - IS_HOTKEY_KEY, - LIVE_RELOAD_GROUP_KEY, - AdvancedOptionsModel, - GeneralModel, - ItemRefreshType, - VisionModeType, +from src.config.reload_groups import ( + HOTKEY_SETTING_KEYS, + LANGUAGE_SETTING_KEYS, + MANUAL_RESTART_SETTING_KEYS, + VISION_MODE_TYPE_SETTING_KEY, + has_any_changed, ) +from src.config.settings_models import ItemRefreshType, VisionModeType from src.dataloader import Dataloader from src.loot_mover import move_items_to_inventory, move_items_to_stash from src.paragon_overlay import request_close as request_close_paragon @@ -46,43 +46,6 @@ LOCK = threading.Lock() -def _setting_key(section: str, field_name: str) -> str: - return f"{section}.{field_name}" - - -def _field_metadata(model_class: type[Any], field_name: str) -> dict[str, Any]: - return model_class.model_fields[field_name].json_schema_extra or {} - - -def _collect_reload_group_keys(section: str, model_class: type[Any], group_name: str) -> set[str]: - return { - _setting_key(section, field_name) - for field_name in model_class.model_fields - if _field_metadata(model_class, field_name).get(LIVE_RELOAD_GROUP_KEY) == group_name - } - - -def _collect_hotkey_setting_keys() -> set[str]: - hotkey_keys = { - _setting_key("advanced_options", field_name) - for field_name in AdvancedOptionsModel.model_fields - if _field_metadata(AdvancedOptionsModel, field_name).get(IS_HOTKEY_KEY) == "True" - } - hotkey_keys.update(_collect_reload_group_keys("advanced_options", AdvancedOptionsModel, "hotkeys")) - return hotkey_keys - - -def _has_any_changed(changed_keys: AbstractSet[str], relevant_keys: set[str]) -> bool: - return any(key in changed_keys for key in relevant_keys) - - -HOTKEY_SETTING_KEYS = _collect_hotkey_setting_keys() -LANGUAGE_SETTING_KEYS = _collect_reload_group_keys("general", GeneralModel, "language") -LOG_LEVEL_SETTING_KEYS = _collect_reload_group_keys("advanced_options", AdvancedOptionsModel, "log_level") -MANUAL_RESTART_SETTING_KEYS = _collect_reload_group_keys("general", GeneralModel, "restart_app") -VISION_MODE_TYPE_SETTING_KEY = _setting_key("general", "vision_mode_type") - - class ScriptHandler: def __init__(self): self.loot_interaction_thread = None @@ -97,7 +60,6 @@ def __init__(self): self._config = IniConfigLoader() self._win_spec = WindowSpec(self._config.advanced_options.process_name) self._language = self._config.general.language - self._log_level = self._config.advanced_options.log_lvl.value.upper() self.vision_mode = self._create_vision_mode(self._config.general.vision_mode_type) # Initialize Info Overlay hooks and subscriptions @@ -119,15 +81,13 @@ def _graceful_exit(self): def _on_config_changed(self, changed_keys: AbstractSet[str]) -> None: """Apply relevant settings after a config change event.""" with self._runtime_config_lock: - if _has_any_changed(changed_keys, LOG_LEVEL_SETTING_KEYS): - self._refresh_logging_level(self._config) - if _has_any_changed(changed_keys, HOTKEY_SETTING_KEYS): + if has_any_changed(changed_keys, HOTKEY_SETTING_KEYS): self._refresh_hotkeys(self._config) - if _has_any_changed(changed_keys, LANGUAGE_SETTING_KEYS): + if has_any_changed(changed_keys, LANGUAGE_SETTING_KEYS): self._refresh_language_assets(self._config) if VISION_MODE_TYPE_SETTING_KEY in changed_keys: self._notify_manual_restart_required("vision mode changes") - elif _has_any_changed(changed_keys, MANUAL_RESTART_SETTING_KEYS): + elif has_any_changed(changed_keys, MANUAL_RESTART_SETTING_KEYS): self._notify_manual_restart_required("settings changes") def _hotkey_signature(self, config: IniConfigLoader) -> tuple[str | bool, ...]: @@ -166,21 +126,6 @@ def _refresh_language_assets(self, config: IniConfigLoader) -> None: self._language = config.general.language LOGGER.info("Reloaded language assets for %s", self._language) - def _refresh_logging_level(self, config: IniConfigLoader) -> None: - current_log_level = config.advanced_options.log_lvl.value.upper() - if current_log_level == self._log_level: - return - - root_logger = logging.getLogger() - root_logger.setLevel(current_log_level) - for handler in root_logger.handlers: - # Skip updating the activity log handler to avoid dashboard clutter - if getattr(handler, "name", "") == "QT_ACTIVITY": - continue - handler.setLevel(current_log_level) - self._log_level = current_log_level - LOGGER.info("Updated log level to %s", current_log_level) - def _notify_manual_restart_required(self, reason: str) -> None: if self._manual_restart_warning: return diff --git a/tests/config/reload_groups_test.py b/tests/config/reload_groups_test.py new file mode 100644 index 00000000..cf2bc6b2 --- /dev/null +++ b/tests/config/reload_groups_test.py @@ -0,0 +1,27 @@ +from src.config.reload_groups import ( + HOTKEY_SETTING_KEYS, + LOG_LEVEL_SETTING_KEYS, + VISION_MODE_TYPE_SETTING_KEY, + has_any_changed, +) + + +def test_log_level_setting_keys_resolves_to_log_lvl_field(): + assert {"advanced_options.log_lvl"} == LOG_LEVEL_SETTING_KEYS + + +def test_vision_mode_type_setting_key(): + assert VISION_MODE_TYPE_SETTING_KEY == "general.vision_mode_type" + + +def test_hotkey_setting_keys_are_namespaced_under_advanced_options(): + assert HOTKEY_SETTING_KEYS + assert all(key.startswith("advanced_options.") for key in HOTKEY_SETTING_KEYS) + + +def test_has_any_changed_detects_overlap(): + assert has_any_changed(frozenset({"advanced_options.log_lvl"}), LOG_LEVEL_SETTING_KEYS) + + +def test_has_any_changed_returns_false_without_overlap(): + assert not has_any_changed(frozenset({"general.language"}), LOG_LEVEL_SETTING_KEYS) diff --git a/tests/logger_test.py b/tests/logger_test.py new file mode 100644 index 00000000..dc44d6c9 --- /dev/null +++ b/tests/logger_test.py @@ -0,0 +1,59 @@ +import logging + +import pytest + +from src.logger import apply_log_level + + +@pytest.fixture +def isolated_root_logger(): + root_logger = logging.getLogger() + original_level = root_logger.level + original_handlers = root_logger.handlers[:] + root_logger.handlers = [] + try: + yield root_logger + finally: + root_logger.handlers = original_handlers + root_logger.setLevel(original_level) + + +def _make_handler(name: str, level: int) -> logging.Handler: + handler = logging.NullHandler() + handler.name = name + handler.setLevel(level) + return handler + + +def test_apply_log_level_updates_root_and_handlers(isolated_root_logger): + file_handler = _make_handler("D4LF_FILE", logging.INFO) + console_handler = _make_handler("QT_CONSOLE", logging.INFO) + isolated_root_logger.addHandler(file_handler) + isolated_root_logger.addHandler(console_handler) + + apply_log_level("debug") + + assert isolated_root_logger.level == logging.DEBUG + assert file_handler.level == logging.DEBUG + assert console_handler.level == logging.DEBUG + + +def test_apply_log_level_skips_named_handlers(isolated_root_logger): + console_handler = _make_handler("QT_CONSOLE", logging.DEBUG) + activity_handler = _make_handler("QT_ACTIVITY", logging.INFO) + isolated_root_logger.addHandler(console_handler) + isolated_root_logger.addHandler(activity_handler) + + apply_log_level("ERROR", skip_handler_names={"QT_ACTIVITY"}) + + assert console_handler.level == logging.ERROR + assert activity_handler.level == logging.INFO + + +def test_apply_log_level_is_case_insensitive(isolated_root_logger): + handler = _make_handler("D4LF_FILE", logging.ERROR) + isolated_root_logger.addHandler(handler) + + apply_log_level("info") + + assert handler.level == logging.INFO