Initial commit

This commit is contained in:
2025-10-11 22:16:58 +03:00
commit 6144a8e929
10 changed files with 325 additions and 0 deletions

19
LICENCE Normal file
View File

@@ -0,0 +1,19 @@
Copyright (c) 2018 The Python Packaging Authority
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

13
README.md Normal file
View File

@@ -0,0 +1,13 @@
# Basic application
## Description
This package was created to run my applications on the current Reinforcement Learning project.
The BasicApplication class implements the entry point for the program and provides the actual application configuration. It also simplifies logging setup.
## Installation
``pip install git+https://zosimovaa@bitbucket.org/zosimovaa/basic_application.git``
## Contacts
- **e-mail**: lesha.spb@gmail.com
- **telegram**: https://t.me/lesha_spb

3
pyproject.toml Normal file
View File

@@ -0,0 +1,3 @@
[build-system]
requires = ["setuptools>=42"]
build-backend = "setuptools.build_meta"

1
requirements.txt Normal file
View File

@@ -0,0 +1 @@
PyYAML>=6.0

24
setup.cfg Normal file
View File

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

0
src/__init__.py Normal file
View File

View File

@@ -0,0 +1,134 @@
import asyncio
import json
import yaml # pip install pyyaml
import logging
import logging.config
from typing import Any
import os
logger = logging.getLogger(__name__)
class ConfigManager:
DEFAULT_UPDATE_INTERVAL = 5.0
DEFAULT_WORK_INTERVAL = 2.0
def __init__(self, path: str):
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()
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: str) -> Any:
ext = os.path.splitext(self.path)[1].lower()
if ext 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
# Берём интервалы из секции config обновления, с контролем типа и значений
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)
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)
logger.info(f"Work interval set to {self.work_interval} seconds")
else:
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:
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._apply_logging_config(new_config)
self._update_intervals_from_config()
logger.info("Config updated: %s", self.config)
except Exception as e:
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 start(self) -> None:
self._halt.clear()
logger.info("ConfigManager started")
await asyncio.gather(
self._worker_loop(),
self._periodic_update_loop()
)
def stop(self) -> None:
self._halt.set()
logger.info("ConfigManager stopping...")
# Пример наследования и переопределения execute
class MyApp(ConfigManager):
def execute(self) -> None:
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())

131
tests/test.py Normal file
View File

@@ -0,0 +1,131 @@
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()

0
tests/test_config.yml Normal file
View File