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.
@@ -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
@@ -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
@@ -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,5 @@
1
+ """Модуль репозиториев."""
2
+
3
+ from .base import BaseRepositoryClass
4
+
5
+ __all__ = ['BaseRepositoryClass']
@@ -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
@@ -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
+ )
@@ -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