mirror of
https://github.com/AstrBotDevs/AstrBot.git
synced 2026-09-21 06:06:10 +08:00
Feat: Allow adjust plugin log levels (#9342)
* feat(logging): add isolated plugin loggers * feat(dashboard): configure plugin log levels * fix(logging): support legacy plugin loggers * fix(dashboard): sync plugin log level response * perf(logging): avoid rescanning plugin handlers * fix(logging): persist plugin levels atomically * test(logging): use valid logger level mocks
This commit is contained in:
+65
-1
@@ -1,4 +1,6 @@
|
|||||||
from astrbot import logger
|
import logging
|
||||||
|
import sys
|
||||||
|
|
||||||
from astrbot.core import html_renderer, sp
|
from astrbot.core import html_renderer, sp
|
||||||
from astrbot.core.agent.tool import FunctionTool, ToolSet
|
from astrbot.core.agent.tool import FunctionTool, ToolSet
|
||||||
from astrbot.core.agent.tool_executor import BaseFunctionToolExecutor
|
from astrbot.core.agent.tool_executor import BaseFunctionToolExecutor
|
||||||
@@ -6,6 +8,68 @@ from astrbot.core.config.astrbot_config import AstrBotConfig
|
|||||||
from astrbot.core.star.register import register_agent as agent
|
from astrbot.core.star.register import register_agent as agent
|
||||||
from astrbot.core.star.register import register_llm_tool as llm_tool
|
from astrbot.core.star.register import register_llm_tool as llm_tool
|
||||||
|
|
||||||
|
_fallback_logger = logging.getLogger("astrbot")
|
||||||
|
_logger_cache: dict[
|
||||||
|
str,
|
||||||
|
tuple[str | None, str | None, logging.Logger],
|
||||||
|
] = {}
|
||||||
|
|
||||||
|
# Caller modules under these roots may belong to plugins that are not
|
||||||
|
# registered yet, so resolution failures for them are never cached.
|
||||||
|
_PLUGIN_MODULE_ROOTS = ("data.plugins.", "astrbot.builtin_stars.")
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_caller_logger(module_name: str) -> logging.Logger:
|
||||||
|
"""Resolve the dedicated plugin logger for a caller module.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
module_name: The ``__name__`` of the module that called the logger.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The plugin's dedicated logger, or the global ``astrbot`` logger when
|
||||||
|
the caller does not belong to a registered plugin.
|
||||||
|
"""
|
||||||
|
# Imported lazily to avoid a circular import with astrbot.core.star.
|
||||||
|
from astrbot.core.log import LogManager
|
||||||
|
from astrbot.core.star.star import star_map
|
||||||
|
|
||||||
|
cached = _logger_cache.get(module_name)
|
||||||
|
if cached is not None:
|
||||||
|
module_path, plugin_name, cached_logger = cached
|
||||||
|
if module_path is None:
|
||||||
|
return cached_logger
|
||||||
|
metadata = star_map.get(module_path)
|
||||||
|
if metadata is not None and metadata.name == plugin_name:
|
||||||
|
return cached_logger
|
||||||
|
_logger_cache.pop(module_name, None)
|
||||||
|
|
||||||
|
for module_path, metadata in star_map.items():
|
||||||
|
if not module_path or not metadata.name:
|
||||||
|
continue
|
||||||
|
package = module_path.rpartition(".")[0]
|
||||||
|
if module_name == module_path or module_name.startswith(package + "."):
|
||||||
|
resolved = LogManager.get_plugin_logger(metadata.name)
|
||||||
|
_logger_cache[module_name] = (module_path, metadata.name, resolved)
|
||||||
|
return resolved
|
||||||
|
|
||||||
|
if not module_name.startswith(_PLUGIN_MODULE_ROOTS):
|
||||||
|
_logger_cache[module_name] = (None, None, _fallback_logger)
|
||||||
|
return _fallback_logger
|
||||||
|
|
||||||
|
|
||||||
|
class _PluginContextLogger:
|
||||||
|
"""Proxy routing ``astrbot.api.logger`` calls to the caller plugin's logger."""
|
||||||
|
|
||||||
|
def __getattr__(self, item: str):
|
||||||
|
module_name = sys._getframe(1).f_globals.get("__name__", "")
|
||||||
|
return getattr(_resolve_caller_logger(module_name), item)
|
||||||
|
|
||||||
|
|
||||||
|
logger = _PluginContextLogger()
|
||||||
|
"""Plugin-facing logger. Calls are routed to the calling plugin's dedicated
|
||||||
|
logger (``astrbot.plugin.<plugin_name>``) so each plugin's log level can be
|
||||||
|
tuned independently; non-plugin callers fall back to the global logger."""
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"AstrBotConfig",
|
"AstrBotConfig",
|
||||||
"BaseFunctionToolExecutor",
|
"BaseFunctionToolExecutor",
|
||||||
|
|||||||
+169
-17
@@ -1,21 +1,33 @@
|
|||||||
"""日志系统,统一将标准 logging 输出转发到 loguru。"""
|
"""日志系统,统一将标准 logging 输出转发到 loguru。"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
import tempfile
|
||||||
import time
|
import time
|
||||||
from asyncio import Queue
|
from asyncio import Queue
|
||||||
from collections import deque
|
from collections import deque
|
||||||
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from loguru import logger as _raw_loguru_logger
|
from loguru import logger as _raw_loguru_logger
|
||||||
|
|
||||||
from astrbot.core.config.default import VERSION
|
from astrbot.core.config.default import VERSION
|
||||||
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
|
from astrbot.core.utils.astrbot_path import (
|
||||||
|
get_astrbot_config_path,
|
||||||
|
get_astrbot_data_path,
|
||||||
|
)
|
||||||
|
|
||||||
CACHED_SIZE = 500
|
CACHED_SIZE = 500
|
||||||
|
|
||||||
|
PLUGIN_LOGGER_PREFIX = "astrbot.plugin."
|
||||||
|
"""Prefix of per-plugin logger names; full name is ``astrbot.plugin.<plugin_name>``."""
|
||||||
|
|
||||||
|
PLUGIN_LOG_LEVELS = ("DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL")
|
||||||
|
"""Allowed per-plugin log levels."""
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from loguru import Record
|
from loguru import Record
|
||||||
|
|
||||||
@@ -24,7 +36,13 @@ class _RecordEnricherFilter(logging.Filter):
|
|||||||
"""为 logging.LogRecord 注入 AstrBot 日志字段。"""
|
"""为 logging.LogRecord 注入 AstrBot 日志字段。"""
|
||||||
|
|
||||||
def filter(self, record: logging.LogRecord) -> bool:
|
def filter(self, record: logging.LogRecord) -> bool:
|
||||||
record.plugin_tag = "[Plug]" if _is_plugin_path(record.pathname) else "[Core]"
|
if record.name.startswith(PLUGIN_LOGGER_PREFIX):
|
||||||
|
# Records from a per-plugin logger are tagged with the plugin name.
|
||||||
|
record.plugin_tag = f"[{record.name[len(PLUGIN_LOGGER_PREFIX) :]}]"
|
||||||
|
else:
|
||||||
|
record.plugin_tag = (
|
||||||
|
"[Plug]" if _is_plugin_path(record.pathname) else "[Core]"
|
||||||
|
)
|
||||||
record.short_levelname = _get_short_level_name(record.levelname)
|
record.short_levelname = _get_short_level_name(record.levelname)
|
||||||
record.astrbot_version_tag = (
|
record.astrbot_version_tag = (
|
||||||
f" [v{VERSION}]" if record.levelno >= logging.WARNING else ""
|
f" [v{VERSION}]" if record.levelno >= logging.WARNING else ""
|
||||||
@@ -173,6 +191,9 @@ class LogManager:
|
|||||||
_console_sink_id: int | None = None
|
_console_sink_id: int | None = None
|
||||||
_file_sink_id: int | None = None
|
_file_sink_id: int | None = None
|
||||||
_trace_sink_id: int | None = None
|
_trace_sink_id: int | None = None
|
||||||
|
_plugin_logger_names: set[str] = set()
|
||||||
|
_plugin_level_overrides: dict[str, str] | None = None
|
||||||
|
_log_broker: "LogBroker | None" = None
|
||||||
_NOISY_LOGGER_LEVELS: dict[str, int] = {
|
_NOISY_LOGGER_LEVELS: dict[str, int] = {
|
||||||
"aiosqlite": logging.WARNING,
|
"aiosqlite": logging.WARNING,
|
||||||
"filelock": logging.WARNING,
|
"filelock": logging.WARNING,
|
||||||
@@ -262,25 +283,147 @@ class LogManager:
|
|||||||
logger.propagate = False
|
logger.propagate = False
|
||||||
return logger
|
return logger
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _plugin_log_levels_path(cls) -> Path:
|
||||||
|
return Path(get_astrbot_config_path()) / "plugin_log_levels.json"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _load_plugin_level_overrides(cls) -> dict[str, str]:
|
||||||
|
"""Lazily load persisted per-plugin log level overrides from disk."""
|
||||||
|
if cls._plugin_level_overrides is None:
|
||||||
|
cls._plugin_level_overrides = {}
|
||||||
|
try:
|
||||||
|
with cls._plugin_log_levels_path().open(encoding="utf-8") as f:
|
||||||
|
data = json.load(f)
|
||||||
|
if isinstance(data, dict):
|
||||||
|
cls._plugin_level_overrides = {
|
||||||
|
str(name): str(level).upper()
|
||||||
|
for name, level in data.items()
|
||||||
|
if str(level).upper() in PLUGIN_LOG_LEVELS
|
||||||
|
}
|
||||||
|
except (OSError, ValueError):
|
||||||
|
pass
|
||||||
|
return cls._plugin_level_overrides
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_plugin_log_level(cls, plugin_name: str) -> str | None:
|
||||||
|
"""Get the log level override of a plugin.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
plugin_name: The plugin name.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The configured level name, or None if the plugin follows the global level.
|
||||||
|
"""
|
||||||
|
return cls._load_plugin_level_overrides().get(plugin_name)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def set_plugin_log_level(cls, plugin_name: str, level: str | None) -> None:
|
||||||
|
"""Persist and apply a per-plugin log level override.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
plugin_name: The plugin name.
|
||||||
|
level: The level name to apply, or None to follow the global level.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If the level name is not valid.
|
||||||
|
"""
|
||||||
|
overrides = dict(cls._load_plugin_level_overrides())
|
||||||
|
if level is None:
|
||||||
|
overrides.pop(plugin_name, None)
|
||||||
|
else:
|
||||||
|
level = level.upper()
|
||||||
|
if level not in PLUGIN_LOG_LEVELS:
|
||||||
|
raise ValueError(f"Invalid log level: {level}")
|
||||||
|
overrides[plugin_name] = level
|
||||||
|
|
||||||
|
config_path = cls._plugin_log_levels_path()
|
||||||
|
config_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
temp_path: Path | None = None
|
||||||
|
try:
|
||||||
|
with tempfile.NamedTemporaryFile(
|
||||||
|
"w",
|
||||||
|
encoding="utf-8",
|
||||||
|
dir=config_path.parent,
|
||||||
|
prefix=f".{config_path.name}.",
|
||||||
|
suffix=".tmp",
|
||||||
|
delete=False,
|
||||||
|
) as f:
|
||||||
|
temp_path = Path(f.name)
|
||||||
|
json.dump(overrides, f, indent=2)
|
||||||
|
f.flush()
|
||||||
|
os.fsync(f.fileno())
|
||||||
|
temp_path.replace(config_path)
|
||||||
|
except Exception:
|
||||||
|
if temp_path is not None:
|
||||||
|
temp_path.unlink(missing_ok=True)
|
||||||
|
raise
|
||||||
|
|
||||||
|
cls._plugin_level_overrides = overrides
|
||||||
|
|
||||||
|
if plugin_name in cls._plugin_logger_names:
|
||||||
|
logging.getLogger(f"{PLUGIN_LOGGER_PREFIX}{plugin_name}").setLevel(
|
||||||
|
cls._effective_plugin_log_level(plugin_name)
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _effective_plugin_log_level(cls, plugin_name: str) -> int:
|
||||||
|
override = cls.get_plugin_log_level(plugin_name)
|
||||||
|
if override:
|
||||||
|
return logging.getLevelName(override)
|
||||||
|
base_level = logging.getLogger("astrbot").level
|
||||||
|
return base_level if base_level > 0 else logging.INFO
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_plugin_logger(cls, plugin_name: str) -> logging.Logger:
|
||||||
|
"""Get or create the dedicated logger for a plugin.
|
||||||
|
|
||||||
|
The logger is isolated from the global ``astrbot`` logger so its level
|
||||||
|
can be tuned independently. Its level defaults to the persisted
|
||||||
|
per-plugin override, falling back to the current global level.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
plugin_name: The plugin name.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The plugin's dedicated logger.
|
||||||
|
"""
|
||||||
|
plugin_logger = cls.GetLogger(f"{PLUGIN_LOGGER_PREFIX}{plugin_name}")
|
||||||
|
if plugin_name not in cls._plugin_logger_names:
|
||||||
|
cls._plugin_logger_names.add(plugin_name)
|
||||||
|
if cls._log_broker is not None:
|
||||||
|
cls.set_queue_handler(plugin_logger, cls._log_broker)
|
||||||
|
# GetLogger() resets the level to DEBUG, so re-apply the effective level.
|
||||||
|
plugin_logger.setLevel(cls._effective_plugin_log_level(plugin_name))
|
||||||
|
return plugin_logger
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def set_queue_handler(cls, logger: logging.Logger, log_broker: LogBroker) -> None:
|
def set_queue_handler(cls, logger: logging.Logger, log_broker: LogBroker) -> None:
|
||||||
cls._ensure_logger_enricher_filter(logger)
|
cls._log_broker = log_broker
|
||||||
|
|
||||||
for handler in logger.handlers:
|
targets = [logger]
|
||||||
if isinstance(handler, LogQueueHandler):
|
if logger.name == "astrbot":
|
||||||
return
|
targets.extend(
|
||||||
|
logging.getLogger(f"{PLUGIN_LOGGER_PREFIX}{name}")
|
||||||
|
for name in cls._plugin_logger_names
|
||||||
|
)
|
||||||
|
for target in targets:
|
||||||
|
cls._ensure_logger_enricher_filter(target)
|
||||||
|
|
||||||
handler = LogQueueHandler(log_broker)
|
if any(isinstance(handler, LogQueueHandler) for handler in target.handlers):
|
||||||
handler.setLevel(logging.DEBUG)
|
continue
|
||||||
handler.addFilter(_QueueAnsiColorFilter())
|
|
||||||
handler.setFormatter(
|
handler = LogQueueHandler(log_broker)
|
||||||
logging.Formatter(
|
handler.setLevel(logging.DEBUG)
|
||||||
"%(ansi_prefix)s[%(asctime)s.%(msecs)03d] %(plugin_tag)s [%(short_levelname)s]%(astrbot_version_tag)s "
|
handler.addFilter(_QueueAnsiColorFilter())
|
||||||
"[%(source_file)s:%(source_line)d]: %(message)s%(ansi_reset)s",
|
handler.setFormatter(
|
||||||
datefmt="%Y-%m-%d %H:%M:%S",
|
logging.Formatter(
|
||||||
),
|
"%(ansi_prefix)s[%(asctime)s.%(msecs)03d] %(plugin_tag)s [%(short_levelname)s]%(astrbot_version_tag)s "
|
||||||
)
|
"[%(source_file)s:%(source_line)d]: %(message)s%(ansi_reset)s",
|
||||||
logger.addHandler(handler)
|
datefmt="%Y-%m-%d %H:%M:%S",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
target.addHandler(handler)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _remove_sink(cls, sink_id: int | None) -> None:
|
def _remove_sink(cls, sink_id: int | None) -> None:
|
||||||
@@ -353,6 +496,15 @@ class LogManager:
|
|||||||
except Exception:
|
except Exception:
|
||||||
logger.setLevel(logging.INFO)
|
logger.setLevel(logging.INFO)
|
||||||
|
|
||||||
|
# Plugin loggers without an explicit override follow the global level.
|
||||||
|
plugin_level = logger.level
|
||||||
|
overrides = cls._load_plugin_level_overrides()
|
||||||
|
for name in cls._plugin_logger_names:
|
||||||
|
if name not in overrides:
|
||||||
|
logging.getLogger(f"{PLUGIN_LOGGER_PREFIX}{name}").setLevel(
|
||||||
|
plugin_level
|
||||||
|
)
|
||||||
|
|
||||||
if "log_file" in config:
|
if "log_file" in config:
|
||||||
file_conf = config.get("log_file") or {}
|
file_conf = config.get("log_file") or {}
|
||||||
enable_file = bool(file_conf.get("enable", False))
|
enable_file = bool(file_conf.get("enable", False))
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import logging
|
|||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
from astrbot.core import html_renderer
|
from astrbot.core import html_renderer
|
||||||
|
from astrbot.core.log import LogManager
|
||||||
from astrbot.core.utils.command_parser import CommandParserMixin
|
from astrbot.core.utils.command_parser import CommandParserMixin
|
||||||
from astrbot.core.utils.plugin_kv_store import PluginKVStoreMixin
|
from astrbot.core.utils.plugin_kv_store import PluginKVStoreMixin
|
||||||
|
|
||||||
@@ -21,9 +22,34 @@ class Star(CommandParserMixin, PluginKVStoreMixin):
|
|||||||
author: str
|
author: str
|
||||||
name: str
|
name: str
|
||||||
context: Context
|
context: Context
|
||||||
|
logger: logging.Logger
|
||||||
|
"""The plugin's dedicated logger, isolated from the global ``astrbot`` logger."""
|
||||||
|
|
||||||
def __init__(self, context: Context, config: dict | None = None) -> None:
|
def __init__(self, context: Context, config: dict | None = None) -> None:
|
||||||
self.context = context
|
self.context = context
|
||||||
|
# Resolve the plugin name from the metadata registered for this module
|
||||||
|
# first (it matches the name the dashboard uses); the loader also
|
||||||
|
# injects a sanitized ``name`` class attribute as a fallback. When both
|
||||||
|
# are absent (e.g. direct instantiation in tests), fall back to the
|
||||||
|
# global logger.
|
||||||
|
metadata = star_map.get(self.__class__.__module__)
|
||||||
|
plugin_name = (metadata.name if metadata else None) or getattr(
|
||||||
|
self, "name", None
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
self.logger = (
|
||||||
|
LogManager.get_plugin_logger(plugin_name)
|
||||||
|
if plugin_name
|
||||||
|
else logging.getLogger("astrbot")
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
"Plugin %s log level: %s.",
|
||||||
|
plugin_name or self.__class__.__name__,
|
||||||
|
logging.getLevelName(self.logger.getEffectiveLevel()),
|
||||||
|
)
|
||||||
|
except AttributeError:
|
||||||
|
# The plugin defines ``logger`` as a read-only property; keep its own.
|
||||||
|
pass
|
||||||
|
|
||||||
def _get_context_config(self) -> Any:
|
def _get_context_config(self) -> Any:
|
||||||
get_config = getattr(self.context, "get_config", None)
|
get_config = getattr(self.context, "get_config", None)
|
||||||
|
|||||||
@@ -10,12 +10,13 @@ from fastapi.responses import PlainTextResponse, Response
|
|||||||
|
|
||||||
from astrbot.api.web import PluginRequest, bind_request_context
|
from astrbot.api.web import PluginRequest, bind_request_context
|
||||||
from astrbot.core import logger
|
from astrbot.core import logger
|
||||||
|
from astrbot.core.log import LogManager
|
||||||
from astrbot.dashboard.asgi_runtime import (
|
from astrbot.dashboard.asgi_runtime import (
|
||||||
DashboardRequestState,
|
DashboardRequestState,
|
||||||
call_request_view,
|
call_request_view,
|
||||||
)
|
)
|
||||||
from astrbot.dashboard.async_utils import run_maybe_async
|
from astrbot.dashboard.async_utils import run_maybe_async
|
||||||
from astrbot.dashboard.responses import ok
|
from astrbot.dashboard.responses import error, ok
|
||||||
from astrbot.dashboard.schemas import (
|
from astrbot.dashboard.schemas import (
|
||||||
EnabledPatch,
|
EnabledPatch,
|
||||||
PluginByIdRequest,
|
PluginByIdRequest,
|
||||||
@@ -24,6 +25,7 @@ from astrbot.dashboard.schemas import (
|
|||||||
PluginConfigUpdateRequest,
|
PluginConfigUpdateRequest,
|
||||||
PluginEnabledRequest,
|
PluginEnabledRequest,
|
||||||
PluginInstallRequest,
|
PluginInstallRequest,
|
||||||
|
PluginLogLevelPayload,
|
||||||
PluginSourceBindRequest,
|
PluginSourceBindRequest,
|
||||||
PluginSourceRequest,
|
PluginSourceRequest,
|
||||||
PluginUninstallRequest,
|
PluginUninstallRequest,
|
||||||
@@ -712,7 +714,13 @@ async def get_plugin_config_by_id(
|
|||||||
_auth: AuthContext = Depends(require_plugin_scope),
|
_auth: AuthContext = Depends(require_plugin_scope),
|
||||||
service: ConfigDisplayService = Depends(get_config_display_service),
|
service: ConfigDisplayService = Depends(get_config_display_service),
|
||||||
):
|
):
|
||||||
return ok({"plugin_name": plugin_id, **await service.get_configs(plugin_id)})
|
return ok(
|
||||||
|
{
|
||||||
|
"plugin_name": plugin_id,
|
||||||
|
"log_level": LogManager.get_plugin_log_level(plugin_id),
|
||||||
|
**await service.get_configs(plugin_id),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.put("/plugins/config")
|
@router.put("/plugins/config")
|
||||||
@@ -956,7 +964,30 @@ async def get_plugin_config(
|
|||||||
_auth: AuthContext = Depends(require_plugin_scope),
|
_auth: AuthContext = Depends(require_plugin_scope),
|
||||||
service: ConfigDisplayService = Depends(get_config_display_service),
|
service: ConfigDisplayService = Depends(get_config_display_service),
|
||||||
):
|
):
|
||||||
return ok({"plugin_name": plugin_id, **await service.get_configs(plugin_id)})
|
return ok(
|
||||||
|
{
|
||||||
|
"plugin_name": plugin_id,
|
||||||
|
"log_level": LogManager.get_plugin_log_level(plugin_id),
|
||||||
|
**await service.get_configs(plugin_id),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/plugins/{plugin_id}/log-level")
|
||||||
|
async def update_plugin_log_level(
|
||||||
|
plugin_id: str,
|
||||||
|
payload: PluginLogLevelPayload,
|
||||||
|
_auth: AuthContext = Depends(require_plugin_scope),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
LogManager.set_plugin_log_level(plugin_id, payload.level)
|
||||||
|
except ValueError as e:
|
||||||
|
return error(str(e))
|
||||||
|
level_desc = payload.level.upper() if payload.level else "global"
|
||||||
|
return ok(
|
||||||
|
message=f"Log level of plugin {plugin_id} set to {level_desc}.",
|
||||||
|
data={"log_level": LogManager.get_plugin_log_level(plugin_id)},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.put("/plugins/{plugin_id}/config")
|
@router.put("/plugins/{plugin_id}/config")
|
||||||
|
|||||||
@@ -589,6 +589,11 @@ class PluginConfigPayload(OpenModel):
|
|||||||
config: dict[str, Any] | None = None
|
config: dict[str, Any] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class PluginLogLevelPayload(OpenModel):
|
||||||
|
level: str | None = None
|
||||||
|
"""Log level name (DEBUG/INFO/WARNING/ERROR/CRITICAL), or null to follow the global level."""
|
||||||
|
|
||||||
|
|
||||||
class PluginSourceRequest(OpenModel):
|
class PluginSourceRequest(OpenModel):
|
||||||
id: str | None = None
|
id: str | None = None
|
||||||
name: str | None = None
|
name: str | None = None
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -1871,6 +1871,23 @@ export type UpdatePluginConfigResponse = (SuccessEnvelope);
|
|||||||
|
|
||||||
export type UpdatePluginConfigError = unknown;
|
export type UpdatePluginConfigError = unknown;
|
||||||
|
|
||||||
|
export type UpdatePluginLogLevelData = {
|
||||||
|
body: {
|
||||||
|
/**
|
||||||
|
* Log level name, or null to follow the global level.
|
||||||
|
*/
|
||||||
|
level?: ('DEBUG' | 'INFO' | 'WARNING' | 'ERROR' | 'CRITICAL') | null;
|
||||||
|
[key: string]: unknown | string;
|
||||||
|
};
|
||||||
|
path: {
|
||||||
|
plugin_id: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UpdatePluginLogLevelResponse = (SuccessEnvelope);
|
||||||
|
|
||||||
|
export type UpdatePluginLogLevelError = unknown;
|
||||||
|
|
||||||
export type GetPluginConfigSchemaData = {
|
export type GetPluginConfigSchemaData = {
|
||||||
path: {
|
path: {
|
||||||
plugin_id: string;
|
plugin_id: string;
|
||||||
|
|||||||
@@ -1259,6 +1259,17 @@ export const pluginApi = {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
updateLogLevel(
|
||||||
|
pluginId: string,
|
||||||
|
level: "DEBUG" | "INFO" | "WARNING" | "ERROR" | "CRITICAL" | null,
|
||||||
|
) {
|
||||||
|
return typed<OpenConfig>(
|
||||||
|
openApiV1.updatePluginLogLevel({
|
||||||
|
path: { plugin_id: pluginId },
|
||||||
|
body: { level },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
},
|
||||||
listConfigFiles(pluginId: string, configKey: string) {
|
listConfigFiles(pluginId: string, configKey: string) {
|
||||||
return typed<any>(
|
return typed<any>(
|
||||||
openApiV1.listPluginConfigFilesById({
|
openApiV1.listPluginConfigFilesById({
|
||||||
|
|||||||
@@ -196,7 +196,12 @@
|
|||||||
},
|
},
|
||||||
"config": {
|
"config": {
|
||||||
"title": "Extension Configuration",
|
"title": "Extension Configuration",
|
||||||
"noConfig": "This extension has no configuration"
|
"noConfig": "This extension has no additional configuration",
|
||||||
|
"coreSettings": {
|
||||||
|
"logLevel": "Log Level",
|
||||||
|
"logLevelHint": "Only affects this plugin's log output. Takes effect immediately, no restart required.",
|
||||||
|
"followGlobal": "Follow Global"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"loading": {
|
"loading": {
|
||||||
"title": "Loading...",
|
"title": "Loading...",
|
||||||
@@ -264,6 +269,7 @@
|
|||||||
"refreshSuccess": "Extension list refreshed!",
|
"refreshSuccess": "Extension list refreshed!",
|
||||||
"refreshFailed": "Error occurred while refreshing extension list",
|
"refreshFailed": "Error occurred while refreshing extension list",
|
||||||
"operationFailed": "Operation failed",
|
"operationFailed": "Operation failed",
|
||||||
|
"logLevelUpdated": "Log level updated and applied immediately",
|
||||||
"reloadSuccess": "Reload successful",
|
"reloadSuccess": "Reload successful",
|
||||||
"reloadFailed": "Reload failed",
|
"reloadFailed": "Reload failed",
|
||||||
"updateSuccess": "Update successful!",
|
"updateSuccess": "Update successful!",
|
||||||
|
|||||||
@@ -195,7 +195,12 @@
|
|||||||
},
|
},
|
||||||
"config": {
|
"config": {
|
||||||
"title": "Настройка плагина",
|
"title": "Настройка плагина",
|
||||||
"noConfig": "У этого плагина нет настраиваемых параметров"
|
"noConfig": "У этого плагина нет других настраиваемых параметров",
|
||||||
|
"coreSettings": {
|
||||||
|
"logLevel": "Уровень логирования",
|
||||||
|
"logLevelHint": "Влияет только на логи этого плагина. Применяется сразу, перезапуск не требуется.",
|
||||||
|
"followGlobal": "Как глобально"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"loading": {
|
"loading": {
|
||||||
"title": "Загрузка...",
|
"title": "Загрузка...",
|
||||||
@@ -263,6 +268,7 @@
|
|||||||
"refreshSuccess": "Список плагинов обновлен",
|
"refreshSuccess": "Список плагинов обновлен",
|
||||||
"refreshFailed": "Ошибка при обновлении списка",
|
"refreshFailed": "Ошибка при обновлении списка",
|
||||||
"operationFailed": "Ошибка операции",
|
"operationFailed": "Ошибка операции",
|
||||||
|
"logLevelUpdated": "Уровень логирования обновлён и применён сразу",
|
||||||
"reloadSuccess": "Перезагрузка завершена",
|
"reloadSuccess": "Перезагрузка завершена",
|
||||||
"reloadFailed": "Ошибка перезагрузки",
|
"reloadFailed": "Ошибка перезагрузки",
|
||||||
"updateSuccess": "Обновление завершено",
|
"updateSuccess": "Обновление завершено",
|
||||||
|
|||||||
@@ -196,7 +196,12 @@
|
|||||||
},
|
},
|
||||||
"config": {
|
"config": {
|
||||||
"title": "插件配置",
|
"title": "插件配置",
|
||||||
"noConfig": "这个插件没有配置"
|
"noConfig": "这个插件没有其他配置项",
|
||||||
|
"coreSettings": {
|
||||||
|
"logLevel": "日志级别",
|
||||||
|
"logLevelHint": "仅影响该插件的日志输出,立即生效,无需重启",
|
||||||
|
"followGlobal": "跟随全局"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"loading": {
|
"loading": {
|
||||||
"title": "加载中...",
|
"title": "加载中...",
|
||||||
@@ -264,6 +269,7 @@
|
|||||||
"refreshSuccess": "插件列表已刷新!",
|
"refreshSuccess": "插件列表已刷新!",
|
||||||
"refreshFailed": "刷新插件列表时发生错误",
|
"refreshFailed": "刷新插件列表时发生错误",
|
||||||
"operationFailed": "操作失败",
|
"operationFailed": "操作失败",
|
||||||
|
"logLevelUpdated": "日志级别已更新并实时生效",
|
||||||
"reloadSuccess": "重载成功",
|
"reloadSuccess": "重载成功",
|
||||||
"reloadFailed": "重载失败",
|
"reloadFailed": "重载失败",
|
||||||
"updateSuccess": "更新成功!",
|
"updateSuccess": "更新成功!",
|
||||||
|
|||||||
@@ -128,6 +128,8 @@ const {
|
|||||||
pluginOff,
|
pluginOff,
|
||||||
openExtensionConfig,
|
openExtensionConfig,
|
||||||
updateConfig,
|
updateConfig,
|
||||||
|
updatePluginLogLevel,
|
||||||
|
pluginLogLevelSaving,
|
||||||
showPluginInfo,
|
showPluginInfo,
|
||||||
reloadPlugin,
|
reloadPlugin,
|
||||||
viewReadme,
|
viewReadme,
|
||||||
@@ -171,6 +173,15 @@ const {
|
|||||||
searchDebounceTimer,
|
searchDebounceTimer,
|
||||||
} = pageState;
|
} = pageState;
|
||||||
|
|
||||||
|
const logLevelItems = computed(() => [
|
||||||
|
{ title: tm("dialogs.config.coreSettings.followGlobal"), value: null },
|
||||||
|
{ title: "DEBUG", value: "DEBUG" },
|
||||||
|
{ title: "INFO", value: "INFO" },
|
||||||
|
{ title: "WARNING", value: "WARNING" },
|
||||||
|
{ title: "ERROR", value: "ERROR" },
|
||||||
|
{ title: "CRITICAL", value: "CRITICAL" },
|
||||||
|
]);
|
||||||
|
|
||||||
const selectedPluginId = computed(() => {
|
const selectedPluginId = computed(() => {
|
||||||
const pluginId = route.params.pluginId;
|
const pluginId = route.params.pluginId;
|
||||||
return Array.isArray(pluginId) ? pluginId[0] : pluginId || "";
|
return Array.isArray(pluginId) ? pluginId[0] : pluginId || "";
|
||||||
@@ -431,6 +442,30 @@ const updateDialogPluginLogo = computed(() => {
|
|||||||
tm("dialogs.config.title")
|
tm("dialogs.config.title")
|
||||||
}}</v-card-title>
|
}}</v-card-title>
|
||||||
<v-card-text>
|
<v-card-text>
|
||||||
|
<div
|
||||||
|
class="d-flex align-center justify-space-between flex-wrap"
|
||||||
|
style="gap: 12px"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div class="text-subtitle-1 font-weight-medium">
|
||||||
|
{{ tm("dialogs.config.coreSettings.logLevel") }}
|
||||||
|
</div>
|
||||||
|
<div class="text-caption text-medium-emphasis">
|
||||||
|
{{ tm("dialogs.config.coreSettings.logLevelHint") }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<v-select
|
||||||
|
:model-value="extension_config.log_level"
|
||||||
|
:items="logLevelItems"
|
||||||
|
:loading="pluginLogLevelSaving"
|
||||||
|
density="compact"
|
||||||
|
variant="outlined"
|
||||||
|
hide-details
|
||||||
|
style="max-width: 220px; min-width: 180px"
|
||||||
|
@update:model-value="updatePluginLogLevel"
|
||||||
|
></v-select>
|
||||||
|
</div>
|
||||||
|
<v-divider class="my-4"></v-divider>
|
||||||
<div style="max-height: 60vh; overflow-y: auto; padding-right: 8px">
|
<div style="max-height: 60vh; overflow-y: auto; padding-right: 8px">
|
||||||
<AstrBotConfig
|
<AstrBotConfig
|
||||||
v-if="extension_config.metadata"
|
v-if="extension_config.metadata"
|
||||||
@@ -445,9 +480,13 @@ const updateDialogPluginLogo = computed(() => {
|
|||||||
</v-card-text>
|
</v-card-text>
|
||||||
<v-card-actions>
|
<v-card-actions>
|
||||||
<v-spacer></v-spacer>
|
<v-spacer></v-spacer>
|
||||||
<v-btn color="blue-darken-1" variant="text" @click="updateConfig">{{
|
<v-btn
|
||||||
tm("buttons.saveAndClose")
|
v-if="extension_config.metadata"
|
||||||
}}</v-btn>
|
color="blue-darken-1"
|
||||||
|
variant="text"
|
||||||
|
@click="updateConfig"
|
||||||
|
>{{ tm("buttons.saveAndClose") }}</v-btn
|
||||||
|
>
|
||||||
<v-btn
|
<v-btn
|
||||||
color="blue-darken-1"
|
color="blue-darken-1"
|
||||||
variant="text"
|
variant="text"
|
||||||
|
|||||||
@@ -94,7 +94,9 @@ export const useExtensionPage = () => {
|
|||||||
metadata: {},
|
metadata: {},
|
||||||
config: {},
|
config: {},
|
||||||
i18n: {},
|
i18n: {},
|
||||||
|
log_level: null,
|
||||||
});
|
});
|
||||||
|
const pluginLogLevelSaving = ref(false);
|
||||||
const pluginMarketData = ref([]);
|
const pluginMarketData = ref([]);
|
||||||
const loadingDialog = reactive({
|
const loadingDialog = reactive({
|
||||||
show: false,
|
show: false,
|
||||||
@@ -1084,16 +1086,58 @@ export const useExtensionPage = () => {
|
|||||||
curr_namespace.value = extension_name;
|
curr_namespace.value = extension_name;
|
||||||
currentConfigPlugin.value = extension_name;
|
currentConfigPlugin.value = extension_name;
|
||||||
configDialog.value = true;
|
configDialog.value = true;
|
||||||
|
extension_config.log_level = null;
|
||||||
try {
|
try {
|
||||||
const res = await pluginApi.config(extension_name);
|
const res = await pluginApi.config(extension_name);
|
||||||
|
// Discard the response if the user has already switched to another
|
||||||
|
// plugin's config dialog while the request was in flight.
|
||||||
|
if (curr_namespace.value !== extension_name) return;
|
||||||
extension_config.metadata = res.data.data.metadata;
|
extension_config.metadata = res.data.data.metadata;
|
||||||
extension_config.config = res.data.data.config;
|
extension_config.config = res.data.data.config;
|
||||||
extension_config.i18n = res.data.data.i18n || {};
|
extension_config.i18n = res.data.data.i18n || {};
|
||||||
|
extension_config.log_level = res.data.data.log_level ?? null;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast(err, "error");
|
toast(err, "error");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const updatePluginLogLevel = async (level) => {
|
||||||
|
if (pluginLogLevelSaving.value) return;
|
||||||
|
const pluginName = curr_namespace.value;
|
||||||
|
const previous = extension_config.log_level;
|
||||||
|
extension_config.log_level = level;
|
||||||
|
pluginLogLevelSaving.value = true;
|
||||||
|
try {
|
||||||
|
const res = await pluginApi.updateLogLevel(pluginName, level);
|
||||||
|
if (res.data.status === "ok") {
|
||||||
|
const serverLevel = res.data.data?.log_level;
|
||||||
|
// Preserve null as "follow global" and only skip synchronization
|
||||||
|
// when an older server does not return the field.
|
||||||
|
if (
|
||||||
|
curr_namespace.value === pluginName &&
|
||||||
|
serverLevel !== undefined
|
||||||
|
) {
|
||||||
|
extension_config.log_level = serverLevel;
|
||||||
|
}
|
||||||
|
toast(tm("messages.logLevelUpdated"), "success");
|
||||||
|
} else {
|
||||||
|
// Roll back the optimistic update, unless the dialog has already
|
||||||
|
// been switched to another plugin.
|
||||||
|
if (curr_namespace.value === pluginName) {
|
||||||
|
extension_config.log_level = previous;
|
||||||
|
}
|
||||||
|
toast(res.data.message || tm("messages.operationFailed"), "error");
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (curr_namespace.value === pluginName) {
|
||||||
|
extension_config.log_level = previous;
|
||||||
|
}
|
||||||
|
toast(err, "error");
|
||||||
|
} finally {
|
||||||
|
pluginLogLevelSaving.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const updateConfig = async () => {
|
const updateConfig = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await pluginApi.updateConfig(
|
const res = await pluginApi.updateConfig(
|
||||||
@@ -1110,6 +1154,7 @@ export const useExtensionPage = () => {
|
|||||||
extension_config.metadata = {};
|
extension_config.metadata = {};
|
||||||
extension_config.config = {};
|
extension_config.config = {};
|
||||||
extension_config.i18n = {};
|
extension_config.i18n = {};
|
||||||
|
extension_config.log_level = null;
|
||||||
getExtensions();
|
getExtensions();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast(err, "error");
|
toast(err, "error");
|
||||||
@@ -2465,6 +2510,8 @@ export const useExtensionPage = () => {
|
|||||||
pluginOff,
|
pluginOff,
|
||||||
openExtensionConfig,
|
openExtensionConfig,
|
||||||
updateConfig,
|
updateConfig,
|
||||||
|
updatePluginLogLevel,
|
||||||
|
pluginLogLevelSaving,
|
||||||
showPluginInfo,
|
showPluginInfo,
|
||||||
reloadPlugin,
|
reloadPlugin,
|
||||||
viewReadme,
|
viewReadme,
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ class MyPlugin(Star):
|
|||||||
3. 在 `__init__` 方法中会传入 `Context` 对象,这个对象包含了 AstrBot 的大多数组件
|
3. 在 `__init__` 方法中会传入 `Context` 对象,这个对象包含了 AstrBot 的大多数组件
|
||||||
4. 具体的处理函数 `Handler` 在插件类中定义,如这里的 `helloworld` 函数。
|
4. 具体的处理函数 `Handler` 在插件类中定义,如这里的 `helloworld` 函数。
|
||||||
5. 请务必使用 `from astrbot.api import logger` 来获取日志对象,而不是使用 `logging` 模块。
|
5. 请务必使用 `from astrbot.api import logger` 来获取日志对象,而不是使用 `logging` 模块。
|
||||||
|
6. 每个插件在 `__init__` 后会自动拥有独立的 `self.logger`。这个 logger 的等级可以在 WebUI 的插件配置弹窗中单独设置,不会影响其他插件和核心。也可以继续使用 `from astrbot.api import logger`,它会根据调用位置自动路由到当前插件的 logger。
|
||||||
|
|
||||||
> [!TIP]
|
> [!TIP]
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -2075,6 +2075,32 @@ paths:
|
|||||||
"200":
|
"200":
|
||||||
$ref: "#/components/responses/Ok"
|
$ref: "#/components/responses/Ok"
|
||||||
|
|
||||||
|
/api/v1/plugins/{plugin_id}/log-level:
|
||||||
|
put:
|
||||||
|
tags: [Plugins]
|
||||||
|
summary: Set plugin log level
|
||||||
|
description: Set the log level of a plugin. Pass null to follow the global log level.
|
||||||
|
operationId: updatePluginLogLevel
|
||||||
|
x-astrbot-scope: plugin
|
||||||
|
parameters:
|
||||||
|
- $ref: "#/components/parameters/PluginId"
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
level:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
enum: [DEBUG, INFO, WARNING, ERROR, CRITICAL, null]
|
||||||
|
description: Log level name, or null to follow the global level.
|
||||||
|
additionalProperties: true
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
$ref: "#/components/responses/Ok"
|
||||||
|
|
||||||
/api/v1/plugins/{plugin_id}/config/schema:
|
/api/v1/plugins/{plugin_id}/config/schema:
|
||||||
get:
|
get:
|
||||||
tags: [Plugins]
|
tags: [Plugins]
|
||||||
|
|||||||
@@ -2843,6 +2843,11 @@ async def test_v1_safe_plugin_routes_accept_slash_ids(
|
|||||||
params={"plugin_id": plugin_id},
|
params={"plugin_id": plugin_id},
|
||||||
headers=headers,
|
headers=headers,
|
||||||
)
|
)
|
||||||
|
config_response = await asgi_client.get(
|
||||||
|
"/api/v1/plugins/config",
|
||||||
|
params={"plugin_id": plugin_id},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
config_files_response = await asgi_client.get(
|
config_files_response = await asgi_client.get(
|
||||||
"/api/v1/plugins/config-files",
|
"/api/v1/plugins/config-files",
|
||||||
params={"plugin_id": plugin_id, "config_key": "assets/path"},
|
params={"plugin_id": plugin_id, "config_key": "assets/path"},
|
||||||
@@ -2863,6 +2868,12 @@ async def test_v1_safe_plugin_routes_accept_slash_ids(
|
|||||||
}
|
}
|
||||||
assert readme_response.status_code == 200
|
assert readme_response.status_code == 200
|
||||||
assert readme_response.json()["data"]["name"] == plugin_id
|
assert readme_response.json()["data"]["name"] == plugin_id
|
||||||
|
assert config_response.status_code == 200
|
||||||
|
assert config_response.json()["data"] == {
|
||||||
|
"plugin_name": plugin_id,
|
||||||
|
"log_level": None,
|
||||||
|
"schema": {"name": plugin_id},
|
||||||
|
}
|
||||||
assert schema_response.status_code == 200
|
assert schema_response.status_code == 200
|
||||||
assert schema_response.json()["data"]["plugin_name"] == plugin_id
|
assert schema_response.json()["data"]["plugin_name"] == plugin_id
|
||||||
assert config_files_response.status_code == 200
|
assert config_files_response.status_code == 200
|
||||||
|
|||||||
@@ -0,0 +1,228 @@
|
|||||||
|
"""Tests for the plugin-aware logger exposed by ``astrbot.api``."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
import astrbot.api as api
|
||||||
|
from astrbot.core.log import LogBroker, LogManager, LogQueueHandler
|
||||||
|
from astrbot.core.star import Star
|
||||||
|
from astrbot.core.star.star import StarMetadata, star_map
|
||||||
|
|
||||||
|
|
||||||
|
def test_plugin_logger_cache_refreshes_after_plugin_rename(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
"""Ensure a cached logger follows the latest plugin metadata name.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
monkeypatch: Pytest fixture used to isolate the plugin registry change.
|
||||||
|
"""
|
||||||
|
module_path = "data.plugins.cache_refresh.main"
|
||||||
|
caller_module = "data.plugins.cache_refresh.helpers"
|
||||||
|
monkeypatch.setattr(LogManager, "_plugin_logger_names", set())
|
||||||
|
monkeypatch.setattr(LogManager, "_plugin_level_overrides", {})
|
||||||
|
monkeypatch.setitem(
|
||||||
|
star_map,
|
||||||
|
module_path,
|
||||||
|
StarMetadata(name="old_name", module_path=module_path),
|
||||||
|
)
|
||||||
|
api._logger_cache.pop(caller_module, None)
|
||||||
|
|
||||||
|
try:
|
||||||
|
old_logger = api._resolve_caller_logger(caller_module)
|
||||||
|
star_map[module_path] = StarMetadata(
|
||||||
|
name="new_name",
|
||||||
|
module_path=module_path,
|
||||||
|
)
|
||||||
|
new_logger = api._resolve_caller_logger(caller_module)
|
||||||
|
|
||||||
|
assert old_logger.name == "astrbot.plugin.old_name"
|
||||||
|
assert new_logger.name == "astrbot.plugin.new_name"
|
||||||
|
finally:
|
||||||
|
api._logger_cache.pop(caller_module, None)
|
||||||
|
|
||||||
|
|
||||||
|
def test_global_level_sync_updates_plugin_loggers(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
"""Ensure global level syncing updates plugins without overrides.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
monkeypatch: Pytest fixture used to isolate LogManager class state.
|
||||||
|
"""
|
||||||
|
plugin_name = "mock_level_sync"
|
||||||
|
plugin_logger = logging.getLogger(f"astrbot.plugin.{plugin_name}")
|
||||||
|
previous_level = plugin_logger.level
|
||||||
|
global_logger = logging.Logger("global_level_sync")
|
||||||
|
monkeypatch.setattr(LogManager, "_plugin_logger_names", {plugin_name})
|
||||||
|
monkeypatch.setattr(LogManager, "_plugin_level_overrides", {})
|
||||||
|
|
||||||
|
try:
|
||||||
|
LogManager.configure_logger(global_logger, {"log_level": "WARNING"})
|
||||||
|
|
||||||
|
assert global_logger.level == logging.WARNING
|
||||||
|
assert plugin_logger.level == logging.WARNING
|
||||||
|
finally:
|
||||||
|
plugin_logger.setLevel(previous_level)
|
||||||
|
|
||||||
|
|
||||||
|
def test_legacy_plugin_without_super_uses_dedicated_logger(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
"""Ensure the historical API logger works without ``Star.__init__``.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
monkeypatch: Pytest fixture used to isolate logger and registry state.
|
||||||
|
"""
|
||||||
|
module_path = "data.plugins.legacy_logger.main"
|
||||||
|
namespace = {
|
||||||
|
"__name__": module_path,
|
||||||
|
"Star": Star,
|
||||||
|
"logger": api.logger,
|
||||||
|
}
|
||||||
|
exec(
|
||||||
|
"class LegacyPlugin(Star):\n"
|
||||||
|
" def __init__(self, context):\n"
|
||||||
|
" self.context = context\n"
|
||||||
|
" def logger_state(self):\n"
|
||||||
|
" return (\n"
|
||||||
|
" logger.name,\n"
|
||||||
|
" logger.getEffectiveLevel(),\n"
|
||||||
|
" logger.isEnabledFor(20),\n"
|
||||||
|
" logger.isEnabledFor(30),\n"
|
||||||
|
" )\n",
|
||||||
|
namespace,
|
||||||
|
)
|
||||||
|
star_map[module_path].name = "legacy_logger"
|
||||||
|
monkeypatch.setattr(LogManager, "_plugin_logger_names", set())
|
||||||
|
monkeypatch.setattr(LogManager, "_log_broker", None)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
LogManager,
|
||||||
|
"_plugin_level_overrides",
|
||||||
|
{"legacy_logger": "WARNING"},
|
||||||
|
)
|
||||||
|
api._logger_cache.pop(module_path, None)
|
||||||
|
|
||||||
|
try:
|
||||||
|
plugin = namespace["LegacyPlugin"](object())
|
||||||
|
|
||||||
|
assert plugin.logger_state() == (
|
||||||
|
"astrbot.plugin.legacy_logger",
|
||||||
|
logging.WARNING,
|
||||||
|
False,
|
||||||
|
True,
|
||||||
|
)
|
||||||
|
assert "legacy_logger" in LogManager._plugin_logger_names
|
||||||
|
finally:
|
||||||
|
api._logger_cache.pop(module_path, None)
|
||||||
|
|
||||||
|
|
||||||
|
def test_queue_handler_only_scans_plugins_for_global_logger(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
"""Ensure registering a plugin logger does not rescan every plugin.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
monkeypatch: Pytest fixture used to isolate LogManager class state.
|
||||||
|
"""
|
||||||
|
broker = LogBroker()
|
||||||
|
existing_name = "queue_handler_existing"
|
||||||
|
existing_logger = logging.getLogger(f"astrbot.plugin.{existing_name}")
|
||||||
|
previous_handlers = existing_logger.handlers.copy()
|
||||||
|
previous_filters = existing_logger.filters.copy()
|
||||||
|
existing_logger.handlers.clear()
|
||||||
|
existing_logger.filters.clear()
|
||||||
|
plugin_logger = logging.Logger("astrbot.plugin.queue_handler_new")
|
||||||
|
global_logger = logging.Logger("astrbot")
|
||||||
|
monkeypatch.setattr(LogManager, "_plugin_logger_names", {existing_name})
|
||||||
|
monkeypatch.setattr(LogManager, "_log_broker", None)
|
||||||
|
|
||||||
|
try:
|
||||||
|
LogManager.set_queue_handler(plugin_logger, broker)
|
||||||
|
|
||||||
|
assert any(
|
||||||
|
isinstance(handler, LogQueueHandler) for handler in plugin_logger.handlers
|
||||||
|
)
|
||||||
|
assert not any(
|
||||||
|
isinstance(handler, LogQueueHandler) for handler in existing_logger.handlers
|
||||||
|
)
|
||||||
|
|
||||||
|
LogManager.set_queue_handler(global_logger, broker)
|
||||||
|
|
||||||
|
assert any(
|
||||||
|
isinstance(handler, LogQueueHandler) for handler in global_logger.handlers
|
||||||
|
)
|
||||||
|
assert any(
|
||||||
|
isinstance(handler, LogQueueHandler) for handler in existing_logger.handlers
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
existing_logger.handlers[:] = previous_handlers
|
||||||
|
existing_logger.filters[:] = previous_filters
|
||||||
|
|
||||||
|
|
||||||
|
def test_plugin_log_level_is_persisted_atomically(
|
||||||
|
tmp_path: Path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
"""Ensure a plugin log-level update atomically replaces its config file.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tmp_path: Temporary directory for the persisted configuration.
|
||||||
|
monkeypatch: Pytest fixture used to isolate LogManager class state.
|
||||||
|
"""
|
||||||
|
config_path = tmp_path / "plugin_log_levels.json"
|
||||||
|
monkeypatch.setattr(
|
||||||
|
LogManager,
|
||||||
|
"_plugin_log_levels_path",
|
||||||
|
MagicMock(return_value=config_path),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(LogManager, "_plugin_level_overrides", {})
|
||||||
|
monkeypatch.setattr(LogManager, "_plugin_logger_names", set())
|
||||||
|
|
||||||
|
LogManager.set_plugin_log_level("atomic_plugin", "warning")
|
||||||
|
|
||||||
|
assert json.loads(config_path.read_text(encoding="utf-8")) == {
|
||||||
|
"atomic_plugin": "WARNING"
|
||||||
|
}
|
||||||
|
assert LogManager._plugin_level_overrides == {"atomic_plugin": "WARNING"}
|
||||||
|
assert not list(tmp_path.glob("*.tmp"))
|
||||||
|
|
||||||
|
|
||||||
|
def test_plugin_log_level_write_failure_preserves_previous_state(
|
||||||
|
tmp_path: Path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
"""Ensure a failed atomic replace preserves disk and memory state.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tmp_path: Temporary directory for the persisted configuration.
|
||||||
|
monkeypatch: Pytest fixture used to isolate LogManager class state.
|
||||||
|
"""
|
||||||
|
config_path = tmp_path / "plugin_log_levels.json"
|
||||||
|
config_path.write_text('{"existing_plugin": "INFO"}', encoding="utf-8")
|
||||||
|
previous_overrides = {"existing_plugin": "INFO"}
|
||||||
|
monkeypatch.setattr(
|
||||||
|
LogManager,
|
||||||
|
"_plugin_log_levels_path",
|
||||||
|
MagicMock(return_value=config_path),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
LogManager,
|
||||||
|
"_plugin_level_overrides",
|
||||||
|
previous_overrides,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(LogManager, "_plugin_logger_names", set())
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(Path, "replace", side_effect=OSError("replace failed")),
|
||||||
|
pytest.raises(OSError, match="replace failed"),
|
||||||
|
):
|
||||||
|
LogManager.set_plugin_log_level("new_plugin", "ERROR")
|
||||||
|
|
||||||
|
assert json.loads(config_path.read_text(encoding="utf-8")) == previous_overrides
|
||||||
|
assert LogManager._plugin_level_overrides is previous_overrides
|
||||||
|
assert not list(tmp_path.glob("*.tmp"))
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
"""Tests for AstrBotCoreLifecycle."""
|
"""Tests for AstrBotCoreLifecycle."""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import logging
|
||||||
import os
|
import os
|
||||||
from unittest.mock import AsyncMock, MagicMock, patch
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
@@ -609,6 +610,7 @@ class TestAstrBotCoreLifecycleInitialize:
|
|||||||
new_callable=AsyncMock,
|
new_callable=AsyncMock,
|
||||||
),
|
),
|
||||||
):
|
):
|
||||||
|
mock_logger.level = logging.INFO
|
||||||
# Should not raise, just log the error
|
# Should not raise, just log the error
|
||||||
await lifecycle.initialize()
|
await lifecycle.initialize()
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"""Tests for astrbot.core.star.base module."""
|
"""Tests for astrbot.core.star.base module."""
|
||||||
|
|
||||||
|
import logging
|
||||||
from unittest.mock import AsyncMock, MagicMock, patch
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -31,6 +32,33 @@ class TestStarBase:
|
|||||||
|
|
||||||
assert star.context is mock_context
|
assert star.context is mock_context
|
||||||
|
|
||||||
|
def test_star_init_logs_plugin_level(self):
|
||||||
|
"""Test that Star initialization reports the effective plugin level."""
|
||||||
|
from astrbot.core.star import Star
|
||||||
|
|
||||||
|
mock_context = MagicMock()
|
||||||
|
plugin_logger = MagicMock(spec=logging.Logger)
|
||||||
|
plugin_logger.getEffectiveLevel.return_value = logging.WARNING
|
||||||
|
|
||||||
|
class TestLevelStar(Star):
|
||||||
|
name = "test_level_star"
|
||||||
|
author = "test_author"
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"astrbot.core.star.base.LogManager.get_plugin_logger",
|
||||||
|
return_value=plugin_logger,
|
||||||
|
),
|
||||||
|
patch("astrbot.core.star.base.logger") as core_logger,
|
||||||
|
):
|
||||||
|
TestLevelStar(context=mock_context)
|
||||||
|
|
||||||
|
core_logger.info.assert_called_once_with(
|
||||||
|
"Plugin %s log level: %s.",
|
||||||
|
"test_level_star",
|
||||||
|
"WARNING",
|
||||||
|
)
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_text_to_image_with_config(self):
|
async def test_text_to_image_with_config(self):
|
||||||
"""Test text_to_image method with valid config."""
|
"""Test text_to_image method with valid config."""
|
||||||
|
|||||||
Reference in New Issue
Block a user