mirror of
https://github.com/agessaman/meshcore-bot.git
synced 2026-10-23 22:32:29 +00:00
refactor(core): move reload_config's helpers into ConfigReloadMixin
_config_section_values, _restart_only_config_changes, _validate_config_snapshot, _command_config_state and _apply_command_config_state move verbatim (AST-identical) to modules/config_reload.py, mixed into MeshCoreBot. reload_config itself stays in core: the reload-rollback characterization tests patch modules.core.CommandManager, which it calls. _COMMAND_CONFIG_STATE stays on MeshCoreBot (annotated on the mixin for mypy). core.py 1,269 → 1,154 lines.
This commit is contained in:
1 parent
e6c9a74e71
commit
bdb59221da
2 files changed
+132
-117
No files matched your search
@@ -0,0 +1,130 @@
|
||||
"""Config reload helpers, mixed into MeshCoreBot.
|
||||
|
||||
What reload_config uses to check a new config before applying it: the sections that only take effect on restart, validation of the snapshot, and saving/restoring command config state for rollback. reload_config itself stays in core, where tests patch modules.core.CommandManager."""
|
||||
|
||||
import configparser
|
||||
from typing import Any
|
||||
|
||||
from .command_manager import CommandManager
|
||||
|
||||
|
||||
class ConfigReloadMixin:
|
||||
"""Mixed into MeshCoreBot."""
|
||||
|
||||
_COMMAND_CONFIG_STATE: Any
|
||||
|
||||
@staticmethod
|
||||
def _config_section_values(
|
||||
config: configparser.ConfigParser, section: str
|
||||
) -> dict[str, str]:
|
||||
if not config.has_section(section):
|
||||
return {}
|
||||
return dict(config.items(section, raw=True))
|
||||
|
||||
def _restart_only_config_changes(
|
||||
self,
|
||||
old_config: configparser.ConfigParser,
|
||||
new_config: configparser.ConfigParser,
|
||||
) -> list[str]:
|
||||
"""Return changed settings whose owning component is startup-only.
|
||||
|
||||
Service plugins commonly cache constructor settings. Until they expose
|
||||
a transactional reload contract, rejecting those edits is safer than a
|
||||
split state where on-demand reads see new values and cached fields do not.
|
||||
"""
|
||||
changed: list[str] = []
|
||||
# Built-in services may currently be disabled and therefore absent from
|
||||
# ``self.services``. Their enable flags and constructor-cached settings
|
||||
# are still restart-only; otherwise a successful reload would claim to
|
||||
# start a service that was never instantiated.
|
||||
startup_sections = {
|
||||
"Admin",
|
||||
"Connection",
|
||||
"DARC_MoWaS_Service",
|
||||
"DiscordBridge",
|
||||
"Earthquake_Service",
|
||||
"Feed_Manager",
|
||||
"Logging",
|
||||
"MapUploader",
|
||||
"MqttWeather",
|
||||
"PacketCapture",
|
||||
"RepeaterPrefixCollision_Service",
|
||||
"Service_Overrides",
|
||||
"TelegramBridge",
|
||||
"Weather_Service",
|
||||
"Webhook",
|
||||
"Web_Viewer",
|
||||
"Worldcup_Service",
|
||||
}
|
||||
for service in getattr(self, "services", {}).values():
|
||||
section = getattr(service, "config_section", None)
|
||||
if not section:
|
||||
derive = getattr(service, "_derive_config_section", None)
|
||||
if callable(derive):
|
||||
section = derive()
|
||||
if isinstance(section, str) and section:
|
||||
startup_sections.add(section)
|
||||
if service.__class__.__name__ == "WeatherService":
|
||||
# WeatherService also caches units from the shared [Weather]
|
||||
# section during construction.
|
||||
startup_sections.add("Weather")
|
||||
|
||||
for section in sorted(startup_sections):
|
||||
if self._config_section_values(old_config, section) != self._config_section_values(
|
||||
new_config, section
|
||||
):
|
||||
changed.append(f"[{section}]")
|
||||
|
||||
for key in ("db_path", "local_dir_path", "prefix_bytes"):
|
||||
old_value = old_config.get("Bot", key, fallback="")
|
||||
new_value = new_config.get("Bot", key, fallback="")
|
||||
if old_value != new_value:
|
||||
changed.append(f"[Bot] {key}")
|
||||
return changed
|
||||
|
||||
@staticmethod
|
||||
def _validate_config_snapshot(config: configparser.ConfigParser) -> None:
|
||||
"""Validate the fully merged candidate before it can be published."""
|
||||
from .config_schema import SECTIONS
|
||||
from .config_validation import REQUIRED_SECTIONS
|
||||
|
||||
missing = sorted(REQUIRED_SECTIONS - set(config.sections()))
|
||||
if missing:
|
||||
raise ValueError(
|
||||
"Missing required configuration section(s): " + ", ".join(missing)
|
||||
)
|
||||
|
||||
# Expand interpolation across every final value, not just the base file.
|
||||
# This catches malformed '%' expressions in an overlay before publish.
|
||||
for section in config.sections():
|
||||
list(config.items(section))
|
||||
|
||||
# Validate all typed keys currently covered by the project schema.
|
||||
for section, section_meta in SECTIONS.items():
|
||||
if not config.has_section(section):
|
||||
continue
|
||||
for key, meta in section_meta.keys.items():
|
||||
if not config.has_option(section, key):
|
||||
continue
|
||||
if meta.type == "int":
|
||||
config.getint(section, key)
|
||||
elif meta.type == "bool":
|
||||
config.getboolean(section, key)
|
||||
elif meta.type == "enum" and meta.values:
|
||||
value = config.get(section, key).strip().lower()
|
||||
if value not in meta.values:
|
||||
raise ValueError(
|
||||
f"[{section}] {key} must be one of "
|
||||
f"{', '.join(meta.values)} (got {value!r})"
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _command_config_state(cls, manager: CommandManager) -> dict[str, Any]:
|
||||
return {name: getattr(manager, name) for name in cls._COMMAND_CONFIG_STATE}
|
||||
|
||||
@classmethod
|
||||
def _apply_command_config_state(
|
||||
cls, manager: CommandManager, state: dict[str, Any]
|
||||
) -> None:
|
||||
for name in cls._COMMAND_CONFIG_STATE:
|
||||
setattr(manager, name, state[name])
|
||||
+2
-117
@@ -25,6 +25,7 @@ from meshcore import EventType
|
||||
from .admin_server import BotAdminServer
|
||||
from .channel_manager import ChannelManager
|
||||
from .command_manager import CommandManager
|
||||
from .config_reload import ConfigReloadMixin
|
||||
from .db_manager import AsyncDBManager, DBManager
|
||||
from .device_setup import DeviceSetupMixin
|
||||
from .feed_manager import FeedManager
|
||||
@@ -46,7 +47,7 @@ from .utils import resolve_path
|
||||
from .web_viewer.integration import WebViewerIntegration
|
||||
|
||||
|
||||
class MeshCoreBot(ServiceSupervisorMixin, RadioLinkMixin, RadioOfflineBreaker, DeviceSetupMixin):
|
||||
class MeshCoreBot(ServiceSupervisorMixin, RadioLinkMixin, RadioOfflineBreaker, DeviceSetupMixin, ConfigReloadMixin):
|
||||
"""MeshCore Bot using official meshcore package.
|
||||
|
||||
This class handles the core functionality of the bot, including connection management,
|
||||
@@ -507,111 +508,6 @@ class MeshCoreBot(ServiceSupervisorMixin, RadioLinkMixin, RadioOfflineBreaker, D
|
||||
)
|
||||
return available
|
||||
|
||||
@staticmethod
|
||||
def _config_section_values(
|
||||
config: configparser.ConfigParser, section: str
|
||||
) -> dict[str, str]:
|
||||
if not config.has_section(section):
|
||||
return {}
|
||||
return dict(config.items(section, raw=True))
|
||||
|
||||
def _restart_only_config_changes(
|
||||
self,
|
||||
old_config: configparser.ConfigParser,
|
||||
new_config: configparser.ConfigParser,
|
||||
) -> list[str]:
|
||||
"""Return changed settings whose owning component is startup-only.
|
||||
|
||||
Service plugins commonly cache constructor settings. Until they expose
|
||||
a transactional reload contract, rejecting those edits is safer than a
|
||||
split state where on-demand reads see new values and cached fields do not.
|
||||
"""
|
||||
changed: list[str] = []
|
||||
# Built-in services may currently be disabled and therefore absent from
|
||||
# ``self.services``. Their enable flags and constructor-cached settings
|
||||
# are still restart-only; otherwise a successful reload would claim to
|
||||
# start a service that was never instantiated.
|
||||
startup_sections = {
|
||||
"Admin",
|
||||
"Connection",
|
||||
"DARC_MoWaS_Service",
|
||||
"DiscordBridge",
|
||||
"Earthquake_Service",
|
||||
"Feed_Manager",
|
||||
"Logging",
|
||||
"MapUploader",
|
||||
"MqttWeather",
|
||||
"PacketCapture",
|
||||
"RepeaterPrefixCollision_Service",
|
||||
"Service_Overrides",
|
||||
"TelegramBridge",
|
||||
"Weather_Service",
|
||||
"Webhook",
|
||||
"Web_Viewer",
|
||||
"Worldcup_Service",
|
||||
}
|
||||
for service in getattr(self, "services", {}).values():
|
||||
section = getattr(service, "config_section", None)
|
||||
if not section:
|
||||
derive = getattr(service, "_derive_config_section", None)
|
||||
if callable(derive):
|
||||
section = derive()
|
||||
if isinstance(section, str) and section:
|
||||
startup_sections.add(section)
|
||||
if service.__class__.__name__ == "WeatherService":
|
||||
# WeatherService also caches units from the shared [Weather]
|
||||
# section during construction.
|
||||
startup_sections.add("Weather")
|
||||
|
||||
for section in sorted(startup_sections):
|
||||
if self._config_section_values(old_config, section) != self._config_section_values(
|
||||
new_config, section
|
||||
):
|
||||
changed.append(f"[{section}]")
|
||||
|
||||
for key in ("db_path", "local_dir_path", "prefix_bytes"):
|
||||
old_value = old_config.get("Bot", key, fallback="")
|
||||
new_value = new_config.get("Bot", key, fallback="")
|
||||
if old_value != new_value:
|
||||
changed.append(f"[Bot] {key}")
|
||||
return changed
|
||||
|
||||
@staticmethod
|
||||
def _validate_config_snapshot(config: configparser.ConfigParser) -> None:
|
||||
"""Validate the fully merged candidate before it can be published."""
|
||||
from .config_schema import SECTIONS
|
||||
from .config_validation import REQUIRED_SECTIONS
|
||||
|
||||
missing = sorted(REQUIRED_SECTIONS - set(config.sections()))
|
||||
if missing:
|
||||
raise ValueError(
|
||||
"Missing required configuration section(s): " + ", ".join(missing)
|
||||
)
|
||||
|
||||
# Expand interpolation across every final value, not just the base file.
|
||||
# This catches malformed '%' expressions in an overlay before publish.
|
||||
for section in config.sections():
|
||||
list(config.items(section))
|
||||
|
||||
# Validate all typed keys currently covered by the project schema.
|
||||
for section, section_meta in SECTIONS.items():
|
||||
if not config.has_section(section):
|
||||
continue
|
||||
for key, meta in section_meta.keys.items():
|
||||
if not config.has_option(section, key):
|
||||
continue
|
||||
if meta.type == "int":
|
||||
config.getint(section, key)
|
||||
elif meta.type == "bool":
|
||||
config.getboolean(section, key)
|
||||
elif meta.type == "enum" and meta.values:
|
||||
value = config.get(section, key).strip().lower()
|
||||
if value not in meta.values:
|
||||
raise ValueError(
|
||||
f"[{section}] {key} must be one of "
|
||||
f"{', '.join(meta.values)} (got {value!r})"
|
||||
)
|
||||
|
||||
_COMMAND_CONFIG_STATE = (
|
||||
"keywords",
|
||||
"custom_syntax",
|
||||
@@ -627,17 +523,6 @@ class MeshCoreBot(ServiceSupervisorMixin, RadioLinkMixin, RadioOfflineBreaker, D
|
||||
"commands",
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _command_config_state(cls, manager: CommandManager) -> dict[str, Any]:
|
||||
return {name: getattr(manager, name) for name in cls._COMMAND_CONFIG_STATE}
|
||||
|
||||
@classmethod
|
||||
def _apply_command_config_state(
|
||||
cls, manager: CommandManager, state: dict[str, Any]
|
||||
) -> None:
|
||||
for name in cls._COMMAND_CONFIG_STATE:
|
||||
setattr(manager, name, state[name])
|
||||
|
||||
def reload_config(self) -> tuple[bool, str]:
|
||||
"""Reload configuration from file without restarting the bot.
|
||||
|
||||
|
||||
Reference in new issue
Block a user