chutils 2.0.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- chutils-2.0.0/LICENSE +21 -0
- chutils-2.0.0/PKG-INFO +196 -0
- chutils-2.0.0/README.md +177 -0
- chutils-2.0.0/pyproject.toml +28 -0
- chutils-2.0.0/src/chutils/__init__.py +102 -0
- chutils-2.0.0/src/chutils/config.py +277 -0
- chutils-2.0.0/src/chutils/logger.py +203 -0
- chutils-2.0.0/src/chutils/secret_manager.py +138 -0
chutils-2.0.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Chu4hel
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
chutils-2.0.0/PKG-INFO
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: chutils
|
|
3
|
+
Version: 2.0.0
|
|
4
|
+
Summary:
|
|
5
|
+
License: MIT
|
|
6
|
+
Author: Sergo
|
|
7
|
+
Author-email: sergeiivanov636@gmail.com
|
|
8
|
+
Requires-Python: >=3.9
|
|
9
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Requires-Dist: keyring (>=25.6.0,<26.0.0)
|
|
17
|
+
Requires-Dist: pyyaml (>=6.0.3,<7.0.0)
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
|
|
20
|
+
# chutils
|
|
21
|
+
|
|
22
|
+
[](https://opensource.org/licenses/MIT) [](https://www.python.org/downloads/)
|
|
23
|
+
|
|
24
|
+
Набор простых и удобных утилит для Python, который избавляет от рутины при работе с конфигурацией, логированием и
|
|
25
|
+
секретами в новых проектах.
|
|
26
|
+
|
|
27
|
+
## Проблема
|
|
28
|
+
|
|
29
|
+
Каждый раз, начиная новый проект, приходится решать одни и те же задачи:
|
|
30
|
+
|
|
31
|
+
- Как удобно читать настройки из файла конфигурации?
|
|
32
|
+
- Как настроить логирование, чтобы сообщения писались и в консоль, и в файл с ежедневной ротацией?
|
|
33
|
+
- Как безопасно хранить API-ключи и пароли, не записывая их в код или в файлы конфигурации?
|
|
34
|
+
- Как сделать так, чтобы все это работало без жестко прописанных путей сразу после установки?
|
|
35
|
+
|
|
36
|
+
**chutils** решает эти проблемы.
|
|
37
|
+
|
|
38
|
+
## Ключевые возможности
|
|
39
|
+
|
|
40
|
+
- **✨ Ноль конфигурации:** Библиотека **автоматически** находит корень вашего проекта и файл конфигурации (`config.yml`
|
|
41
|
+
или `config.ini`).
|
|
42
|
+
- **⚙️ Гибкая конфигурация:** Поддержка `YAML` и `INI` форматов. Простые функции для получения типизированных данных.
|
|
43
|
+
- **✍️ Продвинутый логгер:** Функция `setup_logger()` "из коробки" настраивает логирование в консоль и в ротируемые
|
|
44
|
+
файлы. Возвращает кастомный логгер с дополнительными уровнями отладки (`devdebug`, `mediumdebug`).
|
|
45
|
+
- **🔒 Безопасное хранилище секретов:** Модуль `secret_manager` предоставляет простой интерфейс для сохранения и
|
|
46
|
+
получения секретов через системное хранилище ключей (Keyring).
|
|
47
|
+
- **🚀 Готовность к работе:** Просто установите и используйте.
|
|
48
|
+
|
|
49
|
+
## Установка
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
poetry add chutils
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Или с помощью pip:
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
pip install chutils
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Для разработки клонируйте репозиторий и установите его в режиме редактирования:
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
git clone https://github.com/Chu4hel/chutils.git
|
|
65
|
+
cd chutils
|
|
66
|
+
pip install -e .
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## Быстрый старт
|
|
70
|
+
|
|
71
|
+
1. Создайте в корне вашего проекта файл `config.yml`.
|
|
72
|
+
|
|
73
|
+
**Структура проекта:**
|
|
74
|
+
```
|
|
75
|
+
my_awesome_app/
|
|
76
|
+
├── main.py
|
|
77
|
+
└── config.yml
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
**Содержимое `config.yml`:**
|
|
81
|
+
```yaml
|
|
82
|
+
API:
|
|
83
|
+
base_url: https://api.example.com
|
|
84
|
+
|
|
85
|
+
Database:
|
|
86
|
+
host: localhost
|
|
87
|
+
port: 5432
|
|
88
|
+
user: my_user
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
2. Используйте `chutils` в вашем коде `main.py`:
|
|
92
|
+
|
|
93
|
+
```python
|
|
94
|
+
# main.py
|
|
95
|
+
from chutils import get_config_value, setup_logger, SecretManager, ChutilsLogger
|
|
96
|
+
|
|
97
|
+
# 1. Настраиваем логгер. Он автоматически прочитает настройки из конфига.
|
|
98
|
+
logger: ChutilsLogger = setup_logger()
|
|
99
|
+
|
|
100
|
+
# 2. Инициализируем менеджер секретов для нашего приложения.
|
|
101
|
+
secrets = SecretManager("my_awesome_app")
|
|
102
|
+
|
|
103
|
+
def setup_credentials():
|
|
104
|
+
"""Функция для первоначального сохранения пароля."""
|
|
105
|
+
db_user = get_config_value("Database", "user")
|
|
106
|
+
if not secrets.get_secret(f"{db_user}_password"):
|
|
107
|
+
logger.info("Пароль для БД не найден. Сохраняем новый пароль...")
|
|
108
|
+
secrets.save_secret(f"{db_user}_password", "MySuperSecretDbPassword123!")
|
|
109
|
+
logger.info("Пароль для БД сохранен в системном хранилище.")
|
|
110
|
+
|
|
111
|
+
def connect_to_db():
|
|
112
|
+
# 3. Легко получаем значения из конфига и секреты из хранилища.
|
|
113
|
+
db_host = get_config_value("Database", "host")
|
|
114
|
+
db_user = get_config_value("Database", "user")
|
|
115
|
+
db_password = secrets.get_secret(f"{db_user}_password")
|
|
116
|
+
|
|
117
|
+
if not db_password:
|
|
118
|
+
logger.error("Не удалось получить пароль для БД!")
|
|
119
|
+
return
|
|
120
|
+
|
|
121
|
+
logger.info(f"Подключаемся к базе данных по адресу {db_host} от имени {db_user}...")
|
|
122
|
+
# ... логика подключения ...
|
|
123
|
+
logger.info("Успешно подключились!")
|
|
124
|
+
|
|
125
|
+
def main():
|
|
126
|
+
logger.info("Приложение запущено.")
|
|
127
|
+
setup_credentials()
|
|
128
|
+
connect_to_db()
|
|
129
|
+
logger.info("Приложение завершило работу.")
|
|
130
|
+
|
|
131
|
+
if __name__ == "__main__":
|
|
132
|
+
main()
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
3. Запустите ваш скрипт. Вы увидите логи в консоли, а в проекте появится папка `logs` с файлом лога. Пароль от БД будет
|
|
136
|
+
надежно сохранен в системном хранилище.
|
|
137
|
+
|
|
138
|
+
## API и Использование
|
|
139
|
+
|
|
140
|
+
### Работа с конфигурацией (`chutils.config`)
|
|
141
|
+
|
|
142
|
+
- `get_config_value(section, key, fallback="")`: Получить значение.
|
|
143
|
+
- `get_config_int(section, key, fallback=0)`: Получить целое число.
|
|
144
|
+
- `get_config_boolean(section, key, fallback=False)`: Получить булево значение.
|
|
145
|
+
- `get_config_list(section, key, fallback=[])`: Получить список.
|
|
146
|
+
- `get_config_section(section)`: Получить всю секцию как словарь.
|
|
147
|
+
- `save_config_value(section, key, value)`: Сохранить значение. **Важно: работает только для `.ini` файлов** для
|
|
148
|
+
сохранения комментариев.
|
|
149
|
+
|
|
150
|
+
### Настройка логирования (`chutils.logger`)
|
|
151
|
+
|
|
152
|
+
- `setup_logger(name='app_logger', log_level_str='')`: Настраивает и возвращает экземпляр `ChutilsLogger`.
|
|
153
|
+
- `logger.mediumdebug("message")`: Логирование с уровнем 15.
|
|
154
|
+
- `logger.devdebug("message")`: Логирование с уровнем 9.
|
|
155
|
+
|
|
156
|
+
### Управление секретами (`chutils.secret_manager`)
|
|
157
|
+
|
|
158
|
+
- `SecretManager(service_name)`: Создает менеджер, изолированный по имени сервиса.
|
|
159
|
+
- `secrets.save_secret(key, value)`: Сохраняет секрет.
|
|
160
|
+
- `secrets.get_secret(key)`: Получает секрет.
|
|
161
|
+
- `secrets.delete_secret(key)`: Удаляет секрет.
|
|
162
|
+
|
|
163
|
+
### Ручная инициализация (`chutils.init`)
|
|
164
|
+
|
|
165
|
+
В 99% случаев вам это **не понадобится**. Но если автоматика не справилась, вы можете один раз указать путь к проекту
|
|
166
|
+
вручную:
|
|
167
|
+
|
|
168
|
+
```python
|
|
169
|
+
import chutils
|
|
170
|
+
|
|
171
|
+
chutils.init(base_dir="/path/to/my/project/root")
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
### Пример файла `config.yml`
|
|
175
|
+
|
|
176
|
+
`chutils` использует секцию `Logging` для настройки логгера.
|
|
177
|
+
|
|
178
|
+
```yaml
|
|
179
|
+
API:
|
|
180
|
+
token: your_secret_token_here
|
|
181
|
+
|
|
182
|
+
Database:
|
|
183
|
+
host: localhost
|
|
184
|
+
|
|
185
|
+
Logging:
|
|
186
|
+
# Уровни: DEVDEBUG, DEBUG, MEDIUMDEBUG, INFO, WARNING, ERROR, CRITICAL
|
|
187
|
+
log_level: DEBUG
|
|
188
|
+
# Имя файла для логов
|
|
189
|
+
log_file_name: my_app.log
|
|
190
|
+
# Сколько дней хранить файлы логов
|
|
191
|
+
log_backup_count: 7
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
## Лицензия
|
|
195
|
+
|
|
196
|
+
Проект распространяется под лицензией MIT.
|
chutils-2.0.0/README.md
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
# chutils
|
|
2
|
+
|
|
3
|
+
[](https://opensource.org/licenses/MIT) [](https://www.python.org/downloads/)
|
|
4
|
+
|
|
5
|
+
Набор простых и удобных утилит для Python, который избавляет от рутины при работе с конфигурацией, логированием и
|
|
6
|
+
секретами в новых проектах.
|
|
7
|
+
|
|
8
|
+
## Проблема
|
|
9
|
+
|
|
10
|
+
Каждый раз, начиная новый проект, приходится решать одни и те же задачи:
|
|
11
|
+
|
|
12
|
+
- Как удобно читать настройки из файла конфигурации?
|
|
13
|
+
- Как настроить логирование, чтобы сообщения писались и в консоль, и в файл с ежедневной ротацией?
|
|
14
|
+
- Как безопасно хранить API-ключи и пароли, не записывая их в код или в файлы конфигурации?
|
|
15
|
+
- Как сделать так, чтобы все это работало без жестко прописанных путей сразу после установки?
|
|
16
|
+
|
|
17
|
+
**chutils** решает эти проблемы.
|
|
18
|
+
|
|
19
|
+
## Ключевые возможности
|
|
20
|
+
|
|
21
|
+
- **✨ Ноль конфигурации:** Библиотека **автоматически** находит корень вашего проекта и файл конфигурации (`config.yml`
|
|
22
|
+
или `config.ini`).
|
|
23
|
+
- **⚙️ Гибкая конфигурация:** Поддержка `YAML` и `INI` форматов. Простые функции для получения типизированных данных.
|
|
24
|
+
- **✍️ Продвинутый логгер:** Функция `setup_logger()` "из коробки" настраивает логирование в консоль и в ротируемые
|
|
25
|
+
файлы. Возвращает кастомный логгер с дополнительными уровнями отладки (`devdebug`, `mediumdebug`).
|
|
26
|
+
- **🔒 Безопасное хранилище секретов:** Модуль `secret_manager` предоставляет простой интерфейс для сохранения и
|
|
27
|
+
получения секретов через системное хранилище ключей (Keyring).
|
|
28
|
+
- **🚀 Готовность к работе:** Просто установите и используйте.
|
|
29
|
+
|
|
30
|
+
## Установка
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
poetry add chutils
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Или с помощью pip:
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
pip install chutils
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Для разработки клонируйте репозиторий и установите его в режиме редактирования:
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
git clone https://github.com/Chu4hel/chutils.git
|
|
46
|
+
cd chutils
|
|
47
|
+
pip install -e .
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Быстрый старт
|
|
51
|
+
|
|
52
|
+
1. Создайте в корне вашего проекта файл `config.yml`.
|
|
53
|
+
|
|
54
|
+
**Структура проекта:**
|
|
55
|
+
```
|
|
56
|
+
my_awesome_app/
|
|
57
|
+
├── main.py
|
|
58
|
+
└── config.yml
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
**Содержимое `config.yml`:**
|
|
62
|
+
```yaml
|
|
63
|
+
API:
|
|
64
|
+
base_url: https://api.example.com
|
|
65
|
+
|
|
66
|
+
Database:
|
|
67
|
+
host: localhost
|
|
68
|
+
port: 5432
|
|
69
|
+
user: my_user
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
2. Используйте `chutils` в вашем коде `main.py`:
|
|
73
|
+
|
|
74
|
+
```python
|
|
75
|
+
# main.py
|
|
76
|
+
from chutils import get_config_value, setup_logger, SecretManager, ChutilsLogger
|
|
77
|
+
|
|
78
|
+
# 1. Настраиваем логгер. Он автоматически прочитает настройки из конфига.
|
|
79
|
+
logger: ChutilsLogger = setup_logger()
|
|
80
|
+
|
|
81
|
+
# 2. Инициализируем менеджер секретов для нашего приложения.
|
|
82
|
+
secrets = SecretManager("my_awesome_app")
|
|
83
|
+
|
|
84
|
+
def setup_credentials():
|
|
85
|
+
"""Функция для первоначального сохранения пароля."""
|
|
86
|
+
db_user = get_config_value("Database", "user")
|
|
87
|
+
if not secrets.get_secret(f"{db_user}_password"):
|
|
88
|
+
logger.info("Пароль для БД не найден. Сохраняем новый пароль...")
|
|
89
|
+
secrets.save_secret(f"{db_user}_password", "MySuperSecretDbPassword123!")
|
|
90
|
+
logger.info("Пароль для БД сохранен в системном хранилище.")
|
|
91
|
+
|
|
92
|
+
def connect_to_db():
|
|
93
|
+
# 3. Легко получаем значения из конфига и секреты из хранилища.
|
|
94
|
+
db_host = get_config_value("Database", "host")
|
|
95
|
+
db_user = get_config_value("Database", "user")
|
|
96
|
+
db_password = secrets.get_secret(f"{db_user}_password")
|
|
97
|
+
|
|
98
|
+
if not db_password:
|
|
99
|
+
logger.error("Не удалось получить пароль для БД!")
|
|
100
|
+
return
|
|
101
|
+
|
|
102
|
+
logger.info(f"Подключаемся к базе данных по адресу {db_host} от имени {db_user}...")
|
|
103
|
+
# ... логика подключения ...
|
|
104
|
+
logger.info("Успешно подключились!")
|
|
105
|
+
|
|
106
|
+
def main():
|
|
107
|
+
logger.info("Приложение запущено.")
|
|
108
|
+
setup_credentials()
|
|
109
|
+
connect_to_db()
|
|
110
|
+
logger.info("Приложение завершило работу.")
|
|
111
|
+
|
|
112
|
+
if __name__ == "__main__":
|
|
113
|
+
main()
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
3. Запустите ваш скрипт. Вы увидите логи в консоли, а в проекте появится папка `logs` с файлом лога. Пароль от БД будет
|
|
117
|
+
надежно сохранен в системном хранилище.
|
|
118
|
+
|
|
119
|
+
## API и Использование
|
|
120
|
+
|
|
121
|
+
### Работа с конфигурацией (`chutils.config`)
|
|
122
|
+
|
|
123
|
+
- `get_config_value(section, key, fallback="")`: Получить значение.
|
|
124
|
+
- `get_config_int(section, key, fallback=0)`: Получить целое число.
|
|
125
|
+
- `get_config_boolean(section, key, fallback=False)`: Получить булево значение.
|
|
126
|
+
- `get_config_list(section, key, fallback=[])`: Получить список.
|
|
127
|
+
- `get_config_section(section)`: Получить всю секцию как словарь.
|
|
128
|
+
- `save_config_value(section, key, value)`: Сохранить значение. **Важно: работает только для `.ini` файлов** для
|
|
129
|
+
сохранения комментариев.
|
|
130
|
+
|
|
131
|
+
### Настройка логирования (`chutils.logger`)
|
|
132
|
+
|
|
133
|
+
- `setup_logger(name='app_logger', log_level_str='')`: Настраивает и возвращает экземпляр `ChutilsLogger`.
|
|
134
|
+
- `logger.mediumdebug("message")`: Логирование с уровнем 15.
|
|
135
|
+
- `logger.devdebug("message")`: Логирование с уровнем 9.
|
|
136
|
+
|
|
137
|
+
### Управление секретами (`chutils.secret_manager`)
|
|
138
|
+
|
|
139
|
+
- `SecretManager(service_name)`: Создает менеджер, изолированный по имени сервиса.
|
|
140
|
+
- `secrets.save_secret(key, value)`: Сохраняет секрет.
|
|
141
|
+
- `secrets.get_secret(key)`: Получает секрет.
|
|
142
|
+
- `secrets.delete_secret(key)`: Удаляет секрет.
|
|
143
|
+
|
|
144
|
+
### Ручная инициализация (`chutils.init`)
|
|
145
|
+
|
|
146
|
+
В 99% случаев вам это **не понадобится**. Но если автоматика не справилась, вы можете один раз указать путь к проекту
|
|
147
|
+
вручную:
|
|
148
|
+
|
|
149
|
+
```python
|
|
150
|
+
import chutils
|
|
151
|
+
|
|
152
|
+
chutils.init(base_dir="/path/to/my/project/root")
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
### Пример файла `config.yml`
|
|
156
|
+
|
|
157
|
+
`chutils` использует секцию `Logging` для настройки логгера.
|
|
158
|
+
|
|
159
|
+
```yaml
|
|
160
|
+
API:
|
|
161
|
+
token: your_secret_token_here
|
|
162
|
+
|
|
163
|
+
Database:
|
|
164
|
+
host: localhost
|
|
165
|
+
|
|
166
|
+
Logging:
|
|
167
|
+
# Уровни: DEVDEBUG, DEBUG, MEDIUMDEBUG, INFO, WARNING, ERROR, CRITICAL
|
|
168
|
+
log_level: DEBUG
|
|
169
|
+
# Имя файла для логов
|
|
170
|
+
log_file_name: my_app.log
|
|
171
|
+
# Сколько дней хранить файлы логов
|
|
172
|
+
log_backup_count: 7
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
## Лицензия
|
|
176
|
+
|
|
177
|
+
Проект распространяется под лицензией MIT.
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
[tool.poetry]
|
|
2
|
+
name = "chutils"
|
|
3
|
+
version = "2.0.0"
|
|
4
|
+
description = ""
|
|
5
|
+
authors = ["Sergo <sergeiivanov636@gmail.com>"]
|
|
6
|
+
license = "MIT"
|
|
7
|
+
readme = "README.md"
|
|
8
|
+
packages = [{include = "chutils", from = "src"}]
|
|
9
|
+
|
|
10
|
+
[tool.poetry.dependencies]
|
|
11
|
+
python = ">=3.9"
|
|
12
|
+
keyring = "^25.6.0"
|
|
13
|
+
pyyaml = "^6.0.3"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
[tool.poetry.group.dev.dependencies]
|
|
17
|
+
pytest = "^8.4.2"
|
|
18
|
+
pytest-mock = "^3.15.1"
|
|
19
|
+
pyfakefs = "^5.10.0"
|
|
20
|
+
|
|
21
|
+
[build-system]
|
|
22
|
+
requires = ["poetry-core>=2.0.0,<3.0.0"]
|
|
23
|
+
build-backend = "poetry.core.masonry.api"
|
|
24
|
+
|
|
25
|
+
[tool.pytest.ini_options]
|
|
26
|
+
pythonpath = [
|
|
27
|
+
"src"
|
|
28
|
+
]
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Пакет chutils - набор переиспользуемых утилит для Python.
|
|
3
|
+
|
|
4
|
+
Основная цель - упростить рутинные задачи, такие как работа с конфигурацией,
|
|
5
|
+
логированием и управлением секретами, с минимальными усилиями со стороны разработчика.
|
|
6
|
+
|
|
7
|
+
Ключевые особенности:
|
|
8
|
+
- Автоматическое обнаружение корня проекта и файла конфигурации.
|
|
9
|
+
- Поддержка форматов `config.yml`, `config.yaml` и `config.ini` (YAML в приоритете).
|
|
10
|
+
- Удобные функции для доступа к настройкам.
|
|
11
|
+
- Готовый к работе логгер с выводом в консоль и ротируемые файлы.
|
|
12
|
+
- Безопасное хранение секретов через системное хранилище (keyring).
|
|
13
|
+
|
|
14
|
+
Основное использование:
|
|
15
|
+
----------------------
|
|
16
|
+
Вам не нужно ничего инициализировать. Просто импортируйте и используйте:
|
|
17
|
+
|
|
18
|
+
from chutils import get_config_value, setup_logger, SecretManager
|
|
19
|
+
|
|
20
|
+
logger = setup_logger()
|
|
21
|
+
secrets = SecretManager("my_app")
|
|
22
|
+
db_host = get_config_value("Database", "host", "localhost")
|
|
23
|
+
logger.info(f"Подключение к базе данных на {db_host}")
|
|
24
|
+
|
|
25
|
+
Ручная инициализация (для нестандартных случаев):
|
|
26
|
+
-------------------------------------------------
|
|
27
|
+
Если автоматика не сработала, вы можете указать путь к корню проекта вручную:
|
|
28
|
+
|
|
29
|
+
import chutils
|
|
30
|
+
chutils.init(base_dir="/path/to/your/project")
|
|
31
|
+
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
import os
|
|
35
|
+
|
|
36
|
+
from . import config
|
|
37
|
+
from . import logger
|
|
38
|
+
|
|
39
|
+
# --- Импорт публичных функций и классов ---
|
|
40
|
+
# Явно импортируем все, что должно быть доступно пользователю напрямую из пакета chutils.
|
|
41
|
+
|
|
42
|
+
from .config import (
|
|
43
|
+
get_config,
|
|
44
|
+
get_config_value,
|
|
45
|
+
get_config_int,
|
|
46
|
+
get_config_float,
|
|
47
|
+
get_config_boolean,
|
|
48
|
+
get_config_list,
|
|
49
|
+
get_config_section
|
|
50
|
+
)
|
|
51
|
+
from .logger import setup_logger, ChutilsLogger
|
|
52
|
+
from .secret_manager import SecretManager
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def init(base_dir: str):
|
|
56
|
+
"""
|
|
57
|
+
Ручная инициализация пакета с указанием базовой директории проекта.
|
|
58
|
+
|
|
59
|
+
Эту функцию нужно вызывать только в том случае, если автоматическое
|
|
60
|
+
определение корня проекта не сработало. Вызывать следует один раз
|
|
61
|
+
в самом начале работы основного скрипта вашего приложения.
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
base_dir (str): Абсолютный путь к корневой директории проекта.
|
|
65
|
+
|
|
66
|
+
Raises:
|
|
67
|
+
ValueError: Если указанная директория не существует.
|
|
68
|
+
"""
|
|
69
|
+
if not os.path.isdir(base_dir):
|
|
70
|
+
raise ValueError(f"Указанная директория base_dir не существует или не является директорией: {base_dir}")
|
|
71
|
+
|
|
72
|
+
# Вручную устанавливаем базовую директорию. Модуль config сам найдет
|
|
73
|
+
# нужный файл (yml или ini) при первом обращении.
|
|
74
|
+
config._BASE_DIR = base_dir
|
|
75
|
+
config._paths_initialized = True
|
|
76
|
+
|
|
77
|
+
print(f"Пакет chutils вручную инициализирован с базовой директорией: {base_dir}")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
# --- Определение публичного API (`__all__`) ---
|
|
81
|
+
# Определяет, что будет импортировано при `from chutils import *`
|
|
82
|
+
|
|
83
|
+
__all__ = [
|
|
84
|
+
# Основная функция ручной инициализации
|
|
85
|
+
'init',
|
|
86
|
+
|
|
87
|
+
# Функции и классы из модуля config
|
|
88
|
+
'get_config',
|
|
89
|
+
'get_config_value',
|
|
90
|
+
'get_config_int',
|
|
91
|
+
'get_config_float',
|
|
92
|
+
'get_config_boolean',
|
|
93
|
+
'get_config_list',
|
|
94
|
+
'get_config_section',
|
|
95
|
+
|
|
96
|
+
# Функции и классы из модуля logger
|
|
97
|
+
'setup_logger',
|
|
98
|
+
'ChutilsLogger',
|
|
99
|
+
|
|
100
|
+
# Классы из модуля secret_manager
|
|
101
|
+
'SecretManager',
|
|
102
|
+
]
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Модуль для работы с конфигурацией.
|
|
3
|
+
|
|
4
|
+
Обеспечивает автоматический поиск файла `config.yml`, `config.yaml` или `config.ini`
|
|
5
|
+
в корне проекта и предоставляет удобные функции для чтения настроек.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import configparser
|
|
9
|
+
import logging
|
|
10
|
+
import os
|
|
11
|
+
import re
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any, Optional, List, Dict
|
|
14
|
+
|
|
15
|
+
import yaml
|
|
16
|
+
|
|
17
|
+
# Настраиваем логгер для этого модуля
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
# --- Глобальное состояние для "ленивой" инициализации ---
|
|
21
|
+
_BASE_DIR: Optional[str] = None
|
|
22
|
+
_CONFIG_FILE_PATH: Optional[str] = None
|
|
23
|
+
_paths_initialized = False
|
|
24
|
+
|
|
25
|
+
_config_object: Optional[Dict] = None
|
|
26
|
+
_config_loaded = False
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def find_project_root(start_path: Path, markers: List[str]) -> Optional[Path]:
|
|
30
|
+
"""Ищет корень проекта, двигаясь вверх по дереву каталогов."""
|
|
31
|
+
current_path = start_path.resolve()
|
|
32
|
+
# Идем вверх до тех пор, пока не достигнем корня файловой системы
|
|
33
|
+
while current_path != current_path.parent:
|
|
34
|
+
for marker in markers:
|
|
35
|
+
if (current_path / marker).exists():
|
|
36
|
+
logger.debug(f"Найден маркер '{marker}' в директории: {current_path}")
|
|
37
|
+
return current_path
|
|
38
|
+
current_path = current_path.parent
|
|
39
|
+
logger.debug("Корень проекта не найден.")
|
|
40
|
+
return None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _initialize_paths():
|
|
44
|
+
"""Автоматически находит и кэширует пути к корню проекта и файлу конфигурации."""
|
|
45
|
+
global _BASE_DIR, _CONFIG_FILE_PATH, _paths_initialized
|
|
46
|
+
if _paths_initialized:
|
|
47
|
+
return
|
|
48
|
+
|
|
49
|
+
# Приоритет поиска: сначала YAML, потом INI, потом общий маркер проекта.
|
|
50
|
+
markers = ['config.yml', 'config.yaml', 'config.ini', 'pyproject.toml']
|
|
51
|
+
project_root = find_project_root(Path.cwd(), markers)
|
|
52
|
+
|
|
53
|
+
if project_root:
|
|
54
|
+
_BASE_DIR = str(project_root)
|
|
55
|
+
# Находим, какой именно конфигурационный файл был найден
|
|
56
|
+
for marker in markers:
|
|
57
|
+
if (project_root / marker).is_file() and marker.startswith('config'):
|
|
58
|
+
_CONFIG_FILE_PATH = str(project_root / marker)
|
|
59
|
+
break
|
|
60
|
+
logger.info(f"Корень проекта автоматически определен: {_BASE_DIR}")
|
|
61
|
+
else:
|
|
62
|
+
logger.warning("Не удалось автоматически найти корень проекта.")
|
|
63
|
+
|
|
64
|
+
_paths_initialized = True
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _get_config_path(cfg_file: Optional[str] = None) -> str:
|
|
68
|
+
"""
|
|
69
|
+
Внутренняя функция-шлюз для получения пути к файлу конфигурации.
|
|
70
|
+
|
|
71
|
+
Если путь не был установлен, запускает автоматический поиск.
|
|
72
|
+
Если путь не передан явно и автоматический поиск не дал результатов,
|
|
73
|
+
выбрасывает исключение с понятным сообщением.
|
|
74
|
+
"""
|
|
75
|
+
# Если путь к файлу передан явно, используем его.
|
|
76
|
+
if cfg_file:
|
|
77
|
+
return cfg_file
|
|
78
|
+
|
|
79
|
+
# Если пути еще не инициализированы, запускаем поиск.
|
|
80
|
+
if not _paths_initialized:
|
|
81
|
+
_initialize_paths()
|
|
82
|
+
|
|
83
|
+
# Если после инициализации путь все еще не определен, это ошибка.
|
|
84
|
+
if _CONFIG_FILE_PATH is None:
|
|
85
|
+
raise FileNotFoundError(
|
|
86
|
+
"Файл конфигурации не найден. Не удалось автоматически определить корень проекта. "
|
|
87
|
+
"Убедитесь, что в корне вашего проекта есть 'config.yml' или 'config.ini' или 'pyproject.toml', "
|
|
88
|
+
"либо укажите путь к конфигу вручную через chutils.init(base_dir=...)"
|
|
89
|
+
)
|
|
90
|
+
return _CONFIG_FILE_PATH
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def get_config() -> Dict:
|
|
94
|
+
"""
|
|
95
|
+
Загружает конфигурацию из файла (YAML или INI) и возвращает ее как словарь.
|
|
96
|
+
Результат кэшируется для последующих вызовов.
|
|
97
|
+
|
|
98
|
+
Returns:
|
|
99
|
+
Dict: Загруженный объект конфигурации.
|
|
100
|
+
"""
|
|
101
|
+
global _config_object, _config_loaded
|
|
102
|
+
if _config_loaded and _config_object is not None:
|
|
103
|
+
return _config_object
|
|
104
|
+
|
|
105
|
+
path = _get_config_path()
|
|
106
|
+
if not os.path.exists(path):
|
|
107
|
+
logger.critical(f"Файл конфигурации НЕ НАЙДЕН: {path}")
|
|
108
|
+
_config_object = {}
|
|
109
|
+
_config_loaded = True
|
|
110
|
+
return _config_object
|
|
111
|
+
|
|
112
|
+
file_ext = Path(path).suffix.lower()
|
|
113
|
+
|
|
114
|
+
try:
|
|
115
|
+
with open(path, 'r', encoding='utf-8') as f:
|
|
116
|
+
if file_ext in ['.yml', '.yaml']:
|
|
117
|
+
_config_object = yaml.safe_load(f)
|
|
118
|
+
logger.info(f"Конфигурация успешно загружена из YAML: {path}")
|
|
119
|
+
elif file_ext == '.ini':
|
|
120
|
+
parser = configparser.ConfigParser()
|
|
121
|
+
parser.read_string(f.read())
|
|
122
|
+
# Преобразуем объект ConfigParser в словарь
|
|
123
|
+
_config_object = {s: dict(parser.items(s)) for s in parser.sections()}
|
|
124
|
+
logger.info(f"Конфигурация успешно загружена из INI: {path}")
|
|
125
|
+
else:
|
|
126
|
+
_config_object = {}
|
|
127
|
+
logger.warning(f"Неподдерживаемый формат файла конфигурации: {path}")
|
|
128
|
+
|
|
129
|
+
except (yaml.YAMLError, configparser.Error) as e:
|
|
130
|
+
logger.critical(f"Ошибка чтения файла конфигурации {path}: {e}")
|
|
131
|
+
_config_object = {}
|
|
132
|
+
|
|
133
|
+
if _config_object is None:
|
|
134
|
+
_config_object = {}
|
|
135
|
+
|
|
136
|
+
_config_loaded = True
|
|
137
|
+
return _config_object
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def save_config_value(section: str, key: str, value: str, cfg_file: Optional[str] = None) -> bool:
|
|
141
|
+
"""
|
|
142
|
+
Сохраняет одно значение в конфигурационном файле.
|
|
143
|
+
ВАЖНО: Эта функция работает только для файлов `.ini` и спроектирована так,
|
|
144
|
+
чтобы сохранять комментарии и структуру исходного файла.
|
|
145
|
+
При работе с `.yml` файлами она вернет `False`.
|
|
146
|
+
"""
|
|
147
|
+
path = _get_config_path(cfg_file)
|
|
148
|
+
file_ext = Path(path).suffix.lower()
|
|
149
|
+
|
|
150
|
+
# Защита: работаем только с .ini файлами
|
|
151
|
+
if file_ext != '.ini':
|
|
152
|
+
logger.warning(f"Сохранение поддерживается только для .ini файлов. Файл {path} не будет изменен.")
|
|
153
|
+
return False
|
|
154
|
+
|
|
155
|
+
if not os.path.exists(path):
|
|
156
|
+
logger.error(f"Невозможно сохранить значение: файл конфигурации {path} не найден.")
|
|
157
|
+
return False
|
|
158
|
+
|
|
159
|
+
try:
|
|
160
|
+
with open(path, 'r', encoding='utf-8') as f:
|
|
161
|
+
lines = f.readlines()
|
|
162
|
+
except IOError as e:
|
|
163
|
+
logger.error(f"Ошибка чтения файла {path} для сохранения: {e}")
|
|
164
|
+
return False
|
|
165
|
+
|
|
166
|
+
updated = False
|
|
167
|
+
in_target_section = False
|
|
168
|
+
section_found = False
|
|
169
|
+
key_found_in_section = False
|
|
170
|
+
section_pattern = re.compile(r'^\s*\[\s*(?P<section_name>[^]]+)\s*\]\s*')
|
|
171
|
+
key_pattern = re.compile(rf'^\s*({re.escape(key)})\s*=\s*(.*)', re.IGNORECASE)
|
|
172
|
+
|
|
173
|
+
new_lines = []
|
|
174
|
+
for line in lines:
|
|
175
|
+
section_match = section_pattern.match(line)
|
|
176
|
+
if section_match:
|
|
177
|
+
current_section_name = section_match.group('section_name').strip()
|
|
178
|
+
if current_section_name.lower() == section.lower():
|
|
179
|
+
in_target_section = True
|
|
180
|
+
section_found = True
|
|
181
|
+
else:
|
|
182
|
+
in_target_section = False
|
|
183
|
+
new_lines.append(line)
|
|
184
|
+
continue
|
|
185
|
+
|
|
186
|
+
if in_target_section and not key_found_in_section:
|
|
187
|
+
key_match = key_pattern.match(line)
|
|
188
|
+
if key_match:
|
|
189
|
+
original_key = key_match.group(1)
|
|
190
|
+
new_line_content = f"{original_key} = {value}\n"
|
|
191
|
+
new_lines.append(new_line_content)
|
|
192
|
+
key_found_in_section = True
|
|
193
|
+
updated = True
|
|
194
|
+
logger.info(f"Ключ '{key}' в секции '[{section}]' будет обновлен на '{value}' в файле {path}")
|
|
195
|
+
continue
|
|
196
|
+
|
|
197
|
+
new_lines.append(line)
|
|
198
|
+
|
|
199
|
+
if not section_found:
|
|
200
|
+
logger.warning(f"Секция '[{section}]' не найдена в файле {path}. Значение НЕ сохранено.")
|
|
201
|
+
return False
|
|
202
|
+
if section_found and not key_found_in_section:
|
|
203
|
+
logger.warning(f"Ключ '{key}' не найден в секции '[{section}]' файла {path}. Значение НЕ сохранено.")
|
|
204
|
+
return False
|
|
205
|
+
|
|
206
|
+
if updated:
|
|
207
|
+
try:
|
|
208
|
+
with open(path, 'w', encoding='utf-8') as f:
|
|
209
|
+
f.writelines(new_lines)
|
|
210
|
+
logger.info(f"Файл конфигурации {path} успешно обновлен.")
|
|
211
|
+
return True
|
|
212
|
+
except IOError as e:
|
|
213
|
+
logger.error(f"Ошибка записи в файл {path} при сохранении: {e}")
|
|
214
|
+
return False
|
|
215
|
+
else:
|
|
216
|
+
logger.debug(f"Обновление для ключа '{key}' в секции '[{section}]' не потребовалось.")
|
|
217
|
+
return False
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
# --- Функции-обертки для удобного получения значений ---
|
|
221
|
+
|
|
222
|
+
def get_config_value(section: str, key: str, fallback: Any = "", config: Optional[Dict] = None) -> Any:
|
|
223
|
+
"""Получает значение из конфигурации."""
|
|
224
|
+
if config is None: config = get_config()
|
|
225
|
+
return config.get(section, {}).get(key, fallback)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def get_config_int(section: str, key: str, fallback: int = 0, config: Optional[Dict] = None) -> int:
|
|
229
|
+
"""Получает целочисленное значение."""
|
|
230
|
+
value = get_config_value(section, key, fallback, config)
|
|
231
|
+
try:
|
|
232
|
+
return int(value)
|
|
233
|
+
except (ValueError, TypeError):
|
|
234
|
+
return fallback
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def get_config_float(section: str, key: str, fallback: float = 0.0, config: Optional[Dict] = None) -> float:
|
|
238
|
+
"""Получает дробное значение."""
|
|
239
|
+
value = get_config_value(section, key, fallback, config)
|
|
240
|
+
try:
|
|
241
|
+
return float(value)
|
|
242
|
+
except (ValueError, TypeError):
|
|
243
|
+
return fallback
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def get_config_boolean(section: str, key: str, fallback: bool = False, config: Optional[Dict] = None) -> bool:
|
|
247
|
+
"""Получает булево значение."""
|
|
248
|
+
value = get_config_value(section, key, fallback, config)
|
|
249
|
+
if isinstance(value, bool):
|
|
250
|
+
return value
|
|
251
|
+
if str(value).lower() in ['true', '1', 't', 'y', 'yes']:
|
|
252
|
+
return True
|
|
253
|
+
if str(value).lower() in ['false', '0', 'f', 'n', 'no']:
|
|
254
|
+
return False
|
|
255
|
+
return fallback
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def get_config_list(
|
|
259
|
+
section: str,
|
|
260
|
+
key: str,
|
|
261
|
+
fallback: Optional[List[Any]] = None,
|
|
262
|
+
config: Optional[Dict] = None) -> List[Any]:
|
|
263
|
+
"""Получает значение как список."""
|
|
264
|
+
value = get_config_value(section, key, fallback, config)
|
|
265
|
+
if isinstance(value, list):
|
|
266
|
+
return value
|
|
267
|
+
if fallback is None:
|
|
268
|
+
return []
|
|
269
|
+
return fallback
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def get_config_section(section_name: str, fallback: Optional[Dict] = None, config: Optional[Dict] = None) -> Dict[
|
|
273
|
+
str,
|
|
274
|
+
Any]:
|
|
275
|
+
"""Получает всю секцию как словарь."""
|
|
276
|
+
if config is None: config = get_config()
|
|
277
|
+
return config.get(section_name, fallback if fallback is not None else {})
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Модуль для настройки логирования.
|
|
3
|
+
|
|
4
|
+
Предоставляет унифицированную функцию setup_logger для создания и настройки логгеров,
|
|
5
|
+
которые могут выводить сообщения в консоль и в файлы с автоматической ротацией.
|
|
6
|
+
Директория для логов ('logs') создается автоматически в корне проекта.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
import logging.handlers
|
|
11
|
+
import os
|
|
12
|
+
from typing import Optional
|
|
13
|
+
|
|
14
|
+
# Импортируем наш модуль config для доступа к путям и настройкам
|
|
15
|
+
from . import config
|
|
16
|
+
|
|
17
|
+
# --- Пользовательские уровни логирования ---
|
|
18
|
+
# Для более гранулярного контроля над отладочными сообщениями.
|
|
19
|
+
|
|
20
|
+
DEVDEBUG_LEVEL_NUM = 9
|
|
21
|
+
DEVDEBUG_LEVEL_NAME = "DEVDEBUG"
|
|
22
|
+
MEDIUMDEBUG_LEVEL_NUM = 15
|
|
23
|
+
MEDIUMDEBUG_LEVEL_NAME = "MEDIUMDEBUG"
|
|
24
|
+
|
|
25
|
+
logging.addLevelName(MEDIUMDEBUG_LEVEL_NUM, MEDIUMDEBUG_LEVEL_NAME)
|
|
26
|
+
logging.addLevelName(DEVDEBUG_LEVEL_NUM, DEVDEBUG_LEVEL_NAME)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ChutilsLogger(logging.Logger):
|
|
30
|
+
"""
|
|
31
|
+
Кастомный класс логгера, который расширяет стандартный `logging.Logger`.
|
|
32
|
+
|
|
33
|
+
Основная цель этого класса — добавить поддержку пользовательских уровней
|
|
34
|
+
логирования (`devdebug` и `mediumdebug`), обеспечивая при этом
|
|
35
|
+
корректную работу статических анализаторов и автодополнения в IDE.
|
|
36
|
+
|
|
37
|
+
Вам не нужно создавать экземпляр этого класса напрямую. Используйте
|
|
38
|
+
функцию `setup_logger()`, которая автоматически вернет объект этого типа.
|
|
39
|
+
|
|
40
|
+
:Example:
|
|
41
|
+
from chutils.logger import setup_logger, ChutilsLogger
|
|
42
|
+
|
|
43
|
+
# Используем наш класс для аннотации типа, чтобы IDE давала подсказки
|
|
44
|
+
logger: ChutilsLogger = setup_logger()
|
|
45
|
+
|
|
46
|
+
# Теперь IDE знает об этом методе и не будет показывать предупреждений
|
|
47
|
+
logger.mediumdebug("Это сообщение с автодополнением.")
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
def mediumdebug(self, message, *args, **kws):
|
|
51
|
+
"""Логирует сообщение с уровнем MEDIUMDEBUG (15)."""
|
|
52
|
+
if self.isEnabledFor(MEDIUMDEBUG_LEVEL_NUM):
|
|
53
|
+
self._log(MEDIUMDEBUG_LEVEL_NUM, message, args, **kws)
|
|
54
|
+
|
|
55
|
+
def devdebug(self, message, *args, **kws):
|
|
56
|
+
"""Логирует сообщение с уровнем DEVDEBUG (9)."""
|
|
57
|
+
if self.isEnabledFor(DEVDEBUG_LEVEL_NUM):
|
|
58
|
+
self._log(DEVDEBUG_LEVEL_NUM, message, args, **kws)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
logging.setLoggerClass(ChutilsLogger)
|
|
62
|
+
|
|
63
|
+
# --- Глобальное состояние для "ленивой" инициализации ---
|
|
64
|
+
|
|
65
|
+
# Кэш для пути к директории логов. Изначально пуст.
|
|
66
|
+
_LOG_DIR: Optional[str] = None
|
|
67
|
+
# Глобальный экземпляр основного логгера приложения
|
|
68
|
+
_logger_instance: Optional[ChutilsLogger] = None
|
|
69
|
+
# Флаг, чтобы сообщение об инициализации выводилось только один раз
|
|
70
|
+
_initialization_message_shown = False
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _get_log_dir() -> Optional[str]:
|
|
74
|
+
"""
|
|
75
|
+
"Лениво" получает и кэширует путь к директории логов.
|
|
76
|
+
|
|
77
|
+
При первом вызове:
|
|
78
|
+
1. Запускает поиск корня проекта через модуль config.
|
|
79
|
+
2. Создает директорию 'logs' в корне проекта, если ее нет.
|
|
80
|
+
3. Кэширует результат.
|
|
81
|
+
При последующих вызовах немедленно возвращает кэшированный путь.
|
|
82
|
+
"""
|
|
83
|
+
global _LOG_DIR
|
|
84
|
+
# Если путь уже кэширован, сразу возвращаем его.
|
|
85
|
+
if _LOG_DIR is not None:
|
|
86
|
+
return _LOG_DIR
|
|
87
|
+
|
|
88
|
+
# Запускаем инициализацию в config, если она еще не была выполнена.
|
|
89
|
+
# Это "сердце" автоматического обнаружения.
|
|
90
|
+
config._initialize_paths()
|
|
91
|
+
|
|
92
|
+
# Берем найденный config'ом базовый каталог проекта.
|
|
93
|
+
base_dir = config._BASE_DIR
|
|
94
|
+
|
|
95
|
+
# Если корень проекта не был найден, файловое логирование невозможно.
|
|
96
|
+
if not base_dir:
|
|
97
|
+
print("ПРЕДУПРЕЖДЕНИЕ: Не удалось определить корень проекта, файловое логирование будет отключено.")
|
|
98
|
+
return None
|
|
99
|
+
|
|
100
|
+
# Создаем путь к директории логов и саму директорию, если нужно.
|
|
101
|
+
log_path = os.path.join(base_dir, 'logs')
|
|
102
|
+
if not os.path.exists(log_path):
|
|
103
|
+
try:
|
|
104
|
+
os.makedirs(log_path)
|
|
105
|
+
print(f"INFO: Создана директория для логов: {log_path}")
|
|
106
|
+
except OSError as e:
|
|
107
|
+
# Если не удалось создать директорию, логирование в файл будет невозможно.
|
|
108
|
+
print(f"ОШИБКА: Не удалось создать директорию для логов {log_path}: {e}")
|
|
109
|
+
return None
|
|
110
|
+
|
|
111
|
+
# Кэшируем успешный результат и возвращаем его.
|
|
112
|
+
_LOG_DIR = log_path
|
|
113
|
+
return _LOG_DIR
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def setup_logger(name: str = 'app_logger', log_level_str: str = '') -> ChutilsLogger:
|
|
117
|
+
"""
|
|
118
|
+
Настраивает и возвращает логгер с нужным именем.
|
|
119
|
+
|
|
120
|
+
- Предотвращает повторную настройку уже существующего логгера.
|
|
121
|
+
- Читает настройки из config.ini (уровень, имя файла и т.д.).
|
|
122
|
+
- Добавляет обработчики для вывода в консоль и в файл с ежедневной ротацией.
|
|
123
|
+
|
|
124
|
+
Args:
|
|
125
|
+
name (str): Имя логгера. 'app_logger' используется для основного логгера приложения.
|
|
126
|
+
log_level_str (str, optional): Явное указание уровня логирования (например, 'DEBUG').
|
|
127
|
+
Если не задан, берется из конфига.
|
|
128
|
+
|
|
129
|
+
Returns:
|
|
130
|
+
logging.Logger: Настроенный экземпляр логгера.
|
|
131
|
+
"""
|
|
132
|
+
global _logger_instance, _initialization_message_shown
|
|
133
|
+
|
|
134
|
+
# Если логгер с таким именем уже имеет обработчики, значит он настроен.
|
|
135
|
+
# Просто возвращаем его, чтобы не дублировать вывод.
|
|
136
|
+
existing_logger = logging.getLogger(name)
|
|
137
|
+
if existing_logger.hasHandlers():
|
|
138
|
+
return existing_logger # type: ignore
|
|
139
|
+
|
|
140
|
+
# Если запрашивается основной логгер приложения и он уже есть в кэше.
|
|
141
|
+
if name == 'app_logger' and _logger_instance:
|
|
142
|
+
return _logger_instance
|
|
143
|
+
|
|
144
|
+
# Получаем директорию для логов. Это первая точка, где запускается вся магия поиска путей.
|
|
145
|
+
log_dir = _get_log_dir()
|
|
146
|
+
|
|
147
|
+
# Загружаем конфигурацию для получения настроек логирования.
|
|
148
|
+
cfg = config.get_config()
|
|
149
|
+
|
|
150
|
+
# Определяем уровень логирования
|
|
151
|
+
if not log_level_str:
|
|
152
|
+
log_level_str = config.get_config_value('Logging', 'log_level', 'INFO', cfg)
|
|
153
|
+
log_level = getattr(logging, log_level_str.upper(), logging.INFO)
|
|
154
|
+
|
|
155
|
+
# Получаем остальные настройки из конфига
|
|
156
|
+
log_file_name = config.get_config_value('Logging', 'log_file_name', 'app.log', cfg)
|
|
157
|
+
backup_count = config.get_config_int('Logging', 'log_backup_count', 3, cfg)
|
|
158
|
+
|
|
159
|
+
# Создаем и настраиваем новый экземпляр логгера
|
|
160
|
+
logger = logging.getLogger(name)
|
|
161
|
+
logger.setLevel(log_level)
|
|
162
|
+
formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
|
|
163
|
+
|
|
164
|
+
# 1. Обработчик для вывода в консоль (StreamHandler)
|
|
165
|
+
console_handler = logging.StreamHandler()
|
|
166
|
+
console_handler.setFormatter(formatter)
|
|
167
|
+
logger.addHandler(console_handler)
|
|
168
|
+
|
|
169
|
+
# 2. Обработчик для записи в файл (TimedRotatingFileHandler)
|
|
170
|
+
# Добавляем его, только если директория логов была успешно определена.
|
|
171
|
+
if log_dir and log_file_name:
|
|
172
|
+
log_file_path = os.path.join(log_dir, log_file_name)
|
|
173
|
+
try:
|
|
174
|
+
# Ротация каждый день ('D'), храним backup_count старых файлов
|
|
175
|
+
file_handler = logging.handlers.TimedRotatingFileHandler(
|
|
176
|
+
log_file_path,
|
|
177
|
+
when="D",
|
|
178
|
+
interval=1,
|
|
179
|
+
backupCount=backup_count,
|
|
180
|
+
encoding='utf-8'
|
|
181
|
+
)
|
|
182
|
+
file_handler.setFormatter(formatter)
|
|
183
|
+
logger.addHandler(file_handler)
|
|
184
|
+
|
|
185
|
+
# Выводим информационное сообщение только один раз для всего приложения
|
|
186
|
+
if not _initialization_message_shown:
|
|
187
|
+
logger.debug(
|
|
188
|
+
f"Логирование настроено. Уровень: {log_level_str}. "
|
|
189
|
+
f"Файл: {log_file_path}, ротация: {backup_count} дней."
|
|
190
|
+
)
|
|
191
|
+
_initialization_message_shown = True
|
|
192
|
+
except Exception as e:
|
|
193
|
+
logger.error(f"Не удалось настроить файловый обработчик логов для {log_file_path}: {e}")
|
|
194
|
+
else:
|
|
195
|
+
if not _initialization_message_shown:
|
|
196
|
+
logger.warning("Директория для логов не настроена. Файловое логирование отключено.")
|
|
197
|
+
_initialization_message_shown = True
|
|
198
|
+
|
|
199
|
+
# Кэшируем основной логгер приложения
|
|
200
|
+
if name == 'app_logger':
|
|
201
|
+
_logger_instance = logger
|
|
202
|
+
|
|
203
|
+
return logger # type: ignore
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import keyring
|
|
2
|
+
from keyring.errors import NoKeyringError, PasswordDeleteError
|
|
3
|
+
from typing import Optional
|
|
4
|
+
from . import logger as logging
|
|
5
|
+
|
|
6
|
+
logger = logging.setup_logger(__name__)
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class SecretManager:
|
|
10
|
+
"""
|
|
11
|
+
Универсальный менеджер для безопасного хранения и получения секретов
|
|
12
|
+
с использованием системного хранилища (keyring).
|
|
13
|
+
|
|
14
|
+
Изолирует секреты разных приложений по `service_name`.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
prefix: str = "Chutils_"
|
|
18
|
+
|
|
19
|
+
def __init__(self, service_name: str) -> None:
|
|
20
|
+
"""
|
|
21
|
+
Инициализирует менеджер для конкретного сервиса (приложения).
|
|
22
|
+
|
|
23
|
+
:param service_name: Уникальное имя для твоего приложения.
|
|
24
|
+
Например, 'my_super_app' или 'project_alpha_db'.
|
|
25
|
+
"""
|
|
26
|
+
if not service_name or not isinstance(service_name, str):
|
|
27
|
+
raise ValueError("service_name должен быть непустой строкой.")
|
|
28
|
+
self.service_name: str = self.prefix + service_name
|
|
29
|
+
logger.devdebug(f"Менеджер секретов инициализирован для сервиса: '{self.service_name}'")
|
|
30
|
+
|
|
31
|
+
def save_secret(self, key: str, value: str) -> bool:
|
|
32
|
+
"""
|
|
33
|
+
Сохраняет пару ключ-значение в системном хранилище.
|
|
34
|
+
Если ключ уже существует, его значение будет перезаписано.
|
|
35
|
+
|
|
36
|
+
:param key: Ключ для секрета (например, 'db_password' или 'api_token').
|
|
37
|
+
:param value: Секретное значение, которое нужно сохранить.
|
|
38
|
+
"""
|
|
39
|
+
try:
|
|
40
|
+
keyring.set_password(self.service_name, key, value)
|
|
41
|
+
logger.devdebug(f"Секрет для ключа '{key}' успешно сохранен.")
|
|
42
|
+
return True
|
|
43
|
+
except NoKeyringError:
|
|
44
|
+
logger.error("Ошибка: системное хранилище (keyring) не найдено. Секрет не сохранен.")
|
|
45
|
+
return False
|
|
46
|
+
except Exception as e:
|
|
47
|
+
logger.error(f"Произошла непредвиденная ошибка при сохранении секрета: {e}")
|
|
48
|
+
return False
|
|
49
|
+
|
|
50
|
+
def get_secret(self, key: str) -> Optional[str]:
|
|
51
|
+
"""
|
|
52
|
+
Получает секретное значение по ключу из системного хранилища.
|
|
53
|
+
|
|
54
|
+
:param key: Ключ, по которому нужно найти секрет.
|
|
55
|
+
:return: Сохраненное значение или None, если ключ не найден.
|
|
56
|
+
"""
|
|
57
|
+
try:
|
|
58
|
+
value = keyring.get_password(self.service_name, key)
|
|
59
|
+
if value is None:
|
|
60
|
+
logger.devdebug(f"Секрет для ключа '{key}' не найден.")
|
|
61
|
+
else:
|
|
62
|
+
logger.devdebug(f"Секрет для ключа '{key}' получен.")
|
|
63
|
+
return value
|
|
64
|
+
except NoKeyringError:
|
|
65
|
+
logger.critical("Ошибка: системное хранилище (keyring) не найдено. Невозможно получить секрет.")
|
|
66
|
+
return None
|
|
67
|
+
except Exception as e:
|
|
68
|
+
logger.error(f"Произошла непредвиденная ошибка при получении секрета: {e}")
|
|
69
|
+
return None
|
|
70
|
+
|
|
71
|
+
def delete_secret(self, key: str) -> bool:
|
|
72
|
+
"""
|
|
73
|
+
Удаляет пару ключ-значение из системного хранилища.
|
|
74
|
+
|
|
75
|
+
:param key: Ключ, который нужно удалить.
|
|
76
|
+
"""
|
|
77
|
+
try:
|
|
78
|
+
# Сначала проверим, есть ли что удалять, для более понятного вывода
|
|
79
|
+
if self.get_secret(key) is None:
|
|
80
|
+
# Сообщение об отсутствии секрета уже будет выведено из get_secret
|
|
81
|
+
return True
|
|
82
|
+
|
|
83
|
+
keyring.delete_password(self.service_name, key)
|
|
84
|
+
logger.devdebug(f"Секрет для ключа '{key}' успешно удален.")
|
|
85
|
+
return True
|
|
86
|
+
except PasswordDeleteError:
|
|
87
|
+
logger.error(f"Ошибка: не удалось удалить секрет для ключа '{key}'.")
|
|
88
|
+
return False
|
|
89
|
+
except NoKeyringError:
|
|
90
|
+
logger.critical("Ошибка: системное хранилище (keyring) не найдено. Невозможно удалить секрет.")
|
|
91
|
+
return False
|
|
92
|
+
except Exception as e:
|
|
93
|
+
logger.error(f"Произошла непредвиденная ошибка при удалении секрета: {e}")
|
|
94
|
+
return False
|
|
95
|
+
|
|
96
|
+
def update_secret(self, key: str, value: str) -> bool:
|
|
97
|
+
"""
|
|
98
|
+
Обновляет значение для существующего ключа.
|
|
99
|
+
Это псевдоним для функции save_secret, так как keyring перезаписывает значение.
|
|
100
|
+
|
|
101
|
+
:param key: Ключ для секрета (например, 'db_password' или 'api_token').
|
|
102
|
+
:param value: Секретное значение, которое нужно сохранить.
|
|
103
|
+
"""
|
|
104
|
+
logger.devdebug(f"Обновление секрета для ключа '{key}'...")
|
|
105
|
+
return self.save_secret(key, value)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
# --- Пример использования ---
|
|
109
|
+
# Этот блок выполнится, только если запустить этот файл напрямую (python secret_manager.py)
|
|
110
|
+
if __name__ == '__main__':
|
|
111
|
+
# 1. Создаем экземпляр менеджера для нашего приложения "my_test_project"
|
|
112
|
+
secrets = SecretManager("my_test_project")
|
|
113
|
+
|
|
114
|
+
# 2. Определяем ключ для пароля от базы данных
|
|
115
|
+
db_password_key = "postgres_password"
|
|
116
|
+
|
|
117
|
+
# 3. Сохраняем пароль
|
|
118
|
+
secrets.save_secret(db_password_key, "MySuperSecretPassword123!")
|
|
119
|
+
|
|
120
|
+
# 4. Получаем его обратно
|
|
121
|
+
retrieved_password = secrets.get_secret(db_password_key)
|
|
122
|
+
if retrieved_password:
|
|
123
|
+
print(f" -> Полученный пароль: {retrieved_password}")
|
|
124
|
+
|
|
125
|
+
# 5. Пробуем получить несуществующий ключ
|
|
126
|
+
secrets.get_secret("non_existent_key")
|
|
127
|
+
|
|
128
|
+
# 6. Обновляем пароль
|
|
129
|
+
secrets.update_secret(db_password_key, "NewPassword456!")
|
|
130
|
+
retrieved_password_after_update = secrets.get_secret(db_password_key)
|
|
131
|
+
if retrieved_password_after_update:
|
|
132
|
+
print(f" -> Пароль после обновления: {retrieved_password_after_update}")
|
|
133
|
+
|
|
134
|
+
# 7. Удаляем пароль
|
|
135
|
+
secrets.delete_secret(db_password_key)
|
|
136
|
+
|
|
137
|
+
# 8. Убеждаемся, что он удален
|
|
138
|
+
secrets.get_secret(db_password_key)
|