13 Commits

11 changed files with 193 additions and 215 deletions

6
.gitignore vendored
View File

@@ -1,3 +1,5 @@
src/config_manager/__pycache__/basic_application.cpython-312.pyc __pycache__
venv/ .venv/
.vscode/ .vscode/
log*.log
config_manager.egg-info

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "config_manager" name = "config_manager"
version = "1.0.2" version = "1.2.2"
description = "Config manager for building applications" description = "Config manager for building applications"
authors = [ authors = [
{ name = "Aleksei Zosimov", email = "lesha.spb@gmail.com" } { name = "Aleksei Zosimov", email = "lesha.spb@gmail.com" }

View File

@@ -1,24 +0,0 @@
[metadata]
name = config_manager
version = 1.0.1
author = Aleksei Zosimov
author_email = lesha.spb@gmail.com
description = Base application with configuration and logging features.
long_description = file: README.md
long_description_content_type = text/markdown
url = https://git.lesha.spb.ru/alex/config_manager
project_urls =
Bug Tracker = https://git.lesha.spb.ru/alex/config_manager/issues
classifiers =
Programming Language :: Python :: 3
License :: OSI Approved :: MIT License
Operating System :: OS Independent
[options]
package_dir =
= src
packages = find:
python_requires = >=3.10
[options.packages.find]
where = src

View File

@@ -1 +0,0 @@
from config_manager.config_manager import ConfigManager

View File

@@ -0,0 +1,2 @@
from .cfg_manager import ConfigManager
from .log_manager import LogManager

View File

@@ -1,26 +1,29 @@
import logging
import logging.config
import asyncio import asyncio
import json import json
import yaml import yaml
import logging
import logging.config
from typing import Any
import os import os
from typing import Any, Optional
from .log_manager import LogManager
logger = logging.getLogger(__name__)
class ConfigManager: class ConfigManager:
DEFAULT_UPDATE_INTERVAL = 5.0 DEFAULT_UPDATE_INTERVAL = 5.0
DEFAULT_WORK_INTERVAL = 2.0 DEFAULT_WORK_INTERVAL = 2.0
def __init__(self, path: str): def __init__(self, path: str, log_manager: Optional[LogManager] = None):
self.path = path self.path = path
self.config: Any = None self.config: Any = None
self._last_hash = None self._last_hash = None
self.update_interval = self.DEFAULT_UPDATE_INTERVAL self.update_interval = self.DEFAULT_UPDATE_INTERVAL
self.work_interval = self.DEFAULT_WORK_INTERVAL self.work_interval = self.DEFAULT_WORK_INTERVAL
self._halt = asyncio.Event() 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: def _read_file_sync(self) -> str:
with open(self.path, "r", encoding="utf-8") as f: with open(self.path, "r", encoding="utf-8") as f:
@@ -29,9 +32,9 @@ class ConfigManager:
async def _read_file_async(self) -> str: async def _read_file_async(self) -> str:
return await asyncio.to_thread(self._read_file_sync) return await asyncio.to_thread(self._read_file_sync)
def _parse_config(self, data: str) -> Any: def _parse_config(self, data) -> Any:
ext = os.path.splitext(self.path)[1].lower() extension = os.path.splitext(self.path)[1].lower()
if ext in (".yaml", ".yml"): if extension in (".yaml", ".yml"):
return yaml.safe_load(data) return yaml.safe_load(data)
else: else:
return json.loads(data) return json.loads(data)
@@ -39,31 +42,21 @@ class ConfigManager:
def _update_intervals_from_config(self) -> None: def _update_intervals_from_config(self) -> None:
if not self.config: if not self.config:
return return
# Берём интервалы из секции config обновления, с контролем типа и значений
upd = self.config.get("update_interval") upd = self.config.get("update_interval")
wrk = self.config.get("work_interval") wrk = self.config.get("work_interval")
if isinstance(upd, (int, float)) and upd > 0: if isinstance(upd, (int, float)) and upd > 0:
self.update_interval = float(upd) self.update_interval = float(upd)
logger.info(f"Update interval set to {self.update_interval} seconds") self.logger.info(f"Update interval set to {self.update_interval} seconds")
else: else:
self.update_interval = self.DEFAULT_UPDATE_INTERVAL self.update_interval = self.DEFAULT_UPDATE_INTERVAL
if isinstance(wrk, (int, float)) and wrk > 0: if isinstance(wrk, (int, float)) and wrk > 0:
self.work_interval = float(wrk) self.work_interval = float(wrk)
logger.info(f"Work interval set to {self.work_interval} seconds") self.logger.info(f"Work interval set to {self.work_interval} seconds")
else: else:
self.work_interval = self.DEFAULT_WORK_INTERVAL self.work_interval = self.DEFAULT_WORK_INTERVAL
def _apply_logging_config(self, config: dict) -> None:
try:
logging_config = config.get("logging")
if logging_config:
logging.config.dictConfig(logging_config)
logger.info("Logging configuration applied")
except Exception as e:
logger.error(f"Error applying logging config: {e}")
async def _update_config(self) -> None: async def _update_config(self) -> None:
try: try:
data = await self._read_file_async() data = await self._read_file_async()
@@ -73,12 +66,11 @@ class ConfigManager:
self.config = new_config self.config = new_config
self._last_hash = current_hash self._last_hash = current_hash
self._apply_logging_config(new_config) self._log_manager.apply_config(new_config)
self._update_intervals_from_config() self._update_intervals_from_config()
logger.info("Config updated: %s", self.config)
except Exception as e: except Exception as e:
logger.error(f"Error reading/parsing config file: {e}") self.logger.error(f"Error reading/parsing config file: {e}")
def execute(self) -> None: def execute(self) -> None:
""" """
@@ -98,37 +90,48 @@ class ConfigManager:
await self._update_config() await self._update_config()
await asyncio.sleep(self.update_interval) await asyncio.sleep(self.update_interval)
async def start(self) -> None: async def _run(self) -> None:
"""Внутренняя корутина, запускающая все циклы"""
self._halt.clear() self._halt.clear()
logger.info("ConfigManager started") self.logger.info("ConfigManager started")
await asyncio.gather( try:
self._worker_loop(), await asyncio.gather(
self._periodic_update_loop() self._worker_loop(),
) self._periodic_update_loop()
)
except asyncio.CancelledError:
self.logger.info("ConfigManager tasks cancelled")
finally:
self.logger.info("ConfigManager stopped")
def stop(self) -> None: async def start(self) -> None:
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.logger.info("ConfigManager starting and awaiting _run()")
await self._run()
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() self._halt.set()
logger.info("ConfigManager stopping...")
try:
await self._task
except asyncio.CancelledError:
pass
# Пример наследования и переопределения execute
class MyApp(ConfigManager): self._task = None
def execute(self) -> None: self.logger.info("ConfigManager stopped successfully")
logger.info("Executing blocking work with config: %s", self.config)
async def main():
app = MyApp("config.yaml") # Можно config.json или config.yaml
task = asyncio.create_task(app.start())
await asyncio.sleep(20)
app.stop()
await task
logger.info("Work finished.")
if __name__ == "__main__":
logging.basicConfig(level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s")
asyncio.run(main())

View File

@@ -0,0 +1,40 @@
import logging
from typing import Optional
class LogManager:
"""
Управляет конфигурацией логирования приложения.
Применяет конфигурацию из словаря с обработкой ошибок.
"""
def __init__(self):
self.logger = logging.getLogger(__name__)
self._last_valid_config: Optional[dict] = None
def apply_config(self, config: dict) -> None:
"""
Применяет конфигурацию логирования из словаря.
При ошибке восстанавливает последний валидный конфиг.
Args:
config: Словарь с настройками логирования (из файла конфига)
"""
logging_config = config.get("log")
if not logging_config:
return
try:
logging.config.dictConfig(logging_config)
self._last_valid_config = logging_config
self.logger.info("Logging configuration applied")
except Exception as e:
self.logger.error(f"Error applying logging config: {e}")
# Если был предыдущий валидный конфиг, восстанавливаем его
if self._last_valid_config:
try:
logging.config.dictConfig(self._last_valid_config)
self.logger.info("Previous logging configuration restored")
except Exception as restore_error:
self.logger.error(f"Error restoring previous config: {restore_error}")

53
tests/config.yaml Normal file
View File

@@ -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

View File

@@ -1,131 +0,0 @@
import unittest
from unittest.mock import patch, mock_open, AsyncMock
import asyncio
import logging
import io
import json
import yaml
import sys
import os
sys.path.append(os.path.join(os.path.dirname(__file__), '..', 'src'))
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)
if __name__ == "__main__":
logging.basicConfig(level=logging.WARNING) # отключаем логи во время тестов
unittest.main()

34
tests/test_app.py Normal file
View File

@@ -0,0 +1,34 @@
#import os
#os.chdir(os.path.dirname(__file__))
from config_manager import ConfigManager
import logging
import asyncio
from typing import Optional
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")
logger.info("App started")
await app.start()
logger.info("App finished")
while True:
await asyncio.sleep(1)
if __name__ == "__main__":
asyncio.run(main())

View File