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:
agessaman committed 2026-10-03 14:27:59 -07:00
1 parent e6c9a74e71
commit bdb59221da
2 files changed
+132 -117

No files matched your search

+130
View File
@@ -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
View File
@@ -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.