From 7b74e0b0b80306f3b98dc029326c29b66839a407 Mon Sep 17 00:00:00 2001 From: zosimovaa Date: Fri, 31 Oct 2025 23:16:25 +0300 Subject: [PATCH] no message --- .gitignore | 1 + src/basic_application/__init__.py | 2 - src/config_manager/__init__.py | 2 + src/config_manager/cfg_manager.py | 137 +++++++++++++++++ .../log_manager.py | 0 src/test.py | 35 ----- tests/config.yaml | 53 +++++++ tests/test.py | 145 +++--------------- tests/test_config.yml | 0 9 files changed, 214 insertions(+), 161 deletions(-) delete mode 100644 src/basic_application/__init__.py create mode 100644 src/config_manager/__init__.py create mode 100644 src/config_manager/cfg_manager.py rename src/{basic_application => config_manager}/log_manager.py (100%) delete mode 100644 src/test.py create mode 100644 tests/config.yaml delete mode 100644 tests/test_config.yml diff --git a/.gitignore b/.gitignore index f099a05..841d209 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ __pycache__ venv/ .vscode/ log*.log +config_manager.egg-info \ No newline at end of file diff --git a/src/basic_application/__init__.py b/src/basic_application/__init__.py deleted file mode 100644 index 3d56751..0000000 --- a/src/basic_application/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -from .config_manager import ConfigManager -from .log_manager import LogManager \ No newline at end of file diff --git a/src/config_manager/__init__.py b/src/config_manager/__init__.py new file mode 100644 index 0000000..d09f224 --- /dev/null +++ b/src/config_manager/__init__.py @@ -0,0 +1,2 @@ +from .cfg_manager import ConfigManager +from .log_manager import LogManager \ No newline at end of file diff --git a/src/config_manager/cfg_manager.py b/src/config_manager/cfg_manager.py new file mode 100644 index 0000000..0738b94 --- /dev/null +++ b/src/config_manager/cfg_manager.py @@ -0,0 +1,137 @@ +import logging +import logging.config +import asyncio +import json +import yaml +import os +from typing import Any, Optional + +from .log_manager import LogManager + +class ConfigManager: + DEFAULT_UPDATE_INTERVAL = 5.0 + DEFAULT_WORK_INTERVAL = 2.0 + + def __init__(self, path: str, log_manager: Optional[LogManager] = None): + self.path = path + self.config: Any = None + self._last_hash = None + self.update_interval = self.DEFAULT_UPDATE_INTERVAL + self.work_interval = self.DEFAULT_WORK_INTERVAL + self._halt = asyncio.Event() + self._task: Optional[asyncio.Task] = None + self._loop: Optional[asyncio.AbstractEventLoop] = None + + self._log_manager = log_manager or LogManager() + self.logger = logging.getLogger(__name__) + + def _read_file_sync(self) -> str: + with open(self.path, "r", encoding="utf-8") as f: + return f.read() + + async def _read_file_async(self) -> str: + return await asyncio.to_thread(self._read_file_sync) + + def _parse_config(self, data) -> Any: + extension = os.path.splitext(self.path)[1].lower() + if extension in (".yaml", ".yml"): + return yaml.safe_load(data) + else: + return json.loads(data) + + def _update_intervals_from_config(self) -> None: + if not self.config: + return + upd = self.config.get("update_interval") + wrk = self.config.get("work_interval") + + if isinstance(upd, (int, float)) and upd > 0: + self.update_interval = float(upd) + self.logger.info(f"Update interval set to {self.update_interval} seconds") + else: + self.update_interval = self.DEFAULT_UPDATE_INTERVAL + + if isinstance(wrk, (int, float)) and wrk > 0: + self.work_interval = float(wrk) + self.logger.info(f"Work interval set to {self.work_interval} seconds") + else: + self.work_interval = self.DEFAULT_WORK_INTERVAL + + async def _update_config(self) -> None: + try: + data = await self._read_file_async() + current_hash = hash(data) + if current_hash != self._last_hash: + new_config = self._parse_config(data) + self.config = new_config + self._last_hash = current_hash + + self._log_manager.apply_config(new_config) + self._update_intervals_from_config() + + except Exception as e: + self.logger.error(f"Error reading/parsing config file: {e}") + + def execute(self) -> None: + """ + Метод для переопределения в подклассах. + Здесь может быть блокирующая работа. + Запускается в отдельном потоке. + """ + pass + + async def _worker_loop(self) -> None: + while not self._halt.is_set(): + await asyncio.to_thread(self.execute) + await asyncio.sleep(self.work_interval) + + async def _periodic_update_loop(self) -> None: + while not self._halt.is_set(): + await self._update_config() + await asyncio.sleep(self.update_interval) + + async def _run(self) -> None: + """Внутренняя корутина, запускающая все циклы""" + self._halt.clear() + self.logger.info("ConfigManager started") + try: + await asyncio.gather( + self._worker_loop(), + self._periodic_update_loop() + ) + except asyncio.CancelledError: + self.logger.info("ConfigManager tasks cancelled") + finally: + self.logger.info("ConfigManager stopped") + + def start(self) -> None: + """Запускает менеджер конфигурации в текущем event loop""" + if self._task is not None and not self._task.done(): + self.logger.warning("ConfigManager is already running") + return + + try: + self._loop = asyncio.get_running_loop() + except RuntimeError: + self.logger.error("start() must be called from within an async context") + raise + + self._task = self._loop.create_task(self._run()) + self.logger.info("ConfigManager task created") + + async def stop(self) -> None: + """Останавливает менеджер конфигурации и ожидает завершения""" + if self._task is None: + self.logger.warning("ConfigManager is not running") + return + + self.logger.info("ConfigManager stopping...") + self._halt.set() + + try: + await self._task + except asyncio.CancelledError: + pass + + self._task = None + self.logger.info("ConfigManager stopped successfully") diff --git a/src/basic_application/log_manager.py b/src/config_manager/log_manager.py similarity index 100% rename from src/basic_application/log_manager.py rename to src/config_manager/log_manager.py diff --git a/src/test.py b/src/test.py deleted file mode 100644 index bc21944..0000000 --- a/src/test.py +++ /dev/null @@ -1,35 +0,0 @@ -from basic_application import ConfigManager -import logging -import asyncio -from typing import Optional -import os -os.chdir(os.path.dirname(__file__)) - -logger = logging.getLogger() - - -class MyApp(ConfigManager): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.iter = 0 - - - def execute(self) -> None: - logger.info(f"current iteration {self.iter}") - self.iter += 1 - - -async def main(): - app = MyApp("config.yaml") - app.start() - logger.info("App started") - await asyncio.sleep(20) - await app.stop() - -if __name__ == "__main__": - asyncio.run(main()) - - - - - \ No newline at end of file diff --git a/tests/config.yaml b/tests/config.yaml new file mode 100644 index 0000000..ee9ff58 --- /dev/null +++ b/tests/config.yaml @@ -0,0 +1,53 @@ +# === Раздел с общими конфигурационными параметрами === +runtime: 5 + +# === Логирование === +log: + version: 1 + disable_existing_loggers: False + + formatters: + standard: + format: '%(asctime)s %(module)15s [%(levelname)8s]: %(message)s' + telegram: + format: '%(message)s' + + handlers: + console: + level: DEBUG + formatter: standard + class: logging.StreamHandler + stream: ext://sys.stdout # Default is stderr + + file: + level: DEBUG + formatter: standard + class: logging.handlers.RotatingFileHandler + filename: logs/log.log + mode: a + maxBytes: 500000 + backupCount: 15 + + #telegram: + # level: CRITICAL + # formatter: telegram + # class: logging_telegram_handler.TelegramHandler + # chat_id: 211945135 + # alias: "PDC" + + +# -- Логгеры -- + loggers: + '': + handlers: [console, file] + level: INFO + propagate: False + + __main__: + handlers: [console, file] + level: DEBUG + propagate: False + + config_manager: + handlers: [console, file] + level: DEBUG \ No newline at end of file diff --git a/tests/test.py b/tests/test.py index ce46d9a..a9d1ddb 100644 --- a/tests/test.py +++ b/tests/test.py @@ -1,131 +1,28 @@ -import unittest -from unittest.mock import patch, mock_open, AsyncMock -import asyncio +from config_manager import ConfigManager import logging -import io -import json -import yaml +import asyncio +from typing import Optional +#import os +#os.chdir(os.path.dirname(__file__)) -import sys -import os +logger = logging.getLogger() -sys.path.append(os.path.join(os.path.dirname(__file__), '..', 'src')) +class MyApp(ConfigManager): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.iter = 0 -from basic_application.basic_application import ConfigManager - - -class TestConfigManager(unittest.IsolatedAsyncioTestCase): - def setUp(self): - self.json_data = json.dumps({ - "work_interval": 1, - "update_interval": 1, - "logging": { - "version": 1, - "handlers": {"console": {"class": "logging.StreamHandler", "level": "DEBUG"}}, - "root": {"handlers": ["console"], "level": "DEBUG"} - }, - "some_key": "some_value" - }) - self.yaml_data = """ -work_interval: 1 -update_interval: 1 -logging: - version: 1 - handlers: - console: - class: logging.StreamHandler - level: DEBUG - root: - handlers: [console] - level: DEBUG -some_key: some_value -""" - - @patch("builtins.open", new_callable=mock_open, read_data="") - async def test_read_file_async_json(self, mock_file): - mock_file.return_value.read = lambda: self.json_data - cm = ConfigManager("config.json") - content = await cm._read_file_async() - self.assertEqual(content, self.json_data) - - @patch("builtins.open", new_callable=mock_open, read_data="") - async def test_read_file_async_yaml(self, mock_file): - mock_file.return_value.read = lambda: self.yaml_data - cm = ConfigManager("config.yaml") - content = await cm._read_file_async() - self.assertEqual(content, self.yaml_data) - - def test_parse_json(self): - cm = ConfigManager("config.json") - parsed = cm._parse_config(self.json_data) - self.assertIsInstance(parsed, dict) - self.assertEqual(parsed["some_key"], "some_value") - - def test_parse_yaml(self): - cm = ConfigManager("config.yaml") - parsed = cm._parse_config(self.yaml_data) - self.assertIsInstance(parsed, dict) - self.assertEqual(parsed["some_key"], "some_value") - - @patch("basic_application.basic_application.logging.config.dictConfig") - def test_apply_logging_config(self, mock_dict_config): - cm = ConfigManager("config.json") - cm._apply_logging_config({"logging": {"version": 1}}) - mock_dict_config.assert_called_once() - - async def test_update_config_changes_config_and_intervals(self): - # Мокаем чтение файла - m = mock_open(read_data=self.json_data) - with patch("builtins.open", m): - cm = ConfigManager("config.json") - - # Проверяем исходные интервалы - self.assertEqual(cm.update_interval, cm.DEFAULT_UPDATE_INTERVAL) - self.assertEqual(cm.work_interval, cm.DEFAULT_WORK_INTERVAL) - - await cm._update_config() - - # После обновления данные заполнены - self.assertIsInstance(cm.config, dict) - self.assertEqual(cm.update_interval, 1.0) - self.assertEqual(cm.work_interval, 1.0) - - async def test_execute_called_in_worker_loop(self): - called = False - - class TestCM(ConfigManager): - def execute(self2): - nonlocal called - called = True - - cm = TestCM("config.json") - - async def stop_after_delay(): - await asyncio.sleep(0.1) - cm.stop() - - # Запускаем worker_loop и через 0.1 сек останавливаем - await asyncio.gather(cm._worker_loop(), stop_after_delay()) - - self.assertTrue(called) - - async def test_periodic_update_loop_runs(self): - count = 0 - - class TestCM(ConfigManager): - async def _update_config(self2): - nonlocal count - count += 1 - if count >= 2: - self2.stop() - - cm = TestCM("config.json") - - await cm._periodic_update_loop() - - self.assertGreaterEqual(count, 2) + def execute(self) -> None: + logger.info(f"current iteration {self.iter}") + self.iter += 1 +async def main(): + app = MyApp("config.yaml") + app.start() + logger.info("App started") + await asyncio.sleep(20) + await app.stop() if __name__ == "__main__": - logging.basicConfig(level=logging.WARNING) # отключаем логи во время тестов - unittest.main() + asyncio.run(main()) + \ No newline at end of file diff --git a/tests/test_config.yml b/tests/test_config.yml deleted file mode 100644 index e69de29..0000000