From 0f3fb1a02dda2ca7079b970f35927eeaf4dce872 Mon Sep 17 00:00:00 2001 From: ff225 Date: Thu, 16 Jul 2026 09:34:52 +0200 Subject: [PATCH] Add Config singleton cache in src/common/config.py Config loading was scattered: several call sites (edge_initialization.py, log.py, run_edge.py) re-read and re-validated settings.yaml on every call instead of caching, unlike request_handler.py's ad-hoc _settings_cache. Config.get_server()/get_client() wrap the existing sciot.config loaders with a per-path cache, giving a single access point without touching the in-flight request_handler.py/http_server.py/message_data.py PRs. --- src/common/__init__.py | 0 src/common/config.py | 64 +++++++++++++++ src/server/edge/edge_initialization.py | 6 +- src/server/edge/run_edge.py | 4 +- src/server/logger/log.py | 4 +- tests/unit/test_common_config.py | 107 +++++++++++++++++++++++++ 6 files changed, 178 insertions(+), 7 deletions(-) create mode 100644 src/common/__init__.py create mode 100644 src/common/config.py create mode 100644 tests/unit/test_common_config.py diff --git a/src/common/__init__.py b/src/common/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/common/config.py b/src/common/config.py new file mode 100644 index 00000000..cfe882b3 --- /dev/null +++ b/src/common/config.py @@ -0,0 +1,64 @@ +"""Process-wide cached access to the server/client SCIoT configuration. + +``sciot.config`` already owns YAML loading, env-var overrides, and schema +validation; every call site previously invoked it directly and re-parsed + +re-validated the YAML file on every call (see ``request_handler.py``'s +``_get_settings`` for the one exception, which had its own ad-hoc cache). +``Config`` centralizes that caching behind a single access point so callers +don't each need their own cache. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any, Mapping + +from sciot.config import load_client_config, load_server_config + +_SRC_DIR = Path(__file__).resolve().parent.parent +DEFAULT_SERVER_CONFIG_PATH = _SRC_DIR / "server" / "settings.yaml" +DEFAULT_CLIENT_CONFIG_PATH = _SRC_DIR / "client" / "python" / "http_config.yaml" + + +class Config: + """Singleton-style cache around ``load_server_config``/``load_client_config``. + + Configs are cached per resolved path so repeated calls with the default + (or same explicit) path reuse the already-loaded, already-validated dict + instead of re-reading and re-validating the YAML file. + """ + + _server_cache: dict[Path, dict[str, Any]] = {} + _client_cache: dict[Path, dict[str, Any]] = {} + + @classmethod + def get_server( + cls, + path: str | Path | None = None, + *, + environ: Mapping[str, str] | None = None, + force_reload: bool = False, + ) -> dict[str, Any]: + resolved = Path(path or DEFAULT_SERVER_CONFIG_PATH).resolve() + if force_reload or resolved not in cls._server_cache: + cls._server_cache[resolved] = load_server_config(resolved, environ=environ) + return cls._server_cache[resolved] + + @classmethod + def get_client( + cls, + path: str | Path | None = None, + *, + environ: Mapping[str, str] | None = None, + force_reload: bool = False, + ) -> dict[str, Any]: + resolved = Path(path or DEFAULT_CLIENT_CONFIG_PATH).resolve() + if force_reload or resolved not in cls._client_cache: + cls._client_cache[resolved] = load_client_config(resolved, environ=environ) + return cls._client_cache[resolved] + + @classmethod + def reset(cls) -> None: + """Clear cached configs. Intended for tests.""" + cls._server_cache.clear() + cls._client_cache.clear() diff --git a/src/server/edge/edge_initialization.py b/src/server/edge/edge_initialization.py index 85fed06a..f73a3502 100644 --- a/src/server/edge/edge_initialization.py +++ b/src/server/edge/edge_initialization.py @@ -11,7 +11,7 @@ from server.models.model_manager import ModelManager from server.models.model_input_converter import ModelInputConverter from server.core.variance_detector import VarianceDetector -from sciot.config import load_server_config +from common.config import Config from pathlib import Path @@ -19,7 +19,7 @@ def load_delay_config(): """Load delay configuration from settings.yaml""" settings_path = Path(__file__).parent.parent / "settings.yaml" try: - settings = load_server_config(settings_path) + settings = Config.get_server(settings_path) return settings.get("delay_simulation", {}).get("edge_computation") except Exception as e: print(f"Warning: Could not load delay config: {e}") @@ -391,7 +391,7 @@ def initialization( if __name__ == "__main__": # Carichiamo il file settings.yaml settings_path = Path(__file__).parent.parent / "settings.yaml" - config = load_server_config(settings_path) + config = Config.get_server(settings_path) # Inizializza tutti i modelli disponibili for model_name, model_config in config["model"].items(): diff --git a/src/server/edge/run_edge.py b/src/server/edge/run_edge.py index 1bf07411..4f5c9f85 100644 --- a/src/server/edge/run_edge.py +++ b/src/server/edge/run_edge.py @@ -7,7 +7,7 @@ from paho.mqtt import client as mqtt from server.communication.request_handler import RequestHandler from server.commons import ConfigurationFiles -from sciot.config import load_server_config +from common.config import Config def initialize_models(config: dict) -> None: @@ -97,7 +97,7 @@ def create_enabled_transports( def main(config_path: str | None = None) -> int: configure_logger_from_settings() logger.info("Starting the [EDGE] communication transports") - config = load_server_config( + config = Config.get_server( config_path or ConfigurationFiles.server_configuration_file_path ) diff --git a/src/server/logger/log.py b/src/server/logger/log.py index 515c31d5..fd1bda93 100644 --- a/src/server/logger/log.py +++ b/src/server/logger/log.py @@ -15,7 +15,7 @@ import sys from server.logger.log_config import LoggerConfig -from sciot.config import load_server_config +from common.config import Config (CUSTOM_LOG_FOLDER, DEBUG_LOG_LEVEL, ERROR_LOG_LEVEL, LOG_HISTORY_DAYS_LIMIT) = ( LoggerConfig.LOG_FOLDER, @@ -104,7 +104,7 @@ def configure_logger_from_settings(): """ try: settings_path = Path(__file__).parent.parent / "settings.yaml" - settings = load_server_config(settings_path) + settings = Config.get_server(settings_path) verbose = settings.get("verbose", False) if not verbose: diff --git a/tests/unit/test_common_config.py b/tests/unit/test_common_config.py new file mode 100644 index 00000000..1c86c892 --- /dev/null +++ b/tests/unit/test_common_config.py @@ -0,0 +1,107 @@ +"""Tests for the common.config.Config singleton cache (SCIOT-008).""" + +from pathlib import Path +from unittest.mock import patch + +import pytest +import yaml + +from common.config import Config + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +SERVER_SETTINGS = PROJECT_ROOT / "src/server/settings.yaml" +CLIENT_SETTINGS = PROJECT_ROOT / "src/client/python/http_config.yaml" + + +@pytest.fixture(autouse=True) +def reset_config_cache(): + Config.reset() + yield + Config.reset() + + +def test_get_server_returns_valid_config(): + config = Config.get_server(SERVER_SETTINGS) + assert "model" in config + assert "communication" in config + + +def test_get_client_returns_valid_config(): + config = Config.get_client(CLIENT_SETTINGS) + assert "client" in config + assert "http" in config + + +def test_get_server_only_parses_file_once_per_path(): + calls = [] + real_load = __import__( + "sciot.config", fromlist=["load_server_config"] + ).load_server_config + + def counting_load(*args, **kwargs): + calls.append(1) + return real_load(*args, **kwargs) + + with patch("common.config.load_server_config", side_effect=counting_load): + first = Config.get_server(SERVER_SETTINGS) + second = Config.get_server(SERVER_SETTINGS) + + assert first is second + assert len(calls) == 1 + + +def test_force_reload_reparses_file(): + calls = [] + real_load = __import__( + "sciot.config", fromlist=["load_server_config"] + ).load_server_config + + def counting_load(*args, **kwargs): + calls.append(1) + return real_load(*args, **kwargs) + + with patch("common.config.load_server_config", side_effect=counting_load): + Config.get_server(SERVER_SETTINGS) + Config.get_server(SERVER_SETTINGS, force_reload=True) + + assert len(calls) == 2 + + +def test_different_paths_cached_independently(tmp_path): + alt_config = yaml.safe_load(SERVER_SETTINGS.read_text()) + alt_path = tmp_path / "alt_settings.yaml" + alt_path.write_text(yaml.safe_dump(alt_config)) + + default_result = Config.get_server(SERVER_SETTINGS) + alt_result = Config.get_server(alt_path) + + assert default_result is not alt_result + assert default_result == alt_result + + +def test_get_server_default_path_resolves_to_repo_settings(): + config = Config.get_server() + assert config == Config.get_server(SERVER_SETTINGS) + + +def test_get_client_default_path_resolves_to_repo_settings(): + config = Config.get_client() + assert config == Config.get_client(CLIENT_SETTINGS) + + +def test_reset_clears_cache(): + calls = [] + real_load = __import__( + "sciot.config", fromlist=["load_server_config"] + ).load_server_config + + def counting_load(*args, **kwargs): + calls.append(1) + return real_load(*args, **kwargs) + + with patch("common.config.load_server_config", side_effect=counting_load): + Config.get_server(SERVER_SETTINGS) + Config.reset() + Config.get_server(SERVER_SETTINGS) + + assert len(calls) == 2