forked from aegis/pyserveX
initial commit
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
"""
|
||||
PyServe - HTTP веб-сервер с функционалом nginx
|
||||
"""
|
||||
|
||||
__version__ = "0.6.0"
|
||||
__author__ = "Илья Глазунов"
|
||||
|
||||
from .server import PyServeServer
|
||||
from .config import Config
|
||||
|
||||
__all__ = ["PyServeServer", "Config"]
|
||||
@@ -0,0 +1,155 @@
|
||||
import yaml
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, List, Optional
|
||||
from dataclasses import dataclass, field
|
||||
import logging
|
||||
from .logging_utils import setup_logging
|
||||
|
||||
|
||||
@dataclass
|
||||
class HttpConfig:
|
||||
static_dir: str = "./static"
|
||||
templates_dir: str = "./templates"
|
||||
|
||||
|
||||
@dataclass
|
||||
class ServerConfig:
|
||||
host: str = "0.0.0.0"
|
||||
port: int = 8080
|
||||
backlog: int = 5
|
||||
default_root: bool = False
|
||||
redirect_instructions: Dict[str, str] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
class SSLConfig:
|
||||
enabled: bool = False
|
||||
cert_file: str = "./ssl/cert.pem"
|
||||
key_file: str = "./ssl/key.pem"
|
||||
|
||||
|
||||
@dataclass
|
||||
class LoggingConfig:
|
||||
level: str = "INFO"
|
||||
console_output: bool = True
|
||||
log_file: str = "./logs/pyserve.log"
|
||||
|
||||
|
||||
@dataclass
|
||||
class RoutingExtensionConfig:
|
||||
regex_locations: Dict[str, Dict[str, Any]] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ExtensionConfig:
|
||||
type: str
|
||||
config: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Config:
|
||||
http: HttpConfig = field(default_factory=HttpConfig)
|
||||
server: ServerConfig = field(default_factory=ServerConfig)
|
||||
ssl: SSLConfig = field(default_factory=SSLConfig)
|
||||
logging: LoggingConfig = field(default_factory=LoggingConfig)
|
||||
extensions: List[ExtensionConfig] = field(default_factory=list)
|
||||
|
||||
@classmethod
|
||||
def from_yaml(cls, file_path: str) -> "Config":
|
||||
try:
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
data = yaml.safe_load(f)
|
||||
|
||||
return cls._from_dict(data)
|
||||
except FileNotFoundError:
|
||||
logging.warning(f"Конфигурационный файл {file_path} не найден. Используются значения по умолчанию.")
|
||||
return cls()
|
||||
except yaml.YAMLError as e:
|
||||
logging.error(f"Ошибка парсинга YAML файла {file_path}: {e}")
|
||||
raise
|
||||
|
||||
@classmethod
|
||||
def _from_dict(cls, data: Dict[str, Any]) -> "Config":
|
||||
config = cls()
|
||||
|
||||
if 'http' in data:
|
||||
http_data = data['http']
|
||||
config.http = HttpConfig(
|
||||
static_dir=http_data.get('static_dir', config.http.static_dir),
|
||||
templates_dir=http_data.get('templates_dir', config.http.templates_dir)
|
||||
)
|
||||
|
||||
if 'server' in data:
|
||||
server_data = data['server']
|
||||
config.server = ServerConfig(
|
||||
host=server_data.get('host', config.server.host),
|
||||
port=server_data.get('port', config.server.port),
|
||||
backlog=server_data.get('backlog', config.server.backlog),
|
||||
default_root=server_data.get('default_root', config.server.default_root),
|
||||
redirect_instructions=server_data.get('redirect_instructions', {})
|
||||
)
|
||||
|
||||
if 'ssl' in data:
|
||||
ssl_data = data['ssl']
|
||||
config.ssl = SSLConfig(
|
||||
enabled=ssl_data.get('enabled', config.ssl.enabled),
|
||||
cert_file=ssl_data.get('cert_file', config.ssl.cert_file),
|
||||
key_file=ssl_data.get('key_file', config.ssl.key_file)
|
||||
)
|
||||
|
||||
if 'logging' in data:
|
||||
log_data = data['logging']
|
||||
config.logging = LoggingConfig(
|
||||
level=log_data.get('level', config.logging.level),
|
||||
console_output=log_data.get('console_output', config.logging.console_output),
|
||||
log_file=log_data.get('log_file', config.logging.log_file)
|
||||
)
|
||||
|
||||
if 'extensions' in data:
|
||||
for ext_data in data['extensions']:
|
||||
extension = ExtensionConfig(
|
||||
type=ext_data.get('type', ''),
|
||||
config=ext_data.get('config', {})
|
||||
)
|
||||
config.extensions.append(extension)
|
||||
|
||||
return config
|
||||
|
||||
def validate(self) -> bool:
|
||||
errors = []
|
||||
|
||||
if not os.path.exists(self.http.static_dir):
|
||||
errors.append(f"Статическая директория не существует: {self.http.static_dir}")
|
||||
|
||||
if self.ssl.enabled:
|
||||
if not os.path.exists(self.ssl.cert_file):
|
||||
errors.append(f"SSL сертификат не найден: {self.ssl.cert_file}")
|
||||
if not os.path.exists(self.ssl.key_file):
|
||||
errors.append(f"SSL ключ не найден: {self.ssl.key_file}")
|
||||
|
||||
if not (1 <= self.server.port <= 65535):
|
||||
errors.append(f"Некорректный порт: {self.server.port}")
|
||||
|
||||
log_dir = os.path.dirname(self.logging.log_file)
|
||||
if log_dir and not os.path.exists(log_dir):
|
||||
try:
|
||||
os.makedirs(log_dir, exist_ok=True)
|
||||
except OSError as e:
|
||||
errors.append(f"Невозможно создать директорию для логов: {e}")
|
||||
|
||||
if errors:
|
||||
for error in errors:
|
||||
logging.error(f"Ошибка конфигурации: {error}")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def setup_logging(self) -> None:
|
||||
"""Настройка системы логирования через кастомный менеджер"""
|
||||
config_dict = {
|
||||
'level': self.logging.level,
|
||||
'console_output': self.logging.console_output,
|
||||
'log_file': self.logging.log_file
|
||||
}
|
||||
setup_logging(config_dict)
|
||||
@@ -0,0 +1,200 @@
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Dict, Any, List, Optional
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import Response
|
||||
from .logging_utils import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class Extension(ABC):
|
||||
def __init__(self, config: Dict[str, Any]):
|
||||
self.config = config
|
||||
self.enabled = True
|
||||
|
||||
@abstractmethod
|
||||
async def process_request(self, request: Request) -> Optional[Response]:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def process_response(self, request: Request, response: Response) -> Response:
|
||||
pass
|
||||
|
||||
def initialize(self) -> None:
|
||||
pass
|
||||
|
||||
def cleanup(self) -> None:
|
||||
pass
|
||||
|
||||
|
||||
class RoutingExtension(Extension):
|
||||
def __init__(self, config: Dict[str, Any]):
|
||||
super().__init__(config)
|
||||
from .routing import create_router_from_config
|
||||
|
||||
regex_locations = config.get("regex_locations", {})
|
||||
self.router = create_router_from_config(regex_locations)
|
||||
from .routing import RequestHandler
|
||||
self.handler = RequestHandler(self.router)
|
||||
|
||||
async def process_request(self, request: Request) -> Optional[Response]:
|
||||
try:
|
||||
return await self.handler.handle(request)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка в RoutingExtension: {e}")
|
||||
return None
|
||||
|
||||
async def process_response(self, request: Request, response: Response) -> Response:
|
||||
return response
|
||||
|
||||
|
||||
class SecurityExtension(Extension):
|
||||
def __init__(self, config: Dict[str, Any]):
|
||||
super().__init__(config)
|
||||
self.allowed_ips = config.get("allowed_ips", [])
|
||||
self.blocked_ips = config.get("blocked_ips", [])
|
||||
self.security_headers = config.get("security_headers", {
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
"X-Frame-Options": "DENY",
|
||||
"X-XSS-Protection": "1; mode=block"
|
||||
})
|
||||
|
||||
async def process_request(self, request: Request) -> Optional[Response]:
|
||||
client_ip = request.client.host if request.client else "unknown"
|
||||
|
||||
if self.blocked_ips and client_ip in self.blocked_ips:
|
||||
logger.warning(f"Заблокирован запрос от IP: {client_ip}")
|
||||
from starlette.responses import PlainTextResponse
|
||||
return PlainTextResponse("403 Forbidden", status_code=403)
|
||||
|
||||
if self.allowed_ips and client_ip not in self.allowed_ips:
|
||||
logger.warning(f"Запрещен доступ для IP: {client_ip}")
|
||||
from starlette.responses import PlainTextResponse
|
||||
return PlainTextResponse("403 Forbidden", status_code=403)
|
||||
|
||||
return None
|
||||
|
||||
async def process_response(self, request: Request, response: Response) -> Response:
|
||||
for header, value in self.security_headers.items():
|
||||
response.headers[header] = value
|
||||
return response
|
||||
|
||||
|
||||
class CachingExtension(Extension):
|
||||
def __init__(self, config: Dict[str, Any]):
|
||||
super().__init__(config)
|
||||
self.cache: Dict[str, Any] = {}
|
||||
self.cache_patterns = config.get("cache_patterns", [])
|
||||
self.cache_ttl = config.get("cache_ttl", 3600)
|
||||
|
||||
async def process_request(self, request: Request) -> Optional[Response]:
|
||||
# TODO: Реализовать проверку кэша
|
||||
return None
|
||||
|
||||
async def process_response(self, request: Request, response: Response) -> Response:
|
||||
# TODO: Реализовать кэширование ответов
|
||||
return response
|
||||
|
||||
|
||||
class MonitoringExtension(Extension):
|
||||
def __init__(self, config: Dict[str, Any]):
|
||||
super().__init__(config)
|
||||
self.request_count = 0
|
||||
self.error_count = 0
|
||||
self.response_times = []
|
||||
self.enable_metrics = config.get("enable_metrics", True)
|
||||
|
||||
async def process_request(self, request: Request) -> Optional[Response]:
|
||||
if self.enable_metrics:
|
||||
self.request_count += 1
|
||||
request.state.start_time = __import__('time').time()
|
||||
return None
|
||||
|
||||
async def process_response(self, request: Request, response: Response) -> Response:
|
||||
if self.enable_metrics and hasattr(request.state, 'start_time'):
|
||||
response_time = __import__('time').time() - request.state.start_time
|
||||
self.response_times.append(response_time)
|
||||
|
||||
if response.status_code >= 400:
|
||||
self.error_count += 1
|
||||
|
||||
logger.info(f"Request: {request.method} {request.url.path} - "
|
||||
f"Status: {response.status_code} - "
|
||||
f"Time: {response_time:.3f}s")
|
||||
|
||||
return response
|
||||
|
||||
def get_metrics(self) -> Dict[str, Any]:
|
||||
avg_response_time = (sum(self.response_times) / len(self.response_times)
|
||||
if self.response_times else 0)
|
||||
|
||||
return {
|
||||
"request_count": self.request_count,
|
||||
"error_count": self.error_count,
|
||||
"error_rate": self.error_count / max(self.request_count, 1),
|
||||
"avg_response_time": avg_response_time,
|
||||
"total_response_times": len(self.response_times)
|
||||
}
|
||||
|
||||
|
||||
class ExtensionManager:
|
||||
def __init__(self):
|
||||
self.extensions: List[Extension] = []
|
||||
self.extension_registry = {
|
||||
"routing": RoutingExtension,
|
||||
"security": SecurityExtension,
|
||||
"caching": CachingExtension,
|
||||
"monitoring": MonitoringExtension
|
||||
}
|
||||
|
||||
def register_extension_type(self, name: str, extension_class: type) -> None:
|
||||
self.extension_registry[name] = extension_class
|
||||
|
||||
def load_extension(self, extension_type: str, config: Dict[str, Any]) -> None:
|
||||
if extension_type not in self.extension_registry:
|
||||
logger.error(f"Неизвестный тип расширения: {extension_type}")
|
||||
return
|
||||
|
||||
try:
|
||||
extension_class = self.extension_registry[extension_type]
|
||||
extension = extension_class(config)
|
||||
extension.initialize()
|
||||
self.extensions.append(extension)
|
||||
logger.info(f"Загружено расширение: {extension_type}")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка загрузки расширения {extension_type}: {e}")
|
||||
|
||||
async def process_request(self, request: Request) -> Optional[Response]:
|
||||
for extension in self.extensions:
|
||||
if not extension.enabled:
|
||||
continue
|
||||
|
||||
try:
|
||||
response = await extension.process_request(request)
|
||||
if response is not None:
|
||||
return response
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка в расширении {type(extension).__name__}: {e}")
|
||||
|
||||
return None
|
||||
|
||||
async def process_response(self, request: Request, response: Response) -> Response:
|
||||
for extension in self.extensions:
|
||||
if not extension.enabled:
|
||||
continue
|
||||
|
||||
try:
|
||||
response = await extension.process_response(request, response)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка в расширении {type(extension).__name__}: {e}")
|
||||
|
||||
return response
|
||||
|
||||
def cleanup(self) -> None:
|
||||
for extension in self.extensions:
|
||||
try:
|
||||
extension.cleanup()
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при очистке расширения {type(extension).__name__}: {e}")
|
||||
|
||||
self.extensions.clear()
|
||||
@@ -0,0 +1,279 @@
|
||||
"""
|
||||
Кастомная система логирования для PyServe
|
||||
Управляет логгерами всех пакетов и модулей, включая uvicorn и starlette
|
||||
"""
|
||||
|
||||
import logging
|
||||
import logging.handlers
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, List
|
||||
|
||||
from . import __version__
|
||||
|
||||
|
||||
class UvicornLogFilter(logging.Filter):
|
||||
def filter(self, record):
|
||||
if hasattr(record, 'name') and 'uvicorn.access' in record.name:
|
||||
if hasattr(record, 'getMessage'):
|
||||
msg = record.getMessage()
|
||||
if ' - "' in msg and '" ' in msg:
|
||||
parts = msg.split(' - "')
|
||||
if len(parts) >= 2:
|
||||
client_info = parts[0]
|
||||
request_part = parts[1].split('" ')
|
||||
if len(request_part) >= 2:
|
||||
method_path = request_part[0]
|
||||
status_part = request_part[1]
|
||||
record.msg = f"Access: {client_info} - {method_path} - {status_part}"
|
||||
|
||||
return True
|
||||
|
||||
|
||||
class PyServeFormatter(logging.Formatter):
|
||||
COLORS = {
|
||||
'DEBUG': '\033[36m', # Cyan
|
||||
'INFO': '\033[32m', # Green
|
||||
'WARNING': '\033[33m', # Yellow
|
||||
'ERROR': '\033[31m', # Red
|
||||
'CRITICAL': '\033[35m', # Magenta
|
||||
'RESET': '\033[0m' # Reset
|
||||
}
|
||||
|
||||
def __init__(self, use_colors: bool = True, show_module: bool = True, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.use_colors = use_colors and hasattr(sys.stderr, 'isatty') and sys.stderr.isatty()
|
||||
self.show_module = show_module
|
||||
|
||||
def format(self, record):
|
||||
if self.use_colors:
|
||||
levelname = record.levelname
|
||||
if levelname in self.COLORS:
|
||||
record.levelname = f"{self.COLORS[levelname]}{levelname}{self.COLORS['RESET']}"
|
||||
|
||||
if self.show_module and hasattr(record, 'name'):
|
||||
name = record.name
|
||||
if name.startswith('uvicorn'):
|
||||
record.name = 'uvicorn'
|
||||
elif name.startswith('pyserve'):
|
||||
pass
|
||||
elif name.startswith('starlette'):
|
||||
record.name = 'starlette'
|
||||
|
||||
return super().format(record)
|
||||
|
||||
|
||||
class AccessLogHandler(logging.Handler):
|
||||
def __init__(self, logger_name: str = 'pyserve.access'):
|
||||
super().__init__()
|
||||
self.access_logger = logging.getLogger(logger_name)
|
||||
|
||||
def emit(self, record):
|
||||
self.access_logger.handle(record)
|
||||
|
||||
|
||||
class PyServeLogManager:
|
||||
def __init__(self):
|
||||
self.configured = False
|
||||
self.handlers: Dict[str, logging.Handler] = {}
|
||||
self.loggers: Dict[str, logging.Logger] = {}
|
||||
self.original_handlers: Dict[str, List[logging.Handler]] = {}
|
||||
|
||||
def setup_logging(self, config: Dict[str, Any]) -> None:
|
||||
if self.configured:
|
||||
return
|
||||
|
||||
level = config.get('level', 'INFO').upper()
|
||||
console_output = config.get('console_output', True)
|
||||
log_file = config.get('log_file', './logs/pyserve.log')
|
||||
self._save_original_handlers()
|
||||
self._clear_all_handlers()
|
||||
root_logger = logging.getLogger()
|
||||
root_logger.setLevel(logging.DEBUG)
|
||||
|
||||
detailed_formatter = PyServeFormatter(
|
||||
use_colors=False,
|
||||
show_module=True,
|
||||
fmt='%(asctime)s - %(name)s - %(levelname)s - [%(filename)s:%(lineno)d] - %(message)s'
|
||||
)
|
||||
|
||||
console_formatter = PyServeFormatter(
|
||||
use_colors=True,
|
||||
show_module=True,
|
||||
fmt='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||
)
|
||||
|
||||
if console_output:
|
||||
console_handler = logging.StreamHandler(sys.stdout)
|
||||
console_handler.setLevel(getattr(logging, level))
|
||||
console_handler.setFormatter(console_formatter)
|
||||
|
||||
console_handler.addFilter(UvicornLogFilter())
|
||||
|
||||
root_logger.addHandler(console_handler)
|
||||
self.handlers['console'] = console_handler
|
||||
|
||||
if log_file:
|
||||
self._ensure_log_directory(log_file)
|
||||
|
||||
file_handler = logging.handlers.RotatingFileHandler(
|
||||
log_file,
|
||||
maxBytes=10*1024*1024, # 10MB
|
||||
backupCount=5,
|
||||
encoding='utf-8'
|
||||
)
|
||||
file_handler.setLevel(logging.DEBUG)
|
||||
file_handler.setFormatter(detailed_formatter)
|
||||
file_handler.addFilter(UvicornLogFilter())
|
||||
|
||||
root_logger.addHandler(file_handler)
|
||||
self.handlers['file'] = file_handler
|
||||
|
||||
self._configure_library_loggers(level)
|
||||
|
||||
self._intercept_uvicorn_logging()
|
||||
|
||||
pyserve_logger = logging.getLogger('pyserve')
|
||||
pyserve_logger.setLevel(getattr(logging, level))
|
||||
self.loggers['pyserve'] = pyserve_logger
|
||||
|
||||
pyserve_logger.info(f"PyServe v{__version__} - Система логирования инициализирована")
|
||||
pyserve_logger.info(f"Уровень логирования: {level}")
|
||||
pyserve_logger.info(f"Консольный вывод: {'включен' if console_output else 'отключен'}")
|
||||
pyserve_logger.info(f"Файл логов: {log_file if log_file else 'отключен'}")
|
||||
|
||||
self.configured = True
|
||||
|
||||
def _save_original_handlers(self) -> None:
|
||||
logger_names = ['', 'uvicorn', 'uvicorn.access', 'uvicorn.error', 'starlette']
|
||||
|
||||
for name in logger_names:
|
||||
logger = logging.getLogger(name)
|
||||
self.original_handlers[name] = logger.handlers.copy()
|
||||
|
||||
def _clear_all_handlers(self) -> None:
|
||||
root_logger = logging.getLogger()
|
||||
for handler in root_logger.handlers[:]:
|
||||
root_logger.removeHandler(handler)
|
||||
handler.close()
|
||||
|
||||
logger_names = ['uvicorn', 'uvicorn.access', 'uvicorn.error', 'starlette']
|
||||
for name in logger_names:
|
||||
logger = logging.getLogger(name)
|
||||
for handler in logger.handlers[:]:
|
||||
logger.removeHandler(handler)
|
||||
handler.close()
|
||||
|
||||
self.handlers.clear()
|
||||
|
||||
def _ensure_log_directory(self, log_file: str) -> None:
|
||||
log_dir = Path(log_file).parent
|
||||
log_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def _configure_library_loggers(self, main_level: str) -> None:
|
||||
library_configs = {
|
||||
# Uvicorn и связанные - только в DEBUG режиме
|
||||
'uvicorn': 'DEBUG' if main_level == 'DEBUG' else 'WARNING',
|
||||
'uvicorn.access': 'DEBUG' if main_level == 'DEBUG' else 'WARNING',
|
||||
'uvicorn.error': 'DEBUG' if main_level == 'DEBUG' else 'ERROR',
|
||||
'uvicorn.asgi': 'DEBUG' if main_level == 'DEBUG' else 'WARNING',
|
||||
|
||||
# Starlette - только в DEBUG режиме
|
||||
'starlette': 'DEBUG' if main_level == 'DEBUG' else 'WARNING',
|
||||
|
||||
'asyncio': 'WARNING',
|
||||
'concurrent.futures': 'WARNING',
|
||||
'multiprocessing': 'WARNING',
|
||||
'pyserve': main_level,
|
||||
'pyserve.server': main_level,
|
||||
'pyserve.routing': main_level,
|
||||
'pyserve.extensions': main_level,
|
||||
'pyserve.config': main_level,
|
||||
}
|
||||
|
||||
for logger_name, level in library_configs.items():
|
||||
logger = logging.getLogger(logger_name)
|
||||
logger.setLevel(getattr(logging, level))
|
||||
if logger_name.startswith('uvicorn') and logger_name != 'uvicorn':
|
||||
logger.propagate = False
|
||||
self.loggers[logger_name] = logger
|
||||
|
||||
def _intercept_uvicorn_logging(self) -> None:
|
||||
uvicorn_logger = logging.getLogger('uvicorn')
|
||||
uvicorn_access_logger = logging.getLogger('uvicorn.access')
|
||||
|
||||
for handler in uvicorn_logger.handlers[:]:
|
||||
uvicorn_logger.removeHandler(handler)
|
||||
|
||||
for handler in uvicorn_access_logger.handlers[:]:
|
||||
uvicorn_access_logger.removeHandler(handler)
|
||||
|
||||
uvicorn_logger.propagate = True
|
||||
uvicorn_access_logger.propagate = True
|
||||
|
||||
def get_logger(self, name: str) -> logging.Logger:
|
||||
if name not in self.loggers:
|
||||
logger = logging.getLogger(name)
|
||||
self.loggers[name] = logger
|
||||
|
||||
return self.loggers[name]
|
||||
|
||||
def set_level(self, logger_name: str, level: str) -> None:
|
||||
if logger_name in self.loggers:
|
||||
self.loggers[logger_name].setLevel(getattr(logging, level.upper()))
|
||||
|
||||
def add_handler(self, name: str, handler: logging.Handler) -> None:
|
||||
if name not in self.handlers:
|
||||
root_logger = logging.getLogger()
|
||||
root_logger.addHandler(handler)
|
||||
self.handlers[name] = handler
|
||||
|
||||
def remove_handler(self, name: str) -> None:
|
||||
if name in self.handlers:
|
||||
root_logger = logging.getLogger()
|
||||
root_logger.removeHandler(self.handlers[name])
|
||||
self.handlers[name].close()
|
||||
del self.handlers[name]
|
||||
|
||||
def create_access_log(self, method: str, path: str, status_code: int,
|
||||
response_time: float, client_ip: str, user_agent: str = "") -> None:
|
||||
access_logger = self.get_logger('pyserve.access')
|
||||
|
||||
log_message = f'{client_ip} - - [{time.strftime("%d/%b/%Y:%H:%M:%S %z")}] ' \
|
||||
f'"{method} {path} HTTP/1.1" {status_code} - ' \
|
||||
f'"{user_agent}" {response_time:.3f}s'
|
||||
|
||||
access_logger.info(log_message)
|
||||
|
||||
def shutdown(self) -> None:
|
||||
for handler in self.handlers.values():
|
||||
handler.close()
|
||||
self.handlers.clear()
|
||||
|
||||
for logger_name, handlers in self.original_handlers.items():
|
||||
logger = logging.getLogger(logger_name)
|
||||
for handler in handlers:
|
||||
logger.addHandler(handler)
|
||||
|
||||
self.loggers.clear()
|
||||
self.configured = False
|
||||
|
||||
log_manager = PyServeLogManager()
|
||||
|
||||
|
||||
def setup_logging(config: Dict[str, Any]) -> None:
|
||||
log_manager.setup_logging(config)
|
||||
|
||||
|
||||
def get_logger(name: str) -> logging.Logger:
|
||||
return log_manager.get_logger(name)
|
||||
|
||||
|
||||
def create_access_log(method: str, path: str, status_code: int,
|
||||
response_time: float, client_ip: str, user_agent: str = "") -> None:
|
||||
log_manager.create_access_log(method, path, status_code, response_time, client_ip, user_agent)
|
||||
|
||||
|
||||
def shutdown_logging() -> None:
|
||||
log_manager.shutdown()
|
||||
@@ -0,0 +1,183 @@
|
||||
import re
|
||||
import mimetypes
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, Optional, Pattern
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import Response, FileResponse, PlainTextResponse
|
||||
from .logging_utils import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
class RouteMatch:
|
||||
def __init__(self, config: Dict[str, Any], params: Optional[Dict[str, str]] = None):
|
||||
self.config = config
|
||||
self.params = params or {}
|
||||
|
||||
class Router:
|
||||
def __init__(self, static_dir: str = "./static"):
|
||||
self.static_dir = Path(static_dir)
|
||||
self.routes: Dict[Pattern, Dict[str, Any]] = {}
|
||||
self.exact_routes: Dict[str, Dict[str, Any]] = {}
|
||||
self.default_route: Optional[Dict[str, Any]] = None
|
||||
|
||||
def add_route(self, pattern: str, config: Dict[str, Any]) -> None:
|
||||
if pattern.startswith("="):
|
||||
exact_path = pattern[1:]
|
||||
self.exact_routes[exact_path] = config
|
||||
logger.debug(f"Добавлен exact маршрут: {exact_path}")
|
||||
return
|
||||
|
||||
if pattern == "__default__":
|
||||
self.default_route = config
|
||||
logger.debug("Добавлен default маршрут")
|
||||
return
|
||||
|
||||
if pattern.startswith("~"):
|
||||
case_insensitive = pattern.startswith("~*")
|
||||
regex_pattern = pattern[2:] if case_insensitive else pattern[1:]
|
||||
|
||||
flags = re.IGNORECASE if case_insensitive else 0
|
||||
try:
|
||||
compiled_pattern = re.compile(regex_pattern, flags)
|
||||
self.routes[compiled_pattern] = config
|
||||
logger.debug(f"Добавлен regex маршрут: {pattern}")
|
||||
except re.error as e:
|
||||
logger.error(f"Ошибка компиляции regex {pattern}: {e}")
|
||||
|
||||
def match(self, path: str) -> Optional[RouteMatch]:
|
||||
if path in self.exact_routes:
|
||||
return RouteMatch(self.exact_routes[path])
|
||||
|
||||
for pattern, config in self.routes.items():
|
||||
match = pattern.search(path)
|
||||
if match:
|
||||
params = match.groupdict()
|
||||
return RouteMatch(config, params)
|
||||
|
||||
if self.default_route:
|
||||
return RouteMatch(self.default_route)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
class RequestHandler:
|
||||
def __init__(self, router: Router, static_dir: str = "./static"):
|
||||
self.router = router
|
||||
self.static_dir = Path(static_dir)
|
||||
|
||||
async def handle(self, request: Request) -> Response:
|
||||
path = request.url.path
|
||||
|
||||
logger.info(f"{request.method} {path}")
|
||||
|
||||
route_match = self.router.match(path)
|
||||
if not route_match:
|
||||
return PlainTextResponse("404 Not Found", status_code=404)
|
||||
|
||||
try:
|
||||
return await self._process_route(request, route_match)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка обработки запроса {path}: {e}")
|
||||
return PlainTextResponse("500 Internal Server Error", status_code=500)
|
||||
|
||||
async def _process_route(self, request: Request, route_match: RouteMatch) -> Response:
|
||||
config = route_match.config
|
||||
path = request.url.path
|
||||
|
||||
if "return" in config:
|
||||
status_text = config["return"]
|
||||
if " " in status_text:
|
||||
status_code, text = status_text.split(" ", 1)
|
||||
status_code = int(status_code)
|
||||
else:
|
||||
status_code = int(status_text)
|
||||
text = ""
|
||||
|
||||
content_type = config.get("content_type", "text/plain")
|
||||
return PlainTextResponse(text, status_code=status_code,
|
||||
media_type=content_type)
|
||||
|
||||
if "proxy_pass" in config:
|
||||
return await self._handle_proxy(request, config, route_match.params)
|
||||
|
||||
if "root" in config:
|
||||
return await self._handle_static(request, config)
|
||||
|
||||
if config.get("spa_fallback"):
|
||||
return await self._handle_spa_fallback(request, config)
|
||||
|
||||
return PlainTextResponse("404 Not Found", status_code=404)
|
||||
|
||||
async def _handle_static(self, request: Request, config: Dict[str, Any]) -> Response:
|
||||
root = Path(config["root"])
|
||||
path = request.url.path.lstrip("/")
|
||||
|
||||
if not path or path == "/":
|
||||
index_file = config.get("index_file", "index.html")
|
||||
file_path = root / index_file
|
||||
else:
|
||||
file_path = root / path
|
||||
|
||||
try:
|
||||
file_path = file_path.resolve()
|
||||
root = root.resolve()
|
||||
if not str(file_path).startswith(str(root)):
|
||||
return PlainTextResponse("403 Forbidden", status_code=403)
|
||||
except OSError:
|
||||
return PlainTextResponse("404 Not Found", status_code=404)
|
||||
|
||||
if not file_path.exists() or not file_path.is_file():
|
||||
return PlainTextResponse("404 Not Found", status_code=404)
|
||||
|
||||
content_type, _ = mimetypes.guess_type(str(file_path))
|
||||
|
||||
response = FileResponse(str(file_path), media_type=content_type)
|
||||
|
||||
if "headers" in config:
|
||||
for header in config["headers"]:
|
||||
if ":" in header:
|
||||
name, value = header.split(":", 1)
|
||||
response.headers[name.strip()] = value.strip()
|
||||
|
||||
if "cache_control" in config:
|
||||
response.headers["Cache-Control"] = config["cache_control"]
|
||||
|
||||
return response
|
||||
|
||||
async def _handle_spa_fallback(self, request: Request, config: Dict[str, Any]) -> Response:
|
||||
path = request.url.path
|
||||
|
||||
exclude_patterns = config.get("exclude_patterns", [])
|
||||
for pattern in exclude_patterns:
|
||||
if path.startswith(pattern):
|
||||
return PlainTextResponse("404 Not Found", status_code=404)
|
||||
|
||||
root = Path(config.get("root", self.static_dir))
|
||||
index_file = config.get("index_file", "index.html")
|
||||
file_path = root / index_file
|
||||
|
||||
if file_path.exists() and file_path.is_file():
|
||||
return FileResponse(str(file_path), media_type="text/html")
|
||||
|
||||
return PlainTextResponse("404 Not Found", status_code=404)
|
||||
|
||||
async def _handle_proxy(self, request: Request, config: Dict[str, Any],
|
||||
params: Dict[str, str]) -> Response:
|
||||
# TODO: Реализовать полноценное проксирование
|
||||
proxy_url = config["proxy_pass"]
|
||||
|
||||
for key, value in params.items():
|
||||
proxy_url = proxy_url.replace(f"{{{key}}}", value)
|
||||
|
||||
logger.info(f"Проксирование запроса на: {proxy_url}")
|
||||
|
||||
return PlainTextResponse(f"Proxy to: {proxy_url}", status_code=200)
|
||||
|
||||
|
||||
def create_router_from_config(regex_locations: Dict[str, Dict[str, Any]]) -> Router:
|
||||
router = Router()
|
||||
|
||||
for pattern, config in regex_locations.items():
|
||||
router.add_route(pattern, config)
|
||||
|
||||
return router
|
||||
@@ -0,0 +1,239 @@
|
||||
import ssl
|
||||
import uvicorn
|
||||
import time
|
||||
from starlette.applications import Starlette
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import Response, PlainTextResponse
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.routing import Route
|
||||
from pathlib import Path
|
||||
from typing import Optional, Dict, Any
|
||||
|
||||
from .config import Config
|
||||
from .extensions import ExtensionManager
|
||||
from .logging_utils import get_logger
|
||||
from . import __version__
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class PyServeMiddleware(BaseHTTPMiddleware):
|
||||
def __init__(self, app, extension_manager: ExtensionManager):
|
||||
super().__init__(app)
|
||||
self.extension_manager = extension_manager
|
||||
self.access_logger = get_logger('pyserve.access')
|
||||
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
start_time = time.time()
|
||||
response = await self.extension_manager.process_request(request)
|
||||
|
||||
if response is None:
|
||||
response = await call_next(request)
|
||||
|
||||
response = await self.extension_manager.process_response(request, response)
|
||||
|
||||
response.headers["Server"] = f"pyserve/{__version__}"
|
||||
client_ip = request.client.host if request.client else "unknown"
|
||||
method = request.method
|
||||
path = str(request.url.path)
|
||||
query = str(request.url.query) if request.url.query else ""
|
||||
if query:
|
||||
path += f"?{query}"
|
||||
status_code = response.status_code
|
||||
process_time = round((time.time() - start_time) * 1000, 2)
|
||||
|
||||
self.access_logger.info(f"{client_ip} - {method} {path} - {status_code} - {process_time}ms")
|
||||
|
||||
return response
|
||||
|
||||
|
||||
class PyServeServer:
|
||||
def __init__(self, config: Config):
|
||||
self.config = config
|
||||
self.extension_manager = ExtensionManager()
|
||||
self.app: Optional[Starlette] = None
|
||||
self._setup_logging()
|
||||
self._load_extensions()
|
||||
self._create_app()
|
||||
|
||||
def _setup_logging(self) -> None:
|
||||
self.config.setup_logging()
|
||||
logger.info("PyServe сервер инициализирован")
|
||||
|
||||
def _load_extensions(self) -> None:
|
||||
for ext_config in self.config.extensions:
|
||||
self.extension_manager.load_extension(
|
||||
ext_config.type,
|
||||
ext_config.config
|
||||
)
|
||||
|
||||
def _create_app(self) -> None:
|
||||
routes = [
|
||||
Route("/health", self._health_check, methods=["GET"]),
|
||||
Route("/metrics", self._metrics, methods=["GET"]),
|
||||
Route("/{path:path}", self._catch_all, methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"]),
|
||||
]
|
||||
|
||||
self.app = Starlette(routes=routes)
|
||||
self.app.add_middleware(PyServeMiddleware, extension_manager=self.extension_manager)
|
||||
|
||||
async def _health_check(self, request: Request) -> Response:
|
||||
return PlainTextResponse("OK", status_code=200)
|
||||
|
||||
async def _metrics(self, request: Request) -> Response:
|
||||
metrics = {}
|
||||
|
||||
for extension in self.extension_manager.extensions:
|
||||
if hasattr(extension, 'get_metrics'):
|
||||
try:
|
||||
ext_metrics = getattr(extension, 'get_metrics')()
|
||||
metrics.update(ext_metrics)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения метрик от {type(extension).__name__}: {e}")
|
||||
|
||||
import json
|
||||
return Response(
|
||||
json.dumps(metrics, ensure_ascii=False, indent=2),
|
||||
media_type="application/json"
|
||||
)
|
||||
|
||||
async def _catch_all(self, request: Request) -> Response:
|
||||
return PlainTextResponse("404 Not Found", status_code=404)
|
||||
|
||||
def _create_ssl_context(self) -> Optional[ssl.SSLContext]:
|
||||
if not self.config.ssl.enabled:
|
||||
return None
|
||||
|
||||
if not Path(self.config.ssl.cert_file).exists():
|
||||
logger.error(f"SSL сертификат не найден: {self.config.ssl.cert_file}")
|
||||
return None
|
||||
|
||||
if not Path(self.config.ssl.key_file).exists():
|
||||
logger.error(f"SSL ключ не найден: {self.config.ssl.key_file}")
|
||||
return None
|
||||
|
||||
try:
|
||||
context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)
|
||||
context.load_cert_chain(
|
||||
self.config.ssl.cert_file,
|
||||
self.config.ssl.key_file
|
||||
)
|
||||
logger.info("SSL контекст создан успешно")
|
||||
return context
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка создания SSL контекста: {e}")
|
||||
return None
|
||||
|
||||
def run(self) -> None:
|
||||
"""Запуск сервера"""
|
||||
if not self.config.validate():
|
||||
logger.error("Конфигурация невалидна, сервер не может быть запущен")
|
||||
return
|
||||
|
||||
# Создаем директории если их нет
|
||||
self._ensure_directories()
|
||||
|
||||
# SSL конфигурация
|
||||
ssl_context = self._create_ssl_context()
|
||||
|
||||
uvicorn_config = {
|
||||
"app": self.app,
|
||||
"host": self.config.server.host,
|
||||
"port": self.config.server.port,
|
||||
"log_level": "critical",
|
||||
"access_log": False,
|
||||
"use_colors": False,
|
||||
"server_header": False,
|
||||
}
|
||||
|
||||
if ssl_context:
|
||||
uvicorn_config.update({
|
||||
"ssl_keyfile": self.config.ssl.key_file,
|
||||
"ssl_certfile": self.config.ssl.cert_file,
|
||||
})
|
||||
protocol = "https"
|
||||
else:
|
||||
protocol = "http"
|
||||
|
||||
logger.info(f"Запуск PyServe сервера на {protocol}://{self.config.server.host}:{self.config.server.port}")
|
||||
|
||||
try:
|
||||
uvicorn.run(**uvicorn_config)
|
||||
except KeyboardInterrupt:
|
||||
logger.info("Получен сигнал остановки")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка запуска сервера: {e}")
|
||||
finally:
|
||||
self.shutdown()
|
||||
|
||||
async def run_async(self) -> None:
|
||||
if not self.config.validate():
|
||||
logger.error("Конфигурация невалидна, сервер не может быть запущен")
|
||||
return
|
||||
|
||||
self._ensure_directories()
|
||||
|
||||
config = uvicorn.Config(
|
||||
app=self.app, # type: ignore
|
||||
host=self.config.server.host,
|
||||
port=self.config.server.port,
|
||||
log_level="critical",
|
||||
access_log=False,
|
||||
use_colors=False,
|
||||
)
|
||||
|
||||
server = uvicorn.Server(config)
|
||||
|
||||
try:
|
||||
await server.serve()
|
||||
finally:
|
||||
self.shutdown()
|
||||
|
||||
def _ensure_directories(self) -> None:
|
||||
directories = [
|
||||
self.config.http.static_dir,
|
||||
self.config.http.templates_dir,
|
||||
]
|
||||
|
||||
log_dir = Path(self.config.logging.log_file).parent
|
||||
if log_dir != Path("."):
|
||||
directories.append(str(log_dir))
|
||||
|
||||
for directory in directories:
|
||||
Path(directory).mkdir(parents=True, exist_ok=True)
|
||||
logger.debug(f"Создана/проверена директория: {directory}")
|
||||
|
||||
def shutdown(self) -> None:
|
||||
logger.info("Завершение работы PyServe сервера")
|
||||
self.extension_manager.cleanup()
|
||||
|
||||
from .logging_utils import shutdown_logging
|
||||
shutdown_logging()
|
||||
|
||||
logger.info("Сервер остановлен")
|
||||
|
||||
def add_extension(self, extension_type: str, config: Dict[str, Any]) -> None:
|
||||
self.extension_manager.load_extension(extension_type, config)
|
||||
|
||||
def get_metrics(self) -> Dict[str, Any]:
|
||||
metrics = {"server_status": "running"}
|
||||
|
||||
for extension in self.extension_manager.extensions:
|
||||
if hasattr(extension, 'get_metrics'):
|
||||
try:
|
||||
ext_metrics = getattr(extension, 'get_metrics')()
|
||||
metrics.update(ext_metrics)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения метрик от {type(extension).__name__}: {e}")
|
||||
|
||||
return metrics
|
||||
|
||||
|
||||
def create_server(config_path: str = "config.yaml") -> PyServeServer:
|
||||
config = Config.from_yaml(config_path)
|
||||
return PyServeServer(config)
|
||||
|
||||
|
||||
def run_server(config_path: str = "config.yaml") -> None:
|
||||
server = create_server(config_path)
|
||||
server.run()
|
||||
Reference in New Issue
Block a user