From 6144a8e9291b349af9e2918987ad280f71c076a2 Mon Sep 17 00:00:00 2001 From: zosimovaa Date: Sat, 11 Oct 2025 22:16:58 +0300 Subject: [PATCH] Initial commit --- LICENCE | 19 +++ README.md | 13 ++ pyproject.toml | 3 + requirements.txt | 1 + setup.cfg | 24 ++++ src/__init__.py | 0 .../basic_application.cpython-312.pyc | Bin 0 -> 8556 bytes src/basic_application/basic_application.py | 134 ++++++++++++++++++ tests/test.py | 131 +++++++++++++++++ tests/test_config.yml | 0 10 files changed, 325 insertions(+) create mode 100644 LICENCE create mode 100644 README.md create mode 100644 pyproject.toml create mode 100644 requirements.txt create mode 100644 setup.cfg create mode 100644 src/__init__.py create mode 100644 src/basic_application/__pycache__/basic_application.cpython-312.pyc create mode 100644 src/basic_application/basic_application.py create mode 100644 tests/test.py create mode 100644 tests/test_config.yml diff --git a/LICENCE b/LICENCE new file mode 100644 index 0000000..96f1555 --- /dev/null +++ b/LICENCE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..abe9166 --- /dev/null +++ b/README.md @@ -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 + + diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..fa7093a --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["setuptools>=42"] +build-backend = "setuptools.build_meta" \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..043876c --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +PyYAML>=6.0 \ No newline at end of file diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..9927534 --- /dev/null +++ b/setup.cfg @@ -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 diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/basic_application/__pycache__/basic_application.cpython-312.pyc b/src/basic_application/__pycache__/basic_application.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..b6b168a6b7c341d6bf26db53422ea4d5f314b6bd GIT binary patch literal 8556 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 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()) \ No newline at end of file diff --git a/tests/test.py b/tests/test.py new file mode 100644 index 0000000..ce46d9a --- /dev/null +++ b/tests/test.py @@ -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() diff --git a/tests/test_config.yml b/tests/test_config.yml new file mode 100644 index 0000000..e69de29