Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions src/config/reload_groups.py
Original file line number Diff line number Diff line change
@@ -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")
14 changes: 13 additions & 1 deletion src/gui/unified_window.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
21 changes: 21 additions & 0 deletions src/logger.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)

Expand Down
75 changes: 10 additions & 65 deletions src/scripts/handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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, ...]:
Expand Down Expand Up @@ -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
Expand Down
27 changes: 27 additions & 0 deletions tests/config/reload_groups_test.py
Original file line number Diff line number Diff line change
@@ -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)
59 changes: 59 additions & 0 deletions tests/logger_test.py
Original file line number Diff line number Diff line change
@@ -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
Loading