diff --git a/modules/config_reload.py b/modules/config_reload.py new file mode 100644 index 0000000..360a16b --- /dev/null +++ b/modules/config_reload.py @@ -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]) diff --git a/modules/core.py b/modules/core.py index 19b80d9..fb004d0 100644 --- a/modules/core.py +++ b/modules/core.py @@ -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.