weblite-framework 0.1.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.
- weblite_framework-0.1.0/PKG-INFO +172 -0
- weblite_framework-0.1.0/README.md +157 -0
- weblite_framework-0.1.0/pyproject.toml +78 -0
- weblite_framework-0.1.0/weblite_framework/database/models.py +12 -0
- weblite_framework-0.1.0/weblite_framework/logging/__init__.py +0 -0
- weblite_framework-0.1.0/weblite_framework/logging/logger.py +96 -0
- weblite_framework-0.1.0/weblite_framework/provider/__init__.py +0 -0
- weblite_framework-0.1.0/weblite_framework/provider/s3.py +211 -0
- weblite_framework-0.1.0/weblite_framework/repository/__init__.py +5 -0
- weblite_framework-0.1.0/weblite_framework/repository/base.py +225 -0
- weblite_framework-0.1.0/weblite_framework/schemas/__init__.py +0 -0
- weblite_framework-0.1.0/weblite_framework/schemas/base.py +103 -0
- weblite_framework-0.1.0/weblite_framework/settings/__init__.py +0 -0
- weblite_framework-0.1.0/weblite_framework/settings/s3.py +42 -0
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: weblite_framework
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary:
|
|
5
|
+
Author: Чернов Михаил
|
|
6
|
+
Author-email: mihail.tchernov@yandex.ru
|
|
7
|
+
Requires-Python: ==3.12.6
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Requires-Dist: aioboto3 (==15.1.0)
|
|
10
|
+
Requires-Dist: pydantic-settings (==2.10.1)
|
|
11
|
+
Requires-Dist: pydantic[email] (==2.11.7)
|
|
12
|
+
Requires-Dist: sqlalchemy[asyncio] (==2.0.36)
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
|
|
15
|
+
# Weblite Framework
|
|
16
|
+
|
|
17
|
+
Базовый фреймворк для веб-приложений.
|
|
18
|
+
|
|
19
|
+
## Установка
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
pip install weblite-framework
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## BaseRepositoryClass - Базовый класс для репозиториев
|
|
26
|
+
|
|
27
|
+
`BaseRepositoryClass` предоставляет базовую функциональность для работы с базой данных через SQLAlchemy. Этот класс реализует паттерн Repository и обеспечивает типизированную работу с ORM моделями и DTO объектами.
|
|
28
|
+
|
|
29
|
+
### Основные возможности
|
|
30
|
+
|
|
31
|
+
- **Типизированная работа** с Generic типами `BaseRepositoryClass[DTO, SQLModel]`
|
|
32
|
+
- **Абстрактные методы** для маппинга между моделями и DTO
|
|
33
|
+
- **Управление транзакциями** с гибкими настройками
|
|
34
|
+
- **Базовые операции** CRUD для работы с БД
|
|
35
|
+
|
|
36
|
+
### Использование
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
from typing import TypeVar
|
|
40
|
+
from weblite_framework.repository.base import BaseRepositoryClass
|
|
41
|
+
from weblite_framework.database.models import BaseModel
|
|
42
|
+
|
|
43
|
+
# Определяем типы
|
|
44
|
+
class UserDTO:
|
|
45
|
+
def __init__(self, id_: int, name: str):
|
|
46
|
+
self.id_ = id_
|
|
47
|
+
self.name = name
|
|
48
|
+
|
|
49
|
+
class UserModel(BaseModel):
|
|
50
|
+
id_: int
|
|
51
|
+
name: str
|
|
52
|
+
|
|
53
|
+
# Создаем репозиторий
|
|
54
|
+
class UserRepository(BaseRepositoryClass[UserDTO, UserModel]):
|
|
55
|
+
def _model_to_dto(self, model: UserModel) -> UserDTO:
|
|
56
|
+
return UserDTO(id_=model.id_, name=model.name)
|
|
57
|
+
|
|
58
|
+
def _dto_to_model(self, dto: UserDTO) -> UserModel:
|
|
59
|
+
model = UserModel()
|
|
60
|
+
model.id_ = dto.id_
|
|
61
|
+
model.name = dto.name
|
|
62
|
+
return model
|
|
63
|
+
|
|
64
|
+
# Использование
|
|
65
|
+
async def create_user(session: AsyncSession):
|
|
66
|
+
repo = UserRepository(session=session)
|
|
67
|
+
|
|
68
|
+
# Создание записи
|
|
69
|
+
user_model = UserModel()
|
|
70
|
+
user_model.name = "John"
|
|
71
|
+
created_user = await repo._add_record(model=user_model)
|
|
72
|
+
|
|
73
|
+
# Обновление записи
|
|
74
|
+
existing_user = UserModel()
|
|
75
|
+
existing_user.id_ = 1
|
|
76
|
+
existing_user.name = "Old Name"
|
|
77
|
+
|
|
78
|
+
new_data = UserModel()
|
|
79
|
+
new_data.name = "New Name"
|
|
80
|
+
|
|
81
|
+
updated_user = await repo._update(
|
|
82
|
+
existing_model=existing_user,
|
|
83
|
+
new_data=new_data
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
# Выполнение запросов
|
|
87
|
+
from sqlalchemy import select
|
|
88
|
+
query = select(UserModel).where(UserModel.id_ == 1)
|
|
89
|
+
result = await repo.execute(statement=query, is_use_active_transaction=False)
|
|
90
|
+
|
|
91
|
+
# Коммит изменений
|
|
92
|
+
await repo.commit()
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
### Основные методы
|
|
96
|
+
|
|
97
|
+
#### `_add_record(model: SQLModel) -> SQLModel`
|
|
98
|
+
Создает новую запись в базе данных и возвращает модель с присвоенным идентификатором.
|
|
99
|
+
|
|
100
|
+
#### `_update(existing_model: SQLModel, new_data: SQLModel, ignore_fields: list[str] | None = None) -> SQLModel`
|
|
101
|
+
Обновляет существующую запись в базе данных. Поле `_sa_instance_state` всегда игнорируется.
|
|
102
|
+
|
|
103
|
+
#### `execute(statement: Executable, is_use_active_transaction: bool = True) -> Result[Any]`
|
|
104
|
+
Выполняет SQL запрос с возможностью управления транзакциями:
|
|
105
|
+
- `is_use_active_transaction=True` - использует активную транзакцию
|
|
106
|
+
- `is_use_active_transaction=False` - создает новую транзакцию
|
|
107
|
+
|
|
108
|
+
#### `commit() -> None`
|
|
109
|
+
Выполняет коммит текущей транзакции.
|
|
110
|
+
|
|
111
|
+
#### `flush() -> None`
|
|
112
|
+
Сбрасывает изменения в базу данных без коммита.
|
|
113
|
+
|
|
114
|
+
#### `refresh(instance: SQLModel) -> None`
|
|
115
|
+
Обновляет состояние модели из базы данных.
|
|
116
|
+
|
|
117
|
+
### Абстрактные методы
|
|
118
|
+
|
|
119
|
+
#### `_model_to_dto(model: SQLModel) -> DTO`
|
|
120
|
+
Преобразует ORM модель в DTO объект. Должен быть реализован в дочернем классе.
|
|
121
|
+
|
|
122
|
+
#### `_dto_to_model(dto: DTO) -> SQLModel`
|
|
123
|
+
Преобразует DTO объект в ORM модель. Должен быть реализован в дочернем классе.
|
|
124
|
+
|
|
125
|
+
### Пример выполнения кастомных запросов
|
|
126
|
+
|
|
127
|
+
```python
|
|
128
|
+
from sqlalchemy import text
|
|
129
|
+
|
|
130
|
+
# Выполнение raw SQL
|
|
131
|
+
query = text("SELECT * FROM users WHERE age > :min_age")
|
|
132
|
+
result = await repo.execute(
|
|
133
|
+
statement=query,
|
|
134
|
+
is_use_active_transaction=False
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
# Выполнение в рамках активной транзакции
|
|
138
|
+
async with session.begin():
|
|
139
|
+
await repo._add_record(model=user_model)
|
|
140
|
+
await repo.execute(statement=query, is_use_active_transaction=True)
|
|
141
|
+
# Все операции выполняются в одной транзакции
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
### Примечания
|
|
145
|
+
|
|
146
|
+
- Все методы работают асинхронно
|
|
147
|
+
- Класс использует SQLAlchemy 2.0+ синтаксис
|
|
148
|
+
- Поддерживает типизацию через Generic типы
|
|
149
|
+
- Следует паттерну Repository для разделения логики доступа к данным
|
|
150
|
+
|
|
151
|
+
### Обработка ошибок и Rollback
|
|
152
|
+
|
|
153
|
+
Все транзакционные методы автоматически выполняют rollback при возникновении исключений:
|
|
154
|
+
|
|
155
|
+
```python
|
|
156
|
+
# При ошибке в любом методе автоматически выполняется rollback
|
|
157
|
+
try:
|
|
158
|
+
await repo._add_record(model=user_model)
|
|
159
|
+
await repo.commit()
|
|
160
|
+
except Exception as e:
|
|
161
|
+
# Транзакция уже откачена автоматически
|
|
162
|
+
print(f"Ошибка: {e}")
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
**Методы с автоматическим rollback:**
|
|
166
|
+
- `_add_record()` - при ошибке создания записи
|
|
167
|
+
- `_update()` - при ошибке обновления записи
|
|
168
|
+
- `commit()` - при ошибке коммита
|
|
169
|
+
- `execute()` - при ошибке выполнения запроса
|
|
170
|
+
- `refresh()` - при ошибке обновления модели
|
|
171
|
+
- `flush()` - при ошибке сброса изменений
|
|
172
|
+
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
# Weblite Framework
|
|
2
|
+
|
|
3
|
+
Базовый фреймворк для веб-приложений.
|
|
4
|
+
|
|
5
|
+
## Установка
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install weblite-framework
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## BaseRepositoryClass - Базовый класс для репозиториев
|
|
12
|
+
|
|
13
|
+
`BaseRepositoryClass` предоставляет базовую функциональность для работы с базой данных через SQLAlchemy. Этот класс реализует паттерн Repository и обеспечивает типизированную работу с ORM моделями и DTO объектами.
|
|
14
|
+
|
|
15
|
+
### Основные возможности
|
|
16
|
+
|
|
17
|
+
- **Типизированная работа** с Generic типами `BaseRepositoryClass[DTO, SQLModel]`
|
|
18
|
+
- **Абстрактные методы** для маппинга между моделями и DTO
|
|
19
|
+
- **Управление транзакциями** с гибкими настройками
|
|
20
|
+
- **Базовые операции** CRUD для работы с БД
|
|
21
|
+
|
|
22
|
+
### Использование
|
|
23
|
+
|
|
24
|
+
```python
|
|
25
|
+
from typing import TypeVar
|
|
26
|
+
from weblite_framework.repository.base import BaseRepositoryClass
|
|
27
|
+
from weblite_framework.database.models import BaseModel
|
|
28
|
+
|
|
29
|
+
# Определяем типы
|
|
30
|
+
class UserDTO:
|
|
31
|
+
def __init__(self, id_: int, name: str):
|
|
32
|
+
self.id_ = id_
|
|
33
|
+
self.name = name
|
|
34
|
+
|
|
35
|
+
class UserModel(BaseModel):
|
|
36
|
+
id_: int
|
|
37
|
+
name: str
|
|
38
|
+
|
|
39
|
+
# Создаем репозиторий
|
|
40
|
+
class UserRepository(BaseRepositoryClass[UserDTO, UserModel]):
|
|
41
|
+
def _model_to_dto(self, model: UserModel) -> UserDTO:
|
|
42
|
+
return UserDTO(id_=model.id_, name=model.name)
|
|
43
|
+
|
|
44
|
+
def _dto_to_model(self, dto: UserDTO) -> UserModel:
|
|
45
|
+
model = UserModel()
|
|
46
|
+
model.id_ = dto.id_
|
|
47
|
+
model.name = dto.name
|
|
48
|
+
return model
|
|
49
|
+
|
|
50
|
+
# Использование
|
|
51
|
+
async def create_user(session: AsyncSession):
|
|
52
|
+
repo = UserRepository(session=session)
|
|
53
|
+
|
|
54
|
+
# Создание записи
|
|
55
|
+
user_model = UserModel()
|
|
56
|
+
user_model.name = "John"
|
|
57
|
+
created_user = await repo._add_record(model=user_model)
|
|
58
|
+
|
|
59
|
+
# Обновление записи
|
|
60
|
+
existing_user = UserModel()
|
|
61
|
+
existing_user.id_ = 1
|
|
62
|
+
existing_user.name = "Old Name"
|
|
63
|
+
|
|
64
|
+
new_data = UserModel()
|
|
65
|
+
new_data.name = "New Name"
|
|
66
|
+
|
|
67
|
+
updated_user = await repo._update(
|
|
68
|
+
existing_model=existing_user,
|
|
69
|
+
new_data=new_data
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
# Выполнение запросов
|
|
73
|
+
from sqlalchemy import select
|
|
74
|
+
query = select(UserModel).where(UserModel.id_ == 1)
|
|
75
|
+
result = await repo.execute(statement=query, is_use_active_transaction=False)
|
|
76
|
+
|
|
77
|
+
# Коммит изменений
|
|
78
|
+
await repo.commit()
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
### Основные методы
|
|
82
|
+
|
|
83
|
+
#### `_add_record(model: SQLModel) -> SQLModel`
|
|
84
|
+
Создает новую запись в базе данных и возвращает модель с присвоенным идентификатором.
|
|
85
|
+
|
|
86
|
+
#### `_update(existing_model: SQLModel, new_data: SQLModel, ignore_fields: list[str] | None = None) -> SQLModel`
|
|
87
|
+
Обновляет существующую запись в базе данных. Поле `_sa_instance_state` всегда игнорируется.
|
|
88
|
+
|
|
89
|
+
#### `execute(statement: Executable, is_use_active_transaction: bool = True) -> Result[Any]`
|
|
90
|
+
Выполняет SQL запрос с возможностью управления транзакциями:
|
|
91
|
+
- `is_use_active_transaction=True` - использует активную транзакцию
|
|
92
|
+
- `is_use_active_transaction=False` - создает новую транзакцию
|
|
93
|
+
|
|
94
|
+
#### `commit() -> None`
|
|
95
|
+
Выполняет коммит текущей транзакции.
|
|
96
|
+
|
|
97
|
+
#### `flush() -> None`
|
|
98
|
+
Сбрасывает изменения в базу данных без коммита.
|
|
99
|
+
|
|
100
|
+
#### `refresh(instance: SQLModel) -> None`
|
|
101
|
+
Обновляет состояние модели из базы данных.
|
|
102
|
+
|
|
103
|
+
### Абстрактные методы
|
|
104
|
+
|
|
105
|
+
#### `_model_to_dto(model: SQLModel) -> DTO`
|
|
106
|
+
Преобразует ORM модель в DTO объект. Должен быть реализован в дочернем классе.
|
|
107
|
+
|
|
108
|
+
#### `_dto_to_model(dto: DTO) -> SQLModel`
|
|
109
|
+
Преобразует DTO объект в ORM модель. Должен быть реализован в дочернем классе.
|
|
110
|
+
|
|
111
|
+
### Пример выполнения кастомных запросов
|
|
112
|
+
|
|
113
|
+
```python
|
|
114
|
+
from sqlalchemy import text
|
|
115
|
+
|
|
116
|
+
# Выполнение raw SQL
|
|
117
|
+
query = text("SELECT * FROM users WHERE age > :min_age")
|
|
118
|
+
result = await repo.execute(
|
|
119
|
+
statement=query,
|
|
120
|
+
is_use_active_transaction=False
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
# Выполнение в рамках активной транзакции
|
|
124
|
+
async with session.begin():
|
|
125
|
+
await repo._add_record(model=user_model)
|
|
126
|
+
await repo.execute(statement=query, is_use_active_transaction=True)
|
|
127
|
+
# Все операции выполняются в одной транзакции
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
### Примечания
|
|
131
|
+
|
|
132
|
+
- Все методы работают асинхронно
|
|
133
|
+
- Класс использует SQLAlchemy 2.0+ синтаксис
|
|
134
|
+
- Поддерживает типизацию через Generic типы
|
|
135
|
+
- Следует паттерну Repository для разделения логики доступа к данным
|
|
136
|
+
|
|
137
|
+
### Обработка ошибок и Rollback
|
|
138
|
+
|
|
139
|
+
Все транзакционные методы автоматически выполняют rollback при возникновении исключений:
|
|
140
|
+
|
|
141
|
+
```python
|
|
142
|
+
# При ошибке в любом методе автоматически выполняется rollback
|
|
143
|
+
try:
|
|
144
|
+
await repo._add_record(model=user_model)
|
|
145
|
+
await repo.commit()
|
|
146
|
+
except Exception as e:
|
|
147
|
+
# Транзакция уже откачена автоматически
|
|
148
|
+
print(f"Ошибка: {e}")
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
**Методы с автоматическим rollback:**
|
|
152
|
+
- `_add_record()` - при ошибке создания записи
|
|
153
|
+
- `_update()` - при ошибке обновления записи
|
|
154
|
+
- `commit()` - при ошибке коммита
|
|
155
|
+
- `execute()` - при ошибке выполнения запроса
|
|
156
|
+
- `refresh()` - при ошибке обновления модели
|
|
157
|
+
- `flush()` - при ошибке сброса изменений
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
[tool.poetry]
|
|
2
|
+
name = "weblite_framework"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = ""
|
|
5
|
+
authors = ["Чернов Михаил <mihail.tchernov@yandex.ru>"]
|
|
6
|
+
readme = "README.md"
|
|
7
|
+
|
|
8
|
+
[tool.poetry.dependencies]
|
|
9
|
+
python = "3.12.6"
|
|
10
|
+
pydantic = { version = "2.11.7", extras = ["email"] }
|
|
11
|
+
pydantic-settings = "2.10.1"
|
|
12
|
+
aioboto3 = "15.1.0"
|
|
13
|
+
sqlalchemy = {extras = ["asyncio"], version = "2.0.36"}
|
|
14
|
+
|
|
15
|
+
[tool.poetry.group.test.dependencies]
|
|
16
|
+
pytest = "8.4.1"
|
|
17
|
+
pytest-asyncio = "1.1.0"
|
|
18
|
+
pyhamcrest = "2.1.0"
|
|
19
|
+
|
|
20
|
+
[tool.poetry.group.lint.dependencies]
|
|
21
|
+
isort = "6.0.1"
|
|
22
|
+
flake8 = "7.3.0"
|
|
23
|
+
flake8-quotes = "3.4.0"
|
|
24
|
+
flake8-annotations = "3.1.1"
|
|
25
|
+
flake8-docstrings = "1.7.0"
|
|
26
|
+
flake8-dunder-all = "0.5.0"
|
|
27
|
+
flake8-pyproject = "1.2.3"
|
|
28
|
+
black = "25.1.0"
|
|
29
|
+
mypy = "1.17.1"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
[tool.poetry.group.dev.dependencies]
|
|
33
|
+
pre-commit = "4.3.0"
|
|
34
|
+
twine = "6.1.0"
|
|
35
|
+
|
|
36
|
+
[tool.pytest.ini_options]
|
|
37
|
+
addopts = "-ra -q"
|
|
38
|
+
pythonpath = "."
|
|
39
|
+
testpaths = "tests"
|
|
40
|
+
asyncio_mode = "auto"
|
|
41
|
+
|
|
42
|
+
[tool.flake8]
|
|
43
|
+
exclude = ["__pycache__"]
|
|
44
|
+
max-line-length = 79
|
|
45
|
+
max-doc-length = 79
|
|
46
|
+
docstring-convention = "google"
|
|
47
|
+
require-annotations = 1
|
|
48
|
+
ignore = ["ANN002", "ANN003","ANN101", "ANN102"]
|
|
49
|
+
per-file-ignores = [
|
|
50
|
+
"__init__.py:D104",
|
|
51
|
+
"tests/*:DALL000,D104,D100,FKA100"
|
|
52
|
+
]
|
|
53
|
+
max-complexity = 5
|
|
54
|
+
|
|
55
|
+
[tool.black]
|
|
56
|
+
line-length = 79
|
|
57
|
+
skip-string-normalization = true
|
|
58
|
+
|
|
59
|
+
[tool.isort]
|
|
60
|
+
include_trailing_comma = true
|
|
61
|
+
multi_line_output = 3
|
|
62
|
+
line_length = 79
|
|
63
|
+
|
|
64
|
+
[tool.mypy]
|
|
65
|
+
strict = true
|
|
66
|
+
plugins = "pydantic.mypy"
|
|
67
|
+
ignore_missing_imports = true
|
|
68
|
+
namespace_packages = true
|
|
69
|
+
explicit_package_bases = true
|
|
70
|
+
|
|
71
|
+
disable_error_code = [
|
|
72
|
+
"import-untyped",
|
|
73
|
+
"no-untyped-call"
|
|
74
|
+
]
|
|
75
|
+
|
|
76
|
+
[build-system]
|
|
77
|
+
requires = ["poetry-core==2.0.1"]
|
|
78
|
+
build-backend = "poetry.core.masonry.api"
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Модуль моделей SQLAlchemy."""
|
|
2
|
+
|
|
3
|
+
from sqlalchemy.ext.asyncio import AsyncAttrs
|
|
4
|
+
from sqlalchemy.orm import DeclarativeBase
|
|
5
|
+
|
|
6
|
+
__all__ = ['BaseModel']
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class BaseModel(AsyncAttrs, DeclarativeBase):
|
|
10
|
+
"""Базовый класс для всех моделей SQLAlchemy."""
|
|
11
|
+
|
|
12
|
+
pass
|
|
File without changes
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Модуль логирования для weblite-framework."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
import sys
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from logging.handlers import QueueHandler, QueueListener
|
|
8
|
+
from queue import Queue
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
'JsonFormatter',
|
|
12
|
+
'get_logger',
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class JsonFormatter(logging.Formatter):
|
|
17
|
+
"""Класс для преобразования записей логов в JSON-строки с полями."""
|
|
18
|
+
|
|
19
|
+
def format(self, record: logging.LogRecord) -> str:
|
|
20
|
+
"""Форматирует запись лога в JSON строку.
|
|
21
|
+
|
|
22
|
+
Поля:
|
|
23
|
+
timestamp: Временная метка
|
|
24
|
+
level: Уровень логирования
|
|
25
|
+
source: Источник сообщения (модуль)
|
|
26
|
+
message: Текст сообщения
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
record: Запись лога для форматирования
|
|
30
|
+
|
|
31
|
+
Returns:
|
|
32
|
+
JSON-строка с отформатированным логом
|
|
33
|
+
"""
|
|
34
|
+
log_record = {
|
|
35
|
+
'timestamp': datetime.fromtimestamp(record.created)
|
|
36
|
+
.astimezone()
|
|
37
|
+
.isoformat(),
|
|
38
|
+
'level': record.levelname,
|
|
39
|
+
'source': record.module,
|
|
40
|
+
'message': record.getMessage(),
|
|
41
|
+
}
|
|
42
|
+
return json.dumps(log_record, ensure_ascii=False)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
_loggers: dict[str, logging.Logger] = {}
|
|
46
|
+
_handler: logging.Handler | None = None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def get_handler() -> logging.Handler:
|
|
50
|
+
"""Создает и возвращает обработчик логов.
|
|
51
|
+
|
|
52
|
+
Обработчик использует очередь и listener для асинхронного логирования
|
|
53
|
+
во всех логгерах.
|
|
54
|
+
|
|
55
|
+
Returns:
|
|
56
|
+
logging.Handler: Настроенный обработчик логов
|
|
57
|
+
"""
|
|
58
|
+
global _handler
|
|
59
|
+
|
|
60
|
+
if _handler is not None:
|
|
61
|
+
return _handler
|
|
62
|
+
|
|
63
|
+
log_queue: Queue[logging.LogRecord] = Queue()
|
|
64
|
+
|
|
65
|
+
stream_handler = logging.StreamHandler(stream=sys.stdout)
|
|
66
|
+
stream_handler.setFormatter(fmt=JsonFormatter())
|
|
67
|
+
|
|
68
|
+
listener = QueueListener(log_queue, stream_handler)
|
|
69
|
+
listener.start()
|
|
70
|
+
|
|
71
|
+
_handler = QueueHandler(queue=log_queue)
|
|
72
|
+
|
|
73
|
+
return _handler
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def get_logger(name: str) -> logging.Logger:
|
|
77
|
+
"""Создает и возвращает логгер.
|
|
78
|
+
|
|
79
|
+
Логирование происходит в отдельном потоке через QueueHandler.
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
name: str
|
|
83
|
+
|
|
84
|
+
Returns:
|
|
85
|
+
logging.Logger: Настроенный логгер
|
|
86
|
+
"""
|
|
87
|
+
if name in _loggers:
|
|
88
|
+
return _loggers[name]
|
|
89
|
+
|
|
90
|
+
logger: logging.Logger = logging.getLogger(name=name)
|
|
91
|
+
logger.setLevel(level=logging.INFO)
|
|
92
|
+
logger.addHandler(hdlr=get_handler())
|
|
93
|
+
|
|
94
|
+
_loggers[name] = logger
|
|
95
|
+
|
|
96
|
+
return logger
|
|
File without changes
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
# TODO:
|
|
2
|
+
# В ручках FastAPI перехватывать ошибки валидации из провайдера
|
|
3
|
+
# и возвращать корректные HTTP-коды.
|
|
4
|
+
|
|
5
|
+
"""S3-провайдер на aioboto3: upload/get/delete/list."""
|
|
6
|
+
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import aioboto3
|
|
10
|
+
from botocore.config import Config
|
|
11
|
+
|
|
12
|
+
from weblite_framework.settings.s3 import S3Settings
|
|
13
|
+
|
|
14
|
+
__all__ = ['S3Provider']
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class S3Provider:
|
|
18
|
+
"""Класс для работы с S3 через aioboto3.
|
|
19
|
+
|
|
20
|
+
Note:
|
|
21
|
+
Используйте провайдер только внутри блока ``async with``.
|
|
22
|
+
|
|
23
|
+
Examples:
|
|
24
|
+
async with S3Provider(settings) as s3p:
|
|
25
|
+
await s3p.upload_file(filename="a.txt", data=b"...")
|
|
26
|
+
data: bytes = await s3p.get_file(filename="a.txt")
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __init__(self, settings: S3Settings) -> None:
|
|
30
|
+
"""Создаёт провайдер с заданными настройками.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
settings : S3Settings
|
|
34
|
+
"""
|
|
35
|
+
self.settings = settings
|
|
36
|
+
self._session = aioboto3.Session()
|
|
37
|
+
|
|
38
|
+
self._config = Config(
|
|
39
|
+
region_name=settings.region,
|
|
40
|
+
signature_version=settings.signature_version,
|
|
41
|
+
s3={
|
|
42
|
+
'addressing_style': (
|
|
43
|
+
'path' if settings.path_style else 'virtual'
|
|
44
|
+
),
|
|
45
|
+
},
|
|
46
|
+
retries={
|
|
47
|
+
'max_attempts': settings.max_attempts,
|
|
48
|
+
'mode': 'standard',
|
|
49
|
+
},
|
|
50
|
+
connect_timeout=settings.connect_timeout,
|
|
51
|
+
read_timeout=settings.read_timeout,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
self._client_kwargs = {
|
|
55
|
+
'endpoint_url': settings.endpoint_url,
|
|
56
|
+
'aws_access_key_id': settings.access_key,
|
|
57
|
+
'aws_secret_access_key': settings.secret_key,
|
|
58
|
+
'config': self._config,
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
self._client_cm: Any = None
|
|
62
|
+
self._client: Any = None
|
|
63
|
+
|
|
64
|
+
async def __aenter__(self) -> 'S3Provider':
|
|
65
|
+
"""Открывает S3-клиент и возвращает провайдер.
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
S3Provider: Экземпляр провайдера с открытым S3-клиентом.
|
|
69
|
+
"""
|
|
70
|
+
self._client_cm = self._session.client(
|
|
71
|
+
service_name='s3',
|
|
72
|
+
**self._client_kwargs,
|
|
73
|
+
)
|
|
74
|
+
self._client = await self._client_cm.__aenter__()
|
|
75
|
+
return self
|
|
76
|
+
|
|
77
|
+
async def __aexit__(
|
|
78
|
+
self,
|
|
79
|
+
exc_type: type[BaseException] | None,
|
|
80
|
+
exc: BaseException | None,
|
|
81
|
+
tb: object | None,
|
|
82
|
+
) -> None:
|
|
83
|
+
"""Закрывает соединение с S3.
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
exc_type: Тип исключения, если оно возникло.
|
|
87
|
+
exc: Экземпляр исключения, если он был.
|
|
88
|
+
tb: Трейсбек исключения.
|
|
89
|
+
|
|
90
|
+
Returns:
|
|
91
|
+
None: Исключения не подавляются.
|
|
92
|
+
"""
|
|
93
|
+
try:
|
|
94
|
+
if self._client_cm is not None:
|
|
95
|
+
await self._client_cm.__aexit__(
|
|
96
|
+
exc_type=exc_type,
|
|
97
|
+
exc_value=exc,
|
|
98
|
+
traceback=tb,
|
|
99
|
+
)
|
|
100
|
+
finally:
|
|
101
|
+
self._client = None
|
|
102
|
+
self._client_cm = None
|
|
103
|
+
|
|
104
|
+
@classmethod
|
|
105
|
+
def __validate_filename(cls, filename: str) -> None: # noqa: ANN102
|
|
106
|
+
"""Проверяет, что имя файла непустое.
|
|
107
|
+
|
|
108
|
+
Args:
|
|
109
|
+
filename: Имя файла.
|
|
110
|
+
|
|
111
|
+
Raises:
|
|
112
|
+
ValueError: Если имя файла пустое.
|
|
113
|
+
"""
|
|
114
|
+
if not filename:
|
|
115
|
+
raise ValueError('filename is required')
|
|
116
|
+
|
|
117
|
+
def _ensure_client(self) -> Any: # noqa: ANN401
|
|
118
|
+
"""Возвращает активный S3-клиент или бросает исключение.
|
|
119
|
+
|
|
120
|
+
Raises:
|
|
121
|
+
RuntimeError: Если провайдер используется вне `async with`.
|
|
122
|
+
"""
|
|
123
|
+
if self._client is None:
|
|
124
|
+
raise RuntimeError(
|
|
125
|
+
'S3Provider нужно использовать внутри'
|
|
126
|
+
' "async with S3Provider(...)"',
|
|
127
|
+
)
|
|
128
|
+
return self._client
|
|
129
|
+
|
|
130
|
+
async def upload_file(self, filename: str, data: bytes) -> None:
|
|
131
|
+
"""Загружает байты в S3 под именем `filename`.
|
|
132
|
+
|
|
133
|
+
Args:
|
|
134
|
+
filename (str): Имя файла для сохранения в S3.
|
|
135
|
+
data: Данные для загрузки.
|
|
136
|
+
|
|
137
|
+
Raises:
|
|
138
|
+
ValueError: Если имя файла или данные не указаны.
|
|
139
|
+
"""
|
|
140
|
+
self.__validate_filename(filename=filename)
|
|
141
|
+
if data is None:
|
|
142
|
+
raise ValueError('data is required')
|
|
143
|
+
|
|
144
|
+
s3 = self._ensure_client()
|
|
145
|
+
await s3.put_object(
|
|
146
|
+
Bucket=self.settings.bucket,
|
|
147
|
+
Key=filename,
|
|
148
|
+
Body=data,
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
async def get_file(self, filename: str) -> bytes:
|
|
152
|
+
"""Читает содержимое объекта и возвращает его как bytes.
|
|
153
|
+
|
|
154
|
+
Args:
|
|
155
|
+
filename: Имя файла в S3.
|
|
156
|
+
|
|
157
|
+
Returns:
|
|
158
|
+
bytes: Содержимое файла.
|
|
159
|
+
|
|
160
|
+
Raises:
|
|
161
|
+
ValueError: Если имя файла пустое.
|
|
162
|
+
"""
|
|
163
|
+
self.__validate_filename(filename=filename)
|
|
164
|
+
|
|
165
|
+
s3 = self._ensure_client()
|
|
166
|
+
resp = await s3.get_object(
|
|
167
|
+
Bucket=self.settings.bucket,
|
|
168
|
+
Key=filename,
|
|
169
|
+
)
|
|
170
|
+
async with resp['Body'] as stream:
|
|
171
|
+
content: bytes = await stream.read()
|
|
172
|
+
return content
|
|
173
|
+
|
|
174
|
+
async def delete_file(self, filename: str) -> None:
|
|
175
|
+
"""Удаляет объект из S3 (идемпотентно).
|
|
176
|
+
|
|
177
|
+
Args:
|
|
178
|
+
filename: Имя файла для удаления.
|
|
179
|
+
|
|
180
|
+
Raises:
|
|
181
|
+
ValueError: Если имя файла пустое.
|
|
182
|
+
"""
|
|
183
|
+
self.__validate_filename(filename=filename)
|
|
184
|
+
|
|
185
|
+
s3 = self._ensure_client()
|
|
186
|
+
await s3.delete_object(
|
|
187
|
+
Bucket=self.settings.bucket,
|
|
188
|
+
Key=filename,
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
async def get_files_list(self, prefix: str = '') -> list[str]:
|
|
192
|
+
"""Возвращает список ключей, начинающихся с указанного префикса.
|
|
193
|
+
|
|
194
|
+
Args:
|
|
195
|
+
prefix: Префикс ключей. По умолчанию пустая строка.
|
|
196
|
+
|
|
197
|
+
Returns:
|
|
198
|
+
list: Список имён файлов (ключей).
|
|
199
|
+
"""
|
|
200
|
+
keys: list[str] = []
|
|
201
|
+
|
|
202
|
+
s3 = self._ensure_client()
|
|
203
|
+
paginator = s3.get_paginator(operation_name='list_objects_v2')
|
|
204
|
+
async for page in paginator.paginate(
|
|
205
|
+
Bucket=self.settings.bucket,
|
|
206
|
+
Prefix=prefix,
|
|
207
|
+
):
|
|
208
|
+
for obj in page.get('Contents') or []:
|
|
209
|
+
keys.append(obj['Key'])
|
|
210
|
+
|
|
211
|
+
return keys
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
"""Модуль базового репозитория для работы с БД."""
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from typing import Any, Generic, TypeVar
|
|
5
|
+
|
|
6
|
+
from sqlalchemy import Executable, Result
|
|
7
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
8
|
+
|
|
9
|
+
from weblite_framework.database.models import BaseModel
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
'BaseRepositoryClass',
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
DTO = TypeVar('DTO')
|
|
16
|
+
SQLModel = TypeVar('SQLModel', bound=BaseModel)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class BaseRepositoryClass(Generic[DTO, SQLModel], ABC):
|
|
20
|
+
"""Базовый репозиторий с общим контролем выполняемых транзакций."""
|
|
21
|
+
|
|
22
|
+
def __init__(self, session: AsyncSession) -> None:
|
|
23
|
+
"""Инициализирует экземпляр репозитория.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
session: AsyncSession
|
|
27
|
+
"""
|
|
28
|
+
self.__session = session
|
|
29
|
+
|
|
30
|
+
def _get_session_for_testing(self) -> AsyncSession:
|
|
31
|
+
"""Возвращает сессию для тестирования.
|
|
32
|
+
|
|
33
|
+
Returns:
|
|
34
|
+
AsyncSession: Сессия для тестирования
|
|
35
|
+
"""
|
|
36
|
+
return self.__session
|
|
37
|
+
|
|
38
|
+
@abstractmethod
|
|
39
|
+
def _model_to_dto(self, model: SQLModel) -> DTO:
|
|
40
|
+
"""Производит маппинг данных из ORM модели в класс DTO.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
model: ORM модель
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
DTO: объект dataclass
|
|
47
|
+
"""
|
|
48
|
+
pass
|
|
49
|
+
|
|
50
|
+
@abstractmethod
|
|
51
|
+
def _dto_to_model(self, dto: DTO) -> SQLModel:
|
|
52
|
+
"""Производит маппинг данных из DTO класса в ORM модель.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
dto: объект dataclass
|
|
56
|
+
|
|
57
|
+
Returns:
|
|
58
|
+
SQLModel: ORM модель
|
|
59
|
+
"""
|
|
60
|
+
pass
|
|
61
|
+
|
|
62
|
+
async def _add_record(
|
|
63
|
+
self,
|
|
64
|
+
model: SQLModel,
|
|
65
|
+
) -> SQLModel:
|
|
66
|
+
"""Создает запись в БД.
|
|
67
|
+
|
|
68
|
+
Данный метод создает запись в БД
|
|
69
|
+
и обновляет поля модели без коммита.
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
model: ORM модель
|
|
73
|
+
|
|
74
|
+
Returns:
|
|
75
|
+
SQLModel: ORM модель с присвоенным идентификатором
|
|
76
|
+
"""
|
|
77
|
+
try:
|
|
78
|
+
self.add(model)
|
|
79
|
+
await self.flush()
|
|
80
|
+
return model
|
|
81
|
+
except Exception as e:
|
|
82
|
+
await self.__session.rollback()
|
|
83
|
+
raise e
|
|
84
|
+
|
|
85
|
+
def _prepare_ignore_fields(
|
|
86
|
+
self,
|
|
87
|
+
ignore_fields: list[str] | None = None,
|
|
88
|
+
) -> list[str]:
|
|
89
|
+
"""Подготавливает список игнорируемых полей.
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
ignore_fields: Список игнорируемых полей
|
|
93
|
+
|
|
94
|
+
Returns:
|
|
95
|
+
list[str]: Подготовленный список игнорируемых полей
|
|
96
|
+
"""
|
|
97
|
+
if ignore_fields is None:
|
|
98
|
+
ignore_fields = []
|
|
99
|
+
ignore_fields.append('_sa_instance_state')
|
|
100
|
+
return ignore_fields
|
|
101
|
+
|
|
102
|
+
def _update_model_fields(
|
|
103
|
+
self,
|
|
104
|
+
existing_model: SQLModel,
|
|
105
|
+
new_data: SQLModel,
|
|
106
|
+
ignore_fields: list[str],
|
|
107
|
+
) -> None:
|
|
108
|
+
"""Обновляет поля модели.
|
|
109
|
+
|
|
110
|
+
Args:
|
|
111
|
+
existing_model: Существующая модель
|
|
112
|
+
new_data: Новые данные
|
|
113
|
+
ignore_fields: Список игнорируемых полей
|
|
114
|
+
"""
|
|
115
|
+
for key, value in new_data.__dict__.items():
|
|
116
|
+
if key not in ignore_fields:
|
|
117
|
+
setattr(existing_model, key, value)
|
|
118
|
+
|
|
119
|
+
async def _update(
|
|
120
|
+
self,
|
|
121
|
+
existing_model: SQLModel,
|
|
122
|
+
new_data: SQLModel,
|
|
123
|
+
ignore_fields: list[str] | None = None,
|
|
124
|
+
) -> SQLModel:
|
|
125
|
+
"""Обновляет существующую запись в БД.
|
|
126
|
+
|
|
127
|
+
Данный метод обновляет данные в существующей записи в БД,
|
|
128
|
+
при этом коммит не производится.
|
|
129
|
+
|
|
130
|
+
Args:
|
|
131
|
+
existing_model: ORM модель с информацией, связанная с записью в БД
|
|
132
|
+
new_data: ORM модель с данными для обновления
|
|
133
|
+
ignore_fields: Список игнорируемых полей
|
|
134
|
+
|
|
135
|
+
Returns:
|
|
136
|
+
SQLModel: ORM модель, связанная с БД с обновленными полями
|
|
137
|
+
"""
|
|
138
|
+
try:
|
|
139
|
+
prepared_ignore_fields = self._prepare_ignore_fields(ignore_fields)
|
|
140
|
+
self._update_model_fields(
|
|
141
|
+
existing_model=existing_model,
|
|
142
|
+
new_data=new_data,
|
|
143
|
+
ignore_fields=prepared_ignore_fields,
|
|
144
|
+
)
|
|
145
|
+
await self.flush()
|
|
146
|
+
return existing_model
|
|
147
|
+
except Exception as e:
|
|
148
|
+
await self.__session.rollback()
|
|
149
|
+
raise e
|
|
150
|
+
|
|
151
|
+
def add(self, instance: SQLModel) -> None:
|
|
152
|
+
"""Выполняет добавление в сессию переданного instance.
|
|
153
|
+
|
|
154
|
+
Args:
|
|
155
|
+
instance: ORM модель
|
|
156
|
+
"""
|
|
157
|
+
self.__session.add(instance)
|
|
158
|
+
|
|
159
|
+
async def commit(self) -> None:
|
|
160
|
+
"""Выполняет коммит в текущей сессии."""
|
|
161
|
+
try:
|
|
162
|
+
await self.__session.commit()
|
|
163
|
+
except Exception as e:
|
|
164
|
+
await self.__session.rollback()
|
|
165
|
+
raise e
|
|
166
|
+
|
|
167
|
+
async def execute(
|
|
168
|
+
self,
|
|
169
|
+
statement: Executable,
|
|
170
|
+
is_use_active_transaction: bool = True,
|
|
171
|
+
) -> Result[Any]:
|
|
172
|
+
"""Выполняет переданный SQL-запрос с учетом активной транзакции.
|
|
173
|
+
|
|
174
|
+
Данный метод выполняет переданный запрос, при этом:
|
|
175
|
+
Если передать is_use_active_transaction=True,
|
|
176
|
+
новая транзакция создана не будет.
|
|
177
|
+
Используется для связки методов,
|
|
178
|
+
которые должны выполняться атомарно.
|
|
179
|
+
Такие методы в дочерних классах должны
|
|
180
|
+
быть объявлены как _protected.
|
|
181
|
+
Если передать is_use_active_transaction=False,
|
|
182
|
+
будет создана новая временная транзакция.
|
|
183
|
+
Используется для отдельно взятых запросов,
|
|
184
|
+
не связанных между собой. Такие методы в
|
|
185
|
+
дочерних классах должны быть объявлены как public.
|
|
186
|
+
|
|
187
|
+
Args:
|
|
188
|
+
statement: SQL-запрос
|
|
189
|
+
is_use_active_transaction: Флаг на использование
|
|
190
|
+
активной транзакции
|
|
191
|
+
|
|
192
|
+
Returns:
|
|
193
|
+
Result: Результат выполнения запроса в БД
|
|
194
|
+
"""
|
|
195
|
+
try:
|
|
196
|
+
if is_use_active_transaction:
|
|
197
|
+
return await self.__session.execute(statement)
|
|
198
|
+
else:
|
|
199
|
+
async with self.__session.begin():
|
|
200
|
+
result = await self.__session.execute(statement)
|
|
201
|
+
await self.__session.commit()
|
|
202
|
+
return result
|
|
203
|
+
except Exception as e:
|
|
204
|
+
await self.__session.rollback()
|
|
205
|
+
raise e
|
|
206
|
+
|
|
207
|
+
async def refresh(self, instance: SQLModel) -> None:
|
|
208
|
+
"""Выполняет обновление модели, обращаясь к БД.
|
|
209
|
+
|
|
210
|
+
Args:
|
|
211
|
+
instance: ORM модель, состояние которой обновляется из БД
|
|
212
|
+
"""
|
|
213
|
+
try:
|
|
214
|
+
await self.__session.refresh(instance)
|
|
215
|
+
except Exception as e:
|
|
216
|
+
await self.__session.rollback()
|
|
217
|
+
raise e
|
|
218
|
+
|
|
219
|
+
async def flush(self) -> None:
|
|
220
|
+
"""Сбрасывает изменения в базу данных без коммита."""
|
|
221
|
+
try:
|
|
222
|
+
await self.__session.flush()
|
|
223
|
+
except Exception as e:
|
|
224
|
+
await self.__session.rollback()
|
|
225
|
+
raise e
|
|
File without changes
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Модуль с родительской Pydantic схемой."""
|
|
2
|
+
|
|
3
|
+
from typing import Any, Final, Type, TypeVar
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, ConfigDict
|
|
6
|
+
|
|
7
|
+
T = TypeVar('T', bound='CustomBaseModel')
|
|
8
|
+
|
|
9
|
+
__all__ = ['CustomBaseModel']
|
|
10
|
+
|
|
11
|
+
REQUIRED_FIELD_ATTRIBUTES: Final[tuple[str, ...]] = (
|
|
12
|
+
'alias',
|
|
13
|
+
'description',
|
|
14
|
+
'examples',
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class CustomBaseModel(BaseModel):
|
|
19
|
+
"""Родительская кастомная Pydantic BaseModel схема.
|
|
20
|
+
|
|
21
|
+
Данная схема имеет встроенные проверки и расширенную функциональность.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
model_config = ConfigDict(
|
|
25
|
+
populate_by_name=True,
|
|
26
|
+
arbitrary_types_allowed=True,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
@classmethod
|
|
30
|
+
def __check_required_fields(cls) -> list[str]:
|
|
31
|
+
"""Проверяет обязательные поля для заполнения.
|
|
32
|
+
|
|
33
|
+
Данные метод проверяет поля, обязательные для заполнения
|
|
34
|
+
в Pydantic схеме и возвращает список ошибок.
|
|
35
|
+
Если ошибки не найдены, будет возвращен пустой список.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
cls
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
errors: Список возникших ошибок
|
|
42
|
+
"""
|
|
43
|
+
errors = []
|
|
44
|
+
for field_name, field_info in cls.model_fields.items():
|
|
45
|
+
for attr_name in REQUIRED_FIELD_ATTRIBUTES:
|
|
46
|
+
attr_value = getattr(field_info, attr_name, None)
|
|
47
|
+
if attr_value is None:
|
|
48
|
+
errors.append(f'{field_name}: отсутствует {attr_name}')
|
|
49
|
+
elif attr_name == 'examples' and len(attr_value) != 1:
|
|
50
|
+
errors.append(
|
|
51
|
+
f'{field_name}: '
|
|
52
|
+
f'{attr_name} должен содержать ровно одно значение',
|
|
53
|
+
)
|
|
54
|
+
return errors
|
|
55
|
+
|
|
56
|
+
@classmethod
|
|
57
|
+
def __pydantic_init_subclass__(
|
|
58
|
+
cls,
|
|
59
|
+
**kwargs: Any, # noqa: ANN401
|
|
60
|
+
) -> None:
|
|
61
|
+
"""Инициализация схемы после загрузки данных из атрибутов класса.
|
|
62
|
+
|
|
63
|
+
Данный метод проверяет все поля схемы Pydantic на
|
|
64
|
+
наличие alias, description и example.
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
cls
|
|
68
|
+
kwargs: Дополнительные передаваемые именованные аргументы
|
|
69
|
+
|
|
70
|
+
Raises:
|
|
71
|
+
TypeError: Ошибка при валидации полей схемы
|
|
72
|
+
"""
|
|
73
|
+
super().__pydantic_init_subclass__(**kwargs)
|
|
74
|
+
|
|
75
|
+
errors = cls.__check_required_fields()
|
|
76
|
+
if errors:
|
|
77
|
+
fields_str = '\n'.join(errors)
|
|
78
|
+
raise TypeError(
|
|
79
|
+
f'Модель {cls.__name__} не прошла проверку документации: '
|
|
80
|
+
f'\n{fields_str}',
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
@classmethod
|
|
84
|
+
def generate_example(cls: Type[T]) -> T:
|
|
85
|
+
"""Автоматическая генерация примера на основе alias и example.
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
cls
|
|
89
|
+
|
|
90
|
+
Returns:
|
|
91
|
+
CustomBaseModel
|
|
92
|
+
"""
|
|
93
|
+
example = {}
|
|
94
|
+
|
|
95
|
+
for field_name, field_info in cls.model_fields.items():
|
|
96
|
+
if field_info.examples is not None:
|
|
97
|
+
key = field_info.alias or field_name
|
|
98
|
+
example[key] = field_info.examples[0]
|
|
99
|
+
|
|
100
|
+
return cls.model_validate(
|
|
101
|
+
obj=example,
|
|
102
|
+
from_attributes=False,
|
|
103
|
+
)
|
|
File without changes
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Настройки для подключения к S3."""
|
|
2
|
+
|
|
3
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
4
|
+
|
|
5
|
+
__all__ = ['S3Settings']
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class S3Settings(BaseSettings):
|
|
9
|
+
"""Конфигурация подключения к S3.
|
|
10
|
+
|
|
11
|
+
Args:
|
|
12
|
+
bucket: Имя S3-бакета.
|
|
13
|
+
access_key: Ключ доступа AWS/S3.
|
|
14
|
+
secret_key: Секретный ключ AWS/S3.
|
|
15
|
+
region: Регион S3.
|
|
16
|
+
endpoint_url: URL эндпоинта S3 (например, для MinIO).
|
|
17
|
+
path_style: Включает использование path-style addressing.
|
|
18
|
+
Если True, запросы будут формироваться в виде
|
|
19
|
+
``https://endpoint/bucket/key`` (необходимо для MinIO и
|
|
20
|
+
совместимых сервисов). Если False — используется
|
|
21
|
+
virtual-hosted style ``https://bucket.endpoint/key`` (дефолт AWS).
|
|
22
|
+
signature_version: Версия алгоритма подписи запросов к S3.
|
|
23
|
+
max_attempts: Максимальное число повторных попыток.
|
|
24
|
+
connect_timeout: Таймаут подключения в секундах.
|
|
25
|
+
read_timeout: Таймаут чтения в секундах.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
model_config = SettingsConfigDict(
|
|
29
|
+
env_prefix='S3_',
|
|
30
|
+
extra='ignore',
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
bucket: str
|
|
34
|
+
access_key: str
|
|
35
|
+
secret_key: str
|
|
36
|
+
region: str
|
|
37
|
+
endpoint_url: str
|
|
38
|
+
path_style: bool = True
|
|
39
|
+
signature_version: str = 's3v4'
|
|
40
|
+
max_attempts: int = 3
|
|
41
|
+
connect_timeout: int = 5
|
|
42
|
+
read_timeout: int = 30
|