initial commit

This commit is contained in:
Илья Глазунов
2025-09-01 23:49:50 +03:00
commit 83cb7d68b0
12 changed files with 2028 additions and 0 deletions
+11
View File
@@ -0,0 +1,11 @@
"""
PyServe - HTTP веб-сервер с функционалом nginx
"""
__version__ = "0.6.0"
__author__ = "Илья Глазунов"
from .server import PyServeServer
from .config import Config
__all__ = ["PyServeServer", "Config"]
+155
View File
@@ -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)
+200
View File
@@ -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()
+279
View File
@@ -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()
+183
View File
@@ -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
+239
View File
@@ -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()