Refactor project structure and add new features

This commit is contained in:
Serge Zaigraeff
2025-10-23 00:42:09 +03:00
parent 144414fcf8
commit 852f7975b9
63 changed files with 8101 additions and 1341 deletions
View File
+139
View File
@@ -0,0 +1,139 @@
"""
Модуль manager.py содержит функции для асинхронной обработки задач.
"""
import uuid
import time
from typing import Dict, Any, Callable, Optional
from threading import Thread
import logging
logger = logging.getLogger('app.async_tasks')
class AsyncTaskManager:
"""
Менеджер асинхронных задач на основе потоков.
Attributes:
tasks (Dict): Словарь для хранения информации о задачах.
"""
def __init__(self):
"""
Инициализация менеджера асинхронных задач.
"""
self.tasks: Dict[str, Dict[str, Any]] = {}
def run_task(self, func: Callable, *args, **kwargs) -> str:
"""
Запуск задачи в отдельном потоке.
Args:
func: Функция для выполнения.
*args: Позиционные аргументы для функции.
**kwargs: Именованные аргументы для функции.
Returns:
ID задачи.
"""
task_id = str(uuid.uuid4())
# Создание информации о задаче
self.tasks[task_id] = {
"status": "pending",
"result": None,
"error": None,
"created_at": time.time(),
"started_at": None,
"completed_at": None
}
# Создание и запуск потока
thread = Thread(target=self._run_task_thread, args=(task_id, func, args, kwargs))
thread.daemon = True
thread.start()
return task_id
def _run_task_thread(self, task_id: str, func: Callable, args: tuple, kwargs: dict) -> None:
"""
Функция для выполнения задачи в потоке.
Args:
task_id: ID задачи.
func: Функция для выполнения.
args: Позиционные аргументы для функции.
kwargs: Именованные аргументы для функции.
"""
try:
# Обновление статуса задачи
self.tasks[task_id]["status"] = "running"
self.tasks[task_id]["started_at"] = time.time()
# Выполнение функции
result = func(*args, **kwargs)
# Сохранение результата
self.tasks[task_id]["status"] = "completed"
self.tasks[task_id]["result"] = result
self.tasks[task_id]["completed_at"] = time.time()
logger.info(f"Задача {task_id} завершена успешно")
except Exception as e:
# Обработка ошибки
self.tasks[task_id]["status"] = "failed"
self.tasks[task_id]["error"] = str(e)
self.tasks[task_id]["completed_at"] = time.time()
logger.error(f"Задача {task_id} завершилась с ошибкой: {e}")
def get_task_status(self, task_id: str) -> Optional[Dict[str, Any]]:
"""
Получение статуса задачи.
Args:
task_id: ID задачи.
Returns:
Информация о задаче или None, если задача не найдена.
"""
return self.tasks.get(task_id)
def cleanup_completed_tasks(self, max_age_seconds: int = 3600) -> None:
"""
Очистка завершенных задач старше указанного возраста.
Args:
max_age_seconds: Максимальный возраст задачи в секундах.
"""
current_time = time.time()
tasks_to_remove = []
for task_id, task_info in self.tasks.items():
if (task_info["status"] in ["completed", "failed"] and
"completed_at" in task_info and
current_time - task_info["completed_at"] > max_age_seconds):
tasks_to_remove.append(task_id)
for task_id in tasks_to_remove:
del self.tasks[task_id]
logger.debug(f"Задача {task_id} удалена из-за устаревания")
# Глобальный экземпляр менеджера асинхронных задач
task_manager = AsyncTaskManager()
def transcribe_audio_async(file_path: str, transcriber) -> str:
"""
Асинхронная транскрибация аудиофайла.
Args:
file_path: Путь к аудиофайлу.
transcriber: Экземпляр транскрайбера.
Returns:
ID задачи.
"""
return task_manager.run_task(transcriber.process_file, file_path)
+64
View File
@@ -0,0 +1,64 @@
"""
Модуль config.py содержит централизованную настройку логирования.
"""
import logging
import os
from logging.handlers import RotatingFileHandler
def setup_logging(log_level=logging.INFO, log_file=None):
"""
Настройка логирования для всего приложения.
Args:
log_level: Уровень логирования (по умолчанию INFO).
log_file: Путь к файлу для записи логов (опционально).
"""
# Создаем корневой логгер
root_logger = logging.getLogger()
root_logger.setLevel(log_level)
# Очищаем существующие обработчики
for handler in root_logger.handlers[:]:
root_logger.removeHandler(handler)
# Создаем улучшенный форматтер с поддержкой дополнительных полей
class CustomFormatter(logging.Formatter):
def format(self, record):
# Добавляем поле type если оно отсутствует
if not hasattr(record, 'type'):
record.type = 'general'
return super().format(record)
formatter = CustomFormatter(
'%(asctime)s - %(name)s - %(levelname)s - [%(type)s] %(message)s'
)
# Добавляем обработчик для вывода в консоль
console_handler = logging.StreamHandler()
console_handler.setLevel(log_level)
console_handler.setFormatter(formatter)
root_logger.addHandler(console_handler)
# Добавляем обработчик для записи в файл, если указан путь
if log_file:
# Создаем директорию для файла логов, если она не существует
log_dir = os.path.dirname(log_file)
if log_dir and not os.path.exists(log_dir):
os.makedirs(log_dir)
file_handler = RotatingFileHandler(
log_file,
maxBytes=10*1024*1024, # 10 МБ
backupCount=5
)
file_handler.setLevel(log_level)
file_handler.setFormatter(formatter)
root_logger.addHandler(file_handler)
# Устанавливаем уровень логирования для логгеров в других модулях
logging.getLogger('app').setLevel(log_level)
logging.getLogger('app.request').setLevel(log_level)
return root_logger
@@ -0,0 +1,234 @@
"""
Модуль request_logger.py содержит middleware для логирования входящих запросов и ответов.
"""
import time
import json
import logging
from flask import request, g
from typing import Dict, Any, Optional
class RequestLogger:
"""
Middleware для логирования входящих запросов и ответов.
"""
def __init__(self, app=None, config: Optional[Dict] = None):
"""
Инициализация middleware.
Args:
app: Flask приложение.
config: Конфигурация логирования.
"""
self.app = app
self.config = config or {}
self.logger = logging.getLogger('app.request')
# Чувствительные заголовки для фильтрации
self.sensitive_headers = set(self.config.get(
'sensitive_headers',
['authorization', 'cookie', 'set-cookie', 'proxy-authorization', 'x-api-key']
))
# Эндпоинты для исключения из логирования
self.exclude_endpoints = set(self.config.get('exclude_endpoints', ['/health', '/static']))
if app is not None:
self.init_app(app)
def init_app(self, app):
"""Инициализация middleware с Flask приложением."""
app.before_request(self._before_request)
app.after_request(self._after_request)
def _should_log_request(self) -> bool:
"""Проверка, нужно ли логировать текущий запрос."""
# Проверяем, исключен ли эндпоинт
path = request.path
for excluded in self.exclude_endpoints:
if path.startswith(excluded):
return False
return True
def _before_request(self):
"""Логирование входящего запроса."""
if not self._should_log_request():
return
g.start_time = time.time()
# Определяем режим логирования
debug_mode = self.config.get('log_debug', False)
# Сбор информации о запросе
request_info = self._extract_request_info(debug=debug_mode)
# Логирование в зависимости от режима
if debug_mode:
self._log_debug_request(request_info)
else:
message = self._format_request_message(request_info)
self.logger.info(
message,
extra={"type": "request"}
)
def _after_request(self, response):
"""Логирование ответа."""
if not self._should_log_request():
return response
# Расчет времени обработки
processing_time = time.time() - getattr(g, 'start_time', time.time())
# Определяем режим логирования
debug_mode = self.config.get('log_debug', False)
# Логирование в зависимости от режима
if debug_mode:
self._log_debug_response(response, processing_time)
else:
message = self._format_response_message(response, processing_time)
self.logger.info(
message,
extra={"type": "response"}
)
return response
def _extract_request_info(self, debug: bool = False) -> Dict[str, Any]:
"""Извлечение информации о запросе."""
# Базовая информация
info = {
"endpoint": request.endpoint or str(request.url_rule),
"method": request.method,
"path": request.path,
"client_ip": self._get_client_ip(),
"user_agent": request.headers.get('User-Agent', 'Unknown')
}
# Параметры запроса
if request.args:
info["query_params"] = dict(request.args)
# Данные формы (исключая файлы)
if request.form:
info["form_data"] = dict(request.form)
# JSON данные
if request.is_json:
try:
info["json_data"] = request.get_json()
except Exception:
info["json_data"] = "Invalid JSON"
# Информация о файлах
if request.files:
file_info = {}
for key, file in request.files.items():
file_info[key] = {
"filename": file.filename,
"content_type": file.content_type,
"content_length": len(file.read()) if file else 0
}
file.seek(0) # Возвращаем указатель файла
info["files"] = file_info
# Заголовки
if debug:
# В отладочном режиме логируем все заголовки
headers = dict(request.headers)
else:
# В обычном режиме фильтруем чувствительные заголовки
headers = {}
for key, value in request.headers:
if key.lower() not in self.sensitive_headers:
headers[key] = value
info["headers"] = headers
return info
def _log_debug_request(self, request_info: Dict[str, Any]):
"""Логирование полных данных запроса в отладочном режиме."""
debug_data = {
"timestamp": time.time(),
"type": "request",
"data": request_info
}
self.logger.info(
"DEBUG REQUEST: %s",
json.dumps(debug_data, ensure_ascii=False, default=str)
)
def _log_debug_response(self, response, processing_time: float):
"""Логирование полных данных ответа в отладочном режиме."""
response_info = {
"status_code": response.status_code,
"headers": dict(response.headers),
"content_length": response.content_length,
"processing_time": round(processing_time, 3)
}
debug_data = {
"timestamp": time.time(),
"type": "response",
"data": response_info
}
self.logger.info(
"DEBUG RESPONSE: %s",
json.dumps(debug_data, ensure_ascii=False, default=str)
)
def _format_request_message(self, request_info: Dict[str, Any]) -> str:
"""Форматирование сообщения с деталями запроса."""
# Базовая информация
method = request_info.get("method", "UNKNOWN")
path = request_info.get("path", "/")
client_ip = request_info.get("client_ip", "unknown")
user_agent = request_info.get("user_agent", "Unknown")
# Информация о файлах
file_info = ""
if "files" in request_info and request_info["files"]:
file_details = []
for file_key, file_data in request_info["files"].items():
filename = file_data.get("filename", "unknown")
size = file_data.get("content_length", 0)
file_details.append(f"{filename} ({size} байт)")
file_info = f" файлы: {', '.join(file_details)}"
# Информация о параметрах (только имена для безопасности)
param_info = ""
if "query_params" in request_info and request_info["query_params"]:
param_names = list(request_info["query_params"].keys())
param_info = f" параметры: {', '.join(param_names)}"
elif "form_data" in request_info and request_info["form_data"]:
param_names = list(request_info["form_data"].keys())
param_info = f" параметры: {', '.join(param_names)}"
elif "json_data" in request_info and isinstance(request_info["json_data"], dict):
param_names = list(request_info["json_data"].keys())
param_info = f" параметры: {', '.join(param_names)}"
# Формирование полного сообщения
message = f"{method} {path} от {client_ip} ({user_agent}){file_info}{param_info}"
return message.strip()
def _format_response_message(self, response, processing_time: float) -> str:
"""Форматирование сообщения с деталями ответа."""
status_code = response.status_code
content_length = response.content_length or 0
processing_time_rounded = round(processing_time, 3)
return f"{status_code} за {processing_time_rounded} сек, {content_length} байт"
def _get_client_ip(self) -> str:
"""Получение реального IP адреса клиента."""
if request.headers.get('X-Forwarded-For'):
return request.headers.get('X-Forwarded-For').split(',')[0]
elif request.headers.get('X-Real-IP'):
return request.headers.get('X-Real-IP')
else:
return request.remote_addr or 'unknown'
+128
View File
@@ -0,0 +1,128 @@
"""
Модуль cache.py содержит функции для кэширования данных.
"""
import time
from typing import Dict, Any, Optional, Callable
from functools import wraps
import logging
logger = logging.getLogger('app.cache')
class SimpleCache:
"""
Простой кэш на основе словаря с поддержкой TTL (Time To Live).
Attributes:
cache (Dict): Словарь для хранения кэшированных данных.
ttl (int): Время жизни кэша в секундах.
"""
def __init__(self, ttl: int = 300):
"""
Инициализация кэша.
Args:
ttl: Время жизни кэша в секундах (по умолчанию 5 минут).
"""
self.cache: Dict[str, Dict[str, Any]] = {}
self.ttl = ttl
def get(self, key: str) -> Optional[Any]:
"""
Получение значения из кэша.
Args:
key: Ключ для получения значения.
Returns:
Кэшированное значение или None, если ключ не найден или срок действия истек.
"""
if key in self.cache:
item = self.cache[key]
if time.time() - item["timestamp"] < self.ttl:
logger.debug(f"Кэш hit для ключа: {key}")
return item["value"]
else:
# Удаление просроченного элемента
del self.cache[key]
logger.debug(f"Кэш expired для ключа: {key}")
logger.debug(f"Кэш miss для ключа: {key}")
return None
def set(self, key: str, value: Any) -> None:
"""
Установка значения в кэш.
Args:
key: Ключ для хранения значения.
value: Значение для кэширования.
"""
self.cache[key] = {
"value": value,
"timestamp": time.time()
}
logger.debug(f"Значение кэшировано для ключа: {key}")
def clear(self) -> None:
"""
Очистка кэша.
"""
self.cache.clear()
logger.debug("Кэш очищен")
def delete(self, key: str) -> bool:
"""
Удаление значения из кэша.
Args:
key: Ключ для удаления.
Returns:
True, если ключ был удален, иначе False.
"""
if key in self.cache:
del self.cache[key]
logger.debug(f"Значение удалено из кэша для ключа: {key}")
return True
return False
# Глобальные экземпляры кэша
model_cache = SimpleCache(ttl=3600) # Кэш для метаданных модели (1 час)
config_cache = SimpleCache(ttl=300) # Кэш для конфигурации (5 минут)
def cache_result(cache_instance: SimpleCache, key_prefix: str = ""):
"""
Декоратор для кэширования результатов функции.
Args:
cache_instance: Экземпляр кэша.
key_prefix: Префикс для ключа кэша.
Returns:
Декорированная функция.
"""
def decorator(func: Callable) -> Callable:
@wraps(func)
def wrapper(*args, **kwargs):
# Генерация ключа кэша на основе имени функции и аргументов
cache_key = f"{key_prefix}{func.__name__}_{str(args)}_{str(kwargs)}"
# Попытка получить результат из кэша
cached_result = cache_instance.get(cache_key)
if cached_result is not None:
return cached_result
# Если результат не в кэше, вызываем функцию
result = func(*args, **kwargs)
# Сохраняем результат в кэш
cache_instance.set(cache_key, result)
return result
return wrapper
return decorator
+118
View File
@@ -0,0 +1,118 @@
"""
Модуль file_manager.py содержит классы для централизованного управления временными файлами.
Предоставляет унифицированный интерфейс для создания, отслеживания и очистки временных файлов.
"""
import os
import uuid
import tempfile
import contextlib
from typing import List, Tuple, Optional, Generator
import logging
logger = logging.getLogger('app.file_manager')
class TempFileManager:
"""
Класс для централизованного управления временными файлами.
Предоставляет методы для создания временных файлов и их последующей очистки.
Использует контекстные менеджеры для автоматической очистки ресурсов.
"""
def __init__(self):
"""
Инициализация менеджера временных файлов.
"""
self.temp_files = []
self.temp_dirs = []
def create_temp_file(self, suffix: str = ".wav") -> Tuple[str, str]:
"""
Создает временный файл с уникальным именем.
Args:
suffix: Расширение временного файла.
Returns:
Кортеж (путь к файлу, путь к временной директории).
"""
temp_dir = tempfile.mkdtemp()
temp_file = os.path.join(temp_dir, f"{uuid.uuid4()}{suffix}")
self.temp_files.append(temp_file)
self.temp_dirs.append(temp_dir)
logger.debug(f"Создан временный файл: {temp_file}")
return temp_file, temp_dir
def cleanup_temp_files(self, file_paths: Optional[List[str]] = None) -> None:
"""
Очищает временные файлы и директории.
Args:
file_paths: Список путей к файлам для очистки. Если None, очищает все отслеживаемые файлы.
"""
paths_to_clean = file_paths if file_paths is not None else self.temp_files
for path in paths_to_clean:
try:
if os.path.exists(path):
os.remove(path)
logger.debug(f"Удален временный файл: {path}")
# Попытка удалить директорию, если она пуста
temp_dir = os.path.dirname(path)
if os.path.exists(temp_dir) and not os.listdir(temp_dir):
os.rmdir(temp_dir)
logger.debug(f"Удалена временная директория: {temp_dir}")
# Удаление из списка отслеживаемых директорий
if temp_dir in self.temp_dirs:
self.temp_dirs.remove(temp_dir)
except Exception as e:
logger.warning(f"Не удалось очистить временный файл {path}: {e}")
# Удаление файлов из списка отслеживаемых
if file_paths is None:
self.temp_files.clear()
else:
for path in file_paths:
if path in self.temp_files:
self.temp_files.remove(path)
@contextlib.contextmanager
def temp_file(self, suffix: str = ".wav") -> Generator[str, None, None]:
"""
Контекстный менеджер для создания и автоматической очистки временного файла.
Args:
suffix: Расширение временного файла.
Yields:
Путь к временному файлу.
"""
temp_file, _ = self.create_temp_file(suffix)
try:
yield temp_file
finally:
self.cleanup_temp_files([temp_file])
def cleanup_all(self) -> None:
"""
Очищает все отслеживаемые временные файлы и директории.
"""
self.cleanup_temp_files()
for temp_dir in self.temp_dirs:
try:
if os.path.exists(temp_dir) and not os.listdir(temp_dir):
os.rmdir(temp_dir)
logger.debug(f"Удалена временная директория: {temp_dir}")
except Exception as e:
logger.warning(f"Не удалось очистить временную директорию {temp_dir}: {e}")
self.temp_dirs.clear()
# Глобальный экземпляр менеджера временных файлов
temp_file_manager = TempFileManager()
+183
View File
@@ -0,0 +1,183 @@
"""
Модуль validators.py содержит классы и функции для валидации входных данных.
"""
import os
import magic
from typing import Dict, List, BinaryIO, Optional
import logging
# Получаем логгер из централизованной настройки
logger = logging.getLogger('app.validators')
class ValidationError(Exception):
"""Исключение для ошибок валидации."""
pass
class FileValidator:
"""
Класс для валидации файлов.
Проверяет тип файла, размер и другие параметры на основе конфигурации.
"""
def __init__(self, config: Dict):
"""
Инициализация валидатора файлов.
Args:
config: Словарь с параметрами конфигурации.
"""
self.validation_config = config.get("file_validation", {})
self.max_file_size_mb = self.validation_config.get("max_file_size_mb", 100)
self.allowed_extensions = self.validation_config.get("allowed_extensions",
[".wav", ".mp3", ".ogg", ".flac", ".m4a"])
self.allowed_mime_types = self.validation_config.get("allowed_mime_types",
["audio/wav", "audio/mpeg", "audio/ogg",
"audio/flac", "audio/mp4"])
def validate_file(self, file: BinaryIO, filename: str) -> bool:
"""
Валидирует файл на основе конфигурации.
Args:
file: Файловый объект.
filename: Имя файла.
Returns:
True, если файл прошел валидацию.
Raises:
ValidationError: Если файл не прошел валидацию.
"""
try:
# Проверка размера файла
self._validate_file_size(file)
# Проверка расширения файла
self._validate_file_extension(filename)
# Проверка MIME-типа файла
self._validate_file_mime_type(file)
return True
except ValidationError as e:
# Логирование общей ошибки валидации
logger.warning(f"Ошибка валидации файла '{filename}': {str(e)}")
raise
def _validate_file_size(self, file: BinaryIO) -> None:
"""
Валидирует размер файла.
Args:
file: Файловый объект.
Raises:
ValidationError: Если размер файла превышает максимально допустимый.
"""
# Сохранение текущей позиции
current_position = file.tell()
# Переход в конец файла для определения размера
file.seek(0, os.SEEK_END)
file_length = file.tell()
# Возврат к исходной позиции
file.seek(current_position)
max_size_bytes = self.max_file_size_mb * 1024 * 1024
if file_length > max_size_bytes:
logger.warning(f"Попытка загрузки файла размером {file_length / (1024*1024):.2f} МБ, "
f"что превышает максимально допустимый размер {self.max_file_size_mb} МБ")
raise ValidationError(f"Размер файла ({file_length / (1024*1024):.2f} МБ) "
f"превышает максимально допустимый ({self.max_file_size_mb} МБ)")
def _validate_file_extension(self, filename: str) -> None:
"""
Валидирует расширение файла.
Args:
filename: Имя файла.
Raises:
ValidationError: Если расширение файла не входит в список разрешенных.
"""
if not any(filename.lower().endswith(ext.lower()) for ext in self.allowed_extensions):
# Логирование попытки загрузки файла с неразрешенным расширением
file_extension = os.path.splitext(filename)[1]
logger.warning(f"Попытка загрузки файла с неразрешенным расширением '{file_extension}'. "
f"Имя файла: {filename}. Разрешенные расширения: {', '.join(self.allowed_extensions)}")
raise ValidationError(f"Расширение файла не разрешено. "
f"Разрешенные расширения: {', '.join(self.allowed_extensions)}")
def _validate_file_mime_type(self, file: BinaryIO) -> None:
"""
Валидирует MIME-тип файла.
Args:
file: Файловый объект.
Raises:
ValidationError: Если MIME-тип файла не входит в список разрешенных.
"""
# Сохранение текущей позиции
current_position = file.tell()
try:
# Чтение первых байтов для определения MIME-типа
header = file.read(1024)
mime_type = magic.from_buffer(header, mime=True)
# Возврат к исходной позиции
file.seek(current_position)
if mime_type not in self.allowed_mime_types:
# Логирование попытки загрузки файла с неразрешенным MIME-типом
logger.warning(f"Попытка загрузки файла с неразрешенным MIME-типом '{mime_type}'. "
f"Разрешенные MIME-типы: {', '.join(self.allowed_mime_types)}")
raise ValidationError(f"MIME-тип файла ({mime_type}) не разрешен. "
f"Разрешенные MIME-типы: {', '.join(self.allowed_mime_types)}")
except Exception as e:
# Возврат к исходной позиции в случае ошибки
file.seek(current_position)
logger.warning(f"Не удалось определить MIME-тип файла: {e}")
# Не прерываем валидацию, если не удалось определить MIME-тип
@staticmethod
def validate_local_file_path(file_path: str, allowed_directories: Optional[List[str]] = None) -> str:
"""
Валидирует путь к локальному файлу для предотвращения атак обхода пути.
Args:
file_path: Путь к файлу.
allowed_directories: Список разрешенных директорий.
Returns:
Нормализованный и проверенный путь к файлу.
Raises:
ValidationError: Если путь к файлу небезопасен.
"""
# Нормализация пути
normalized_path = os.path.normpath(file_path)
# Если указаны разрешенные директории, проверяем, что путь находится в одной из них
if allowed_directories:
for allowed_dir in allowed_directories:
full_allowed_path = os.path.abspath(allowed_dir)
full_file_path = os.path.abspath(os.path.join(full_allowed_path, normalized_path))
if full_file_path.startswith(full_allowed_path):
return full_file_path
logger.warning(f"Попытка доступа к файлу вне разрешенных директорий: {file_path}")
raise ValidationError("Путь к файлу не находится в разрешенных директориях")
# Если разрешенные директории не указаны, просто возвращаем нормализованный путь
return normalized_path