pyferro 0.5.6__py3-none-any.whl

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.
Files changed (70) hide show
  1. pyferro/__init__.py +67 -0
  2. pyferro/__main__.py +5 -0
  3. pyferro/app.py +549 -0
  4. pyferro/cli.py +164 -0
  5. pyferro/compat.py +112 -0
  6. pyferro/core/__init__.py +22 -0
  7. pyferro/core/backplane.py +76 -0
  8. pyferro/core/constants.py +20 -0
  9. pyferro/core/http.py +118 -0
  10. pyferro/core/lib/config.py +195 -0
  11. pyferro/core/lifecycle.py +79 -0
  12. pyferro/core/logger.py +177 -0
  13. pyferro/core/router.py +18 -0
  14. pyferro/core/security.py +155 -0
  15. pyferro/core/session.py +532 -0
  16. pyferro/core/tabular.py +121 -0
  17. pyferro/plugins/__init__.py +26 -0
  18. pyferro/plugins/auth/__init__.py +86 -0
  19. pyferro/plugins/auth/config.py +46 -0
  20. pyferro/plugins/auth/core.py +131 -0
  21. pyferro/plugins/auth/discovery.py +152 -0
  22. pyferro/plugins/auth/handlers.py +895 -0
  23. pyferro/plugins/auth/models.py +510 -0
  24. pyferro/plugins/auth/oauth.py +25 -0
  25. pyferro/plugins/auth/permission_handlers.py +422 -0
  26. pyferro/plugins/auth/permissions.py +65 -0
  27. pyferro/plugins/auth/security.py +193 -0
  28. pyferro/plugins/broadcast/__init__.py +182 -0
  29. pyferro/plugins/crud/__init__.py +88 -0
  30. pyferro/plugins/crud/admin_handlers.py +196 -0
  31. pyferro/plugins/crud/auth.py +95 -0
  32. pyferro/plugins/crud/coerce.py +126 -0
  33. pyferro/plugins/crud/events.py +65 -0
  34. pyferro/plugins/crud/handlers.py +825 -0
  35. pyferro/plugins/crud/identity.py +241 -0
  36. pyferro/plugins/crud/meta.py +279 -0
  37. pyferro/plugins/crud/policy.py +140 -0
  38. pyferro/plugins/crud/registry.py +89 -0
  39. pyferro/plugins/crud/static/assets/index-BIwvZwye.js +2 -0
  40. pyferro/plugins/crud/static/assets/index-BpxHDOGr.css +1 -0
  41. pyferro/plugins/crud/static/index.html +13 -0
  42. pyferro/plugins/crud/static_handler.py +135 -0
  43. pyferro/plugins/db/__init__.py +22 -0
  44. pyferro/plugins/db/session.py +161 -0
  45. pyferro/plugins/files/__init__.py +36 -0
  46. pyferro/plugins/files/handlers.py +162 -0
  47. pyferro/plugins/files/models.py +62 -0
  48. pyferro/plugins/files/service.py +195 -0
  49. pyferro/plugins/files/upload.py +515 -0
  50. pyferro/plugins/raw_ws/__init__.py +28 -0
  51. pyferro/plugins/raw_ws/id_gen.py +26 -0
  52. pyferro/plugins/raw_ws/manager.py +126 -0
  53. pyferro/plugins/raw_ws/router.py +54 -0
  54. pyferro/plugins/raw_ws/session.py +136 -0
  55. pyferro/plugins/seo/__init__.py +72 -0
  56. pyferro/plugins/seo/config.py +178 -0
  57. pyferro/plugins/seo/detector.py +103 -0
  58. pyferro/plugins/seo/indexnow.py +294 -0
  59. pyferro/plugins/seo/renderer.py +259 -0
  60. pyferro/plugins/seo/router.py +189 -0
  61. pyferro/plugins/seo/schemas.py +34 -0
  62. pyferro/plugins/seo/sitemap.py +303 -0
  63. pyferro/plugins/smart_cache/__init__.py +24 -0
  64. pyferro/plugins/smart_cache/engine.py +353 -0
  65. pyferro/plugins/smart_cache/handlers.py +54 -0
  66. pyferro-0.5.6.dist-info/METADATA +651 -0
  67. pyferro-0.5.6.dist-info/RECORD +70 -0
  68. pyferro-0.5.6.dist-info/WHEEL +5 -0
  69. pyferro-0.5.6.dist-info/licenses/LICENSE +21 -0
  70. pyferro-0.5.6.dist-info/top_level.txt +1 -0
pyferro/__init__.py ADDED
@@ -0,0 +1,67 @@
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ pyferro: High-performance reactive fullstack Python framework on Rust RSGI (Granian)
4
+ with symmetric WSRPC (JSON-RPC 2.0) and Tabular Data Compression (RFC 0002).
5
+ """
6
+
7
+ # Автоматическая активация моста обратной совместимости для плоских импортов core и plugins
8
+ from pyferro.compat import install_compat
9
+ install_compat()
10
+
11
+ from pyferro.core.session import (
12
+ rpc_method, RPCError, JsonRpcSession,
13
+ current_user_ctx, current_session_ctx, current_rpc_id_ctx, current_transport_ctx,
14
+ ACTIVE_SESSIONS_SET
15
+ )
16
+ from pyferro.core.tabular import pack_tabular, unpack_tabular, tabular_response, is_tabular
17
+ from pyferro.core.constants import ADMIN_ROLE, DEFAULT_USER_ROLE, SYSTEM_ROLES
18
+ from pyferro.core.router import http_route, HTTP_ROUTES
19
+ from pyferro.core.http import extract_header, extract_query_params
20
+ from pyferro.core.lifecycle import (
21
+ on_startup, on_shutdown, run_startup_callbacks, run_shutdown_callbacks
22
+ )
23
+ from pyferro.core.logger import logger, setup_logging
24
+ from pyferro.core.lib.config import configure, get_config
25
+ from pyferro.plugins.auth.oauth import VkOAuth, YandexOAuth
26
+ from pyferro.app import FerroApp
27
+
28
+ from pyferro import core
29
+ from pyferro import plugins
30
+
31
+ __version__ = "0.5.6"
32
+
33
+ __all__ = [
34
+ "FerroApp",
35
+ "VkOAuth",
36
+ "YandexOAuth",
37
+ "rpc_method",
38
+ "RPCError",
39
+ "JsonRpcSession",
40
+ "current_user_ctx",
41
+ "current_session_ctx",
42
+ "current_rpc_id_ctx",
43
+ "current_transport_ctx",
44
+ "ACTIVE_SESSIONS_SET",
45
+ "pack_tabular",
46
+ "unpack_tabular",
47
+ "tabular_response",
48
+ "is_tabular",
49
+ "ADMIN_ROLE",
50
+ "DEFAULT_USER_ROLE",
51
+ "SYSTEM_ROLES",
52
+ "http_route",
53
+ "HTTP_ROUTES",
54
+ "extract_header",
55
+ "extract_query_params",
56
+ "on_startup",
57
+ "on_shutdown",
58
+ "run_startup_callbacks",
59
+ "run_shutdown_callbacks",
60
+ "logger",
61
+ "setup_logging",
62
+ "configure",
63
+ "get_config",
64
+ "core",
65
+ "plugins",
66
+ "install_compat",
67
+ ]
pyferro/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ # -*- coding: utf-8 -*-
2
+ from pyferro.cli import main
3
+
4
+ if __name__ == "__main__":
5
+ main()
pyferro/app.py ADDED
@@ -0,0 +1,549 @@
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ Главный класс приложения FerroApp.
4
+ Предоставляет готовую точку входа для Granian RSGI, инкапсулирует конфигурацию,
5
+ маршрутизацию HTTP/WebSocket, раздачу статики, CORS и управление жизненным циклом.
6
+ """
7
+
8
+ import os
9
+ import sys
10
+ import asyncio
11
+ import inspect
12
+ import mimetypes
13
+ from typing import Optional, List, Dict, Any, Union, Callable
14
+
15
+ from .core.lib.config import configure, settings, Settings
16
+ from .core.session import JsonRpcSession, rpc_method, RPC_REGISTRY, ACTIVE_SESSIONS_SET
17
+ from .core.router import http_route, HTTP_ROUTES
18
+ from .core.lifecycle import on_startup, on_shutdown, run_startup_callbacks, run_shutdown_callbacks
19
+ from .core.logger import logger, setup_logging
20
+
21
+
22
+ class _SessionCounter:
23
+ """Сериализуемый счетчик сессий (совместим с multiprocessing spawn / pickle в Python 3.14)."""
24
+
25
+ def __init__(self, start: int = 0):
26
+ self._val = start
27
+
28
+ def __iter__(self):
29
+ return self
30
+
31
+ def __next__(self) -> int:
32
+ val = self._val
33
+ self._val += 1
34
+ return val
35
+
36
+
37
+ class FerroApp:
38
+ """
39
+ Полнофункциональное RSGI-приложение PyFerro.
40
+ """
41
+
42
+ def __init__(
43
+ self,
44
+ *,
45
+ # Безопасность и ядро
46
+ secret_key: Optional[str] = None,
47
+ database_url: Optional[str] = None,
48
+ db_echo: bool = False,
49
+ files_path: Optional[str] = None,
50
+ max_upload_size: Optional[int] = None,
51
+ login_rpc: Optional[str] = None,
52
+ auth_timeout: Optional[int] = None,
53
+ guest_idle_timeout: Optional[int] = None,
54
+ user_idle_timeout: Optional[int] = None,
55
+ session_idle_timeout: Optional[int] = None,
56
+ allow_guests: Optional[bool] = None,
57
+ password_iterations: Optional[int] = None,
58
+ token_expire_hours: Optional[int] = None,
59
+ max_message_size: Optional[int] = None,
60
+ # HTTP и CORS
61
+ cors: bool = True,
62
+ cors_origins: Union[str, List[str]] = "*",
63
+ cors_headers: str = "content-type, authorization",
64
+ cors_methods: str = "GET, POST, PUT, DELETE, OPTIONS",
65
+ # Статика и UI
66
+ static_dir: Optional[str] = None,
67
+ static_prefix: str = "/static",
68
+ index_file: Optional[str] = None,
69
+ # Внешняя авторизация и плагины
70
+ oauth: Optional[Union[List[Any], Dict[str, Any]]] = None,
71
+ backplane_url: Optional[str] = None,
72
+ enable_seo: bool = False,
73
+ sitemap_host: Optional[str] = None,
74
+ auto_auth_ws: bool = True,
75
+ auto_auth_models: bool = True,
76
+ custom_settings: Optional[Dict[str, Any]] = None,
77
+ **extra_settings: Any
78
+ ):
79
+ # 1. Применяем конфигурацию к ядру
80
+ config_kwargs: Dict[str, Any] = {}
81
+ if secret_key is not None:
82
+ config_kwargs["secret_key"] = secret_key
83
+ if database_url is not None:
84
+ config_kwargs["database_url"] = database_url
85
+ if db_echo:
86
+ config_kwargs["db_echo"] = db_echo
87
+ if files_path is not None:
88
+ config_kwargs["files_path"] = files_path
89
+ if max_upload_size is not None:
90
+ config_kwargs["max_upload_size"] = max_upload_size
91
+ if backplane_url is not None:
92
+ config_kwargs["backplane_url"] = backplane_url
93
+ if sitemap_host is not None:
94
+ config_kwargs["sitemap_host"] = sitemap_host
95
+ if login_rpc is not None:
96
+ config_kwargs["login_rpc"] = login_rpc
97
+ if auth_timeout is not None:
98
+ config_kwargs["auth_timeout"] = auth_timeout
99
+ if guest_idle_timeout is not None:
100
+ config_kwargs["guest_idle_timeout"] = guest_idle_timeout
101
+ if user_idle_timeout is not None:
102
+ config_kwargs["user_idle_timeout"] = user_idle_timeout
103
+ if session_idle_timeout is not None:
104
+ config_kwargs["session_idle_timeout"] = session_idle_timeout
105
+ if allow_guests is not None:
106
+ config_kwargs["allow_guests"] = allow_guests
107
+ if password_iterations is not None:
108
+ config_kwargs["password_iterations"] = password_iterations
109
+ if token_expire_hours is not None:
110
+ config_kwargs["token_expire_hours"] = token_expire_hours
111
+ if max_message_size is not None:
112
+ config_kwargs.setdefault("custom", {})["max_message_size"] = max_message_size
113
+
114
+ if oauth:
115
+ oauth_dict: Dict[str, Any] = {}
116
+ if isinstance(oauth, list):
117
+ for prov in oauth:
118
+ p_name = getattr(prov, "provider_name", None) or prov.__class__.__name__.lower().replace("oauth", "")
119
+ oauth_dict[p_name] = {
120
+ k: v for k, v in prov.__dict__.items() if not k.startswith("_")
121
+ }
122
+ elif isinstance(oauth, dict):
123
+ oauth_dict = oauth
124
+ config_kwargs["oauth"] = oauth_dict
125
+
126
+ if custom_settings:
127
+ config_kwargs.setdefault("custom", {}).update(custom_settings)
128
+ if extra_settings:
129
+ config_kwargs.setdefault("custom", {}).update(extra_settings)
130
+
131
+ self.settings: Settings = configure(**config_kwargs)
132
+
133
+ # 2. Настройки HTTP / CORS
134
+ self.cors_enabled = cors
135
+ self.cors_origins = cors_origins if isinstance(cors_origins, str) else ", ".join(cors_origins)
136
+ self.cors_headers = cors_headers
137
+ self.cors_methods = cors_methods
138
+
139
+ # 3. Настройки статических файлов
140
+ self.static_dir = os.path.abspath(static_dir) if static_dir else None
141
+ self.static_prefix = "/" + static_prefix.strip("/") if static_prefix else "/static"
142
+ self.index_file = None
143
+ if index_file:
144
+ if os.path.isabs(index_file):
145
+ self.index_file = index_file
146
+ elif self.static_dir:
147
+ self.index_file = os.path.join(self.static_dir, index_file)
148
+ else:
149
+ self.index_file = os.path.abspath(index_file)
150
+
151
+ self.enable_seo = enable_seo
152
+ self._session_counter = _SessionCounter()
153
+ self._on_connect_callbacks: List[Callable] = []
154
+
155
+ self.auto_auth_ws = auto_auth_ws
156
+
157
+ # Автоматическая регистрация стандартных моделей в CRUD
158
+ if auto_auth_models:
159
+ try:
160
+ from pyferro.plugins.crud.registry import ModelRegistry
161
+ ModelRegistry.register_auth_models()
162
+ except Exception:
163
+ pass
164
+
165
+ # Граниан связывает __rsgi_init__ и __rsgi_del__ на самом объекте приложения
166
+ self.__rsgi_init__ = self._rsgi_init_handler
167
+ self.__rsgi_del__ = self._rsgi_del_handler
168
+
169
+ async def set_admin_password(self, login: str = "admin", password: Optional[str] = None) -> str:
170
+ """
171
+ Устанавливает или генерирует новый пароль для указанного пользователя в БД.
172
+ Строго DML (не создает таблицы или схему БД во избежание версионных проблем).
173
+ """
174
+ import secrets
175
+ import string
176
+ from sqlalchemy import select
177
+ from pyferro.plugins.db import async_session
178
+ from pyferro.plugins.auth import User, system_bypass_ctx
179
+
180
+ if not password:
181
+ chars = string.ascii_letters + string.digits + "!@#$%^&*"
182
+ password = "".join(secrets.choice(chars) for _ in range(16))
183
+
184
+ token = system_bypass_ctx.set(True)
185
+ try:
186
+ async with async_session() as db:
187
+ user = (await db.execute(select(User).where(User.login == login))).scalar_one_or_none()
188
+ if not user:
189
+ raise ValueError(
190
+ f"Пользователь с логином '{login}' не найден в базе данных. "
191
+ f"Схема данных и пользователи должны быть предварительно инициализированы приложением."
192
+ )
193
+ user.password_hash = User._hash_password(password)
194
+ await db.commit()
195
+ return password
196
+ finally:
197
+ system_bypass_ctx.reset(token)
198
+
199
+ def handle_cli(self, argv: Optional[List[str]] = None) -> bool:
200
+ """
201
+ Обрабатывает служебные CLI-флаги, такие как --set-admin-password.
202
+ Возвращает True, если была выполнена служебная команда (сервер запускать не нужно).
203
+ """
204
+ import sys
205
+ args = argv if argv is not None else sys.argv[1:]
206
+ if not args:
207
+ return False
208
+
209
+ for i, arg in enumerate(args):
210
+ if arg in ("--set-admin-password", "--set-password"):
211
+ custom_password = None
212
+ if i + 1 < len(args) and not args[i + 1].startswith("-"):
213
+ custom_password = args[i + 1]
214
+
215
+ login = "admin"
216
+ for j, a in enumerate(args):
217
+ if a == "--login" and j + 1 < len(args):
218
+ login = args[j + 1]
219
+
220
+ db_url = self.settings.get("database_url", "sqlite+aiosqlite:///app.db")
221
+ try:
222
+ new_pwd = asyncio.run(self.set_admin_password(login=login, password=custom_password))
223
+ print("=" * 64)
224
+ print(" 🛡️ PyFerro: Пароль успешно обновлен в базе данных!")
225
+ print(f" 🗄️ База данных: {db_url}")
226
+ print(f" 👤 Логин: {login}")
227
+ print(f" 🔑 Пароль: {new_pwd}")
228
+ print("=" * 64)
229
+ print(" 👉 Войдите через форму авторизации вашего приложения.")
230
+ return True
231
+ except Exception as e:
232
+ print("=" * 64, file=sys.stderr)
233
+ print(f" ❌ Ошибка смены пароля: {e}", file=sys.stderr)
234
+ print("=" * 64, file=sys.stderr)
235
+ sys.exit(1)
236
+ return False
237
+
238
+ def _auto_authenticate_ws(self, session: JsonRpcSession) -> None:
239
+ """Автоматически авторизует WebSocket по Cookie (ferro_crud_session, ferro_session, rpc_jwt)."""
240
+ if session.data is not None:
241
+ return
242
+ cookies = session.cookies
243
+ if not cookies:
244
+ return
245
+ token = (
246
+ cookies.get("ferro_crud_session")
247
+ or cookies.get("ferro_session")
248
+ or cookies.get("rpc_jwt")
249
+ or cookies.get("token")
250
+ )
251
+ if not token:
252
+ return
253
+
254
+ user_info = None
255
+ try:
256
+ from pyferro.plugins.crud.auth import get_crud_session
257
+ user_info = get_crud_session(token)
258
+ except Exception:
259
+ pass
260
+
261
+ if not user_info:
262
+ try:
263
+ from pyferro.core.security import decode_access_token
264
+ payload = decode_access_token(token)
265
+ if payload:
266
+ roles = payload.get("roles", [])
267
+ role = payload.get("role") or (roles[0] if roles else "user")
268
+ is_admin = bool("admin" in roles or "ADMIN" in roles or "superadmin" in roles or payload.get("is_superadmin") or role in ("admin", "ADMIN", "superadmin"))
269
+ raw_sub = payload.get("sub", "1")
270
+ uid = int(raw_sub) if str(raw_sub).isdigit() else 1
271
+ user_info = {
272
+ "uid": uid,
273
+ "user_id": uid,
274
+ "username": payload.get("username", "admin" if is_admin else "user"),
275
+ "role": "admin" if is_admin else role,
276
+ "roles": roles if roles else [role],
277
+ "is_superadmin": is_admin,
278
+ "perms_dict": payload.get("permissions") or payload.get("perms_dict") or {},
279
+ "allowed_rpc_methods": {"*"} if is_admin else set(payload.get("allowed_rpc_methods", [])),
280
+ }
281
+ except Exception:
282
+ pass
283
+
284
+ if user_info:
285
+ uid = user_info.get("uid") or user_info.get("user_id", 1)
286
+ role = user_info.get("role", "user")
287
+ roles = user_info.get("roles") or [role]
288
+ is_superadmin = bool(
289
+ role in ("admin", "ADMIN", "superadmin")
290
+ or "admin" in roles
291
+ or "ADMIN" in roles
292
+ or "superadmin" in roles
293
+ or user_info.get("is_superadmin")
294
+ )
295
+ user_ctx = type("UserCtx", (), {
296
+ "is_superadmin": is_superadmin,
297
+ "user_id": uid,
298
+ "team_ids": user_info.get("team_ids", []),
299
+ "perms_dict": user_info.get("perms_dict", {}),
300
+ "allowed_rpc_methods": {"*"} if is_superadmin else set(user_info.get("allowed_rpc_methods", [])),
301
+ })()
302
+
303
+ from pyferro.plugins.auth.core import AuthSession
304
+ session.data = AuthSession(
305
+ uid=uid,
306
+ user=None,
307
+ user_name=user_info.get("username", "user"),
308
+ user_role=role,
309
+ user_roles=roles,
310
+ session_db_id=0,
311
+ user_ctx=user_ctx,
312
+ allowed_rpc_methods={"*"} if is_superadmin else set(user_info.get("allowed_rpc_methods", [])),
313
+ send_request_cb=getattr(session, "send_request", None),
314
+ send_stream_cb=getattr(session, "send_stream_chunk", None),
315
+ close_cb=getattr(session, "close", None),
316
+ )
317
+ logger.info(f"[AutoAuth] WebSocket #{session.session_id} автоматически авторизован по Cookie как {user_info.get('username')} ({role})")
318
+
319
+ # --- ДЕКОРАТОРЫ ---
320
+
321
+ def rpc(self, name: Optional[str] = None, role: Optional[Any] = None, http: bool = False, public: bool = False, description: Optional[str] = None):
322
+ """Декоратор для регистрации JSON-RPC метода."""
323
+ return rpc_method(name=name, role=role, http=http, public=public, description=description)
324
+
325
+ def route(self, path: str, methods: Optional[List[str]] = None):
326
+ """Декоратор для регистрации HTTP-обработчика."""
327
+ return http_route(path=path, methods=methods)
328
+
329
+ def on_startup(self, func: Callable):
330
+ """Регистрирует хук запуска приложения."""
331
+ return on_startup(func)
332
+
333
+ def on_shutdown(self, func: Callable):
334
+ """Регистрирует хук завершения приложения."""
335
+ return on_shutdown(func)
336
+
337
+ def on_connect(self, func: Callable):
338
+ """Регистрирует асинхронный хук при подключении новой WebSocket-сессии (session: JsonRpcSession)."""
339
+ self._on_connect_callbacks.append(func)
340
+ return func
341
+
342
+ # --- ЖИЗНЕННЫЙ ЦИКЛ RSGI ---
343
+
344
+ def _rsgi_init_handler(self, loop):
345
+ """Инициализация при старте воркера Granian."""
346
+ try:
347
+ loop.run_until_complete(run_startup_callbacks())
348
+ except Exception as e:
349
+ logger.critical(f"[FerroApp] Сбой при startup: {e}", exc_info=True)
350
+ sys.exit(1)
351
+
352
+ def _rsgi_del_handler(self, loop):
353
+ """Очистка при завершении воркера Granian."""
354
+ try:
355
+ # Закрываем оставшиеся WebSocket-сессии
356
+ async def _cleanup():
357
+ await run_shutdown_callbacks()
358
+ close_tasks = [s.close() for s in list(ACTIVE_SESSIONS_SET)]
359
+ if close_tasks:
360
+ await asyncio.gather(*close_tasks, return_exceptions=True)
361
+
362
+ loop.run_until_complete(_cleanup())
363
+ logger.info("[FerroApp] Корректно завершен (graceful shutdown).")
364
+ except Exception as e:
365
+ logger.error(f"[FerroApp] Ошибка при shutdown: {e}", exc_info=True)
366
+
367
+ # --- ОБРАБОТКА RSGI ВХОДЯЩИХ ЗАПРОСОВ ---
368
+
369
+ async def __call__(self, scope, proto):
370
+ """
371
+ Главный диспетчер Granian RSGI: разделение HTTP и WebSocket.
372
+ """
373
+ proto_type = getattr(scope, "proto", "")
374
+
375
+ if proto_type == "http":
376
+ await self._handle_http(scope, proto)
377
+ return
378
+
379
+ if proto_type in ("websocket", "ws"):
380
+ await self._handle_websocket(scope, proto)
381
+ return
382
+
383
+ # Неизвестный протокол
384
+ logger.warning(f"[FerroApp] Неизвестный тип протокола: {proto_type}")
385
+
386
+ async def _handle_http(self, scope, proto):
387
+ method = getattr(scope, "method", None) or (scope.get("method", "GET") if isinstance(scope, dict) else "GET")
388
+ path = getattr(scope, "path", None) or (scope.get("path", "/") if isinstance(scope, dict) else "/")
389
+
390
+ # 1. CORS Preflight (OPTIONS)
391
+ if self.cors_enabled and method == "OPTIONS":
392
+ proto.response_str(
393
+ status=204,
394
+ headers=[
395
+ ("access-control-allow-origin", self.cors_origins),
396
+ ("access-control-allow-methods", self.cors_methods),
397
+ ("access-control-allow-headers", self.cors_headers),
398
+ ("access-control-max-age", "86400"),
399
+ ],
400
+ body=""
401
+ )
402
+ return
403
+
404
+ # 2. Опциональный перехват SEO-ботов
405
+ if self.enable_seo:
406
+ try:
407
+ from .plugins.seo.router import handle_bot_http
408
+ if await handle_bot_http(scope, proto):
409
+ return
410
+ except ImportError:
411
+ pass
412
+ except Exception as e:
413
+ logger.error(f"[FerroApp] Ошибка SEO перехвата: {e}", exc_info=True)
414
+
415
+ # 3. Маршрутизация по зарегистрированным HTTP_ROUTES
416
+ for route_path, methods, handler in HTTP_ROUTES:
417
+ if method in methods:
418
+ if path == route_path:
419
+ await handler(scope, proto)
420
+ return
421
+ elif route_path.endswith("/*"):
422
+ prefix = route_path[:-1] # e.g. "/crud/"
423
+ if path.startswith(prefix) or path == route_path[:-2]: # "/crud/" or "/crud"
424
+ await handler(scope, proto)
425
+ return
426
+ elif path.rstrip("/") == route_path.rstrip("/"):
427
+ await handler(scope, proto)
428
+ return
429
+
430
+ # 4. Отдача index.html на корневой GET /
431
+ if method == "GET" and path == "/" and self.index_file and os.path.exists(self.index_file):
432
+ self._serve_static_file(proto, self.index_file)
433
+ return
434
+
435
+ # 5. Раздача статики из static_dir
436
+ if method == "GET" and self.static_dir:
437
+ # 1. Попытка с префиксом static_prefix (если он задан)
438
+ if self.static_prefix and path.startswith(self.static_prefix):
439
+ rel_path = path[len(self.static_prefix):].lstrip("/")
440
+ if rel_path:
441
+ safe_full_path = os.path.abspath(os.path.join(self.static_dir, rel_path))
442
+ # Защита от Path Traversal
443
+ if (safe_full_path == self.static_dir or safe_full_path.startswith(self.static_dir + os.sep)) and os.path.isfile(safe_full_path):
444
+ self._serve_static_file(proto, safe_full_path)
445
+ return
446
+
447
+ # 2. Прямая раздача из static_dir (например, /assets/..., /favicon.ico)
448
+ rel_path = path.lstrip("/")
449
+ if rel_path:
450
+ safe_full_path = os.path.abspath(os.path.join(self.static_dir, rel_path))
451
+ # Защита от Path Traversal
452
+ if (safe_full_path == self.static_dir or safe_full_path.startswith(self.static_dir + os.sep)) and os.path.isfile(safe_full_path):
453
+ self._serve_static_file(proto, safe_full_path)
454
+ return
455
+
456
+ # 6. 404 Not Found
457
+ proto.response_str(
458
+ status=404,
459
+ headers=[("content-type", "text/plain; charset=utf-8")],
460
+ body="404 Not Found"
461
+ )
462
+
463
+ def _serve_static_file(self, proto, file_path: str):
464
+ """Отдает статический файл через нативный proto.response_file (Zero-Copy)."""
465
+ content_type, _ = mimetypes.guess_type(file_path)
466
+ content_type = content_type or "application/octet-stream"
467
+ if content_type.startswith("text/") or content_type in ("application/javascript", "application/json"):
468
+ content_type += "; charset=utf-8"
469
+
470
+ headers = [
471
+ ("content-type", content_type),
472
+ ("cache-control", "public, max-age=3600"),
473
+ ]
474
+ if self.cors_enabled:
475
+ headers.append(("access-control-allow-origin", self.cors_origins))
476
+
477
+ if hasattr(proto, "response_file"):
478
+ proto.response_file(status=200, headers=headers, file=file_path)
479
+ else:
480
+ # Fallback для тестовых моков
481
+ with open(file_path, "rb") as f:
482
+ data = f.read()
483
+ if hasattr(proto, "response_bytes"):
484
+ proto.response_bytes(status=200, headers=headers, body=data)
485
+ else:
486
+ proto.response_str(status=200, headers=headers, body=data.decode("utf-8", errors="replace"))
487
+
488
+ async def _handle_websocket(self, scope, proto):
489
+ """Принимает входящее WebSocket-соединение и запускает WSRPC JsonRpcSession."""
490
+ try:
491
+ ws = await proto.accept()
492
+ session_id = next(self._session_counter)
493
+
494
+ # Извлекаем IP адрес клиента из scope
495
+ client_ip = "0.0.0.0"
496
+ client = getattr(scope, "client", None)
497
+ if client and isinstance(client, (list, tuple)) and len(client) > 0:
498
+ client_ip = str(client[0])
499
+
500
+ session = JsonRpcSession(ws, session_id, ip=client_ip, scope=scope)
501
+ if self.auto_auth_ws:
502
+ self._auto_authenticate_ws(session)
503
+ for cb in self._on_connect_callbacks:
504
+ try:
505
+ if inspect.iscoroutinefunction(cb):
506
+ await cb(session)
507
+ else:
508
+ cb(session)
509
+ except Exception as e:
510
+ logger.error(f"[FerroApp on_connect] Ошибка в хуке подключения: {e}", exc_info=True)
511
+ await session.start()
512
+ except (asyncio.CancelledError, ConnectionResetError):
513
+ pass
514
+ except Exception as e:
515
+ if "RSGI transport is closed" in str(e) or "ProtocolClosed" in type(e).__name__:
516
+ logger.debug(f"[FerroApp WS] Клиент отключился (сессия #{session.session_id})")
517
+ else:
518
+ logger.error(f"[FerroApp WS] Ошибка сокет-сессии: {e}", exc_info=True)
519
+
520
+ def run(self, host: str = "127.0.0.1", port: int = 8080, workers: int = 1, **granian_kwargs: Any):
521
+ """
522
+ Удобный запуск сервера через Granian в коде.
523
+ """
524
+ if self.handle_cli():
525
+ return
526
+ if workers > 1:
527
+ from .core.backplane import get_backplane, MemoryBackplane
528
+ if isinstance(get_backplane(), MemoryBackplane):
529
+ logger.warning(
530
+ f"[Multi-Worker] Внимание: сервер запускается с workers={workers}, "
531
+ "но используется локальный MemoryBackplane. "
532
+ "WebSocket-сессии и Smart Cache изолированы внутри каждого процесса воркера! "
533
+ "Для синхронизации подключите распределенный Backplane (Redis/PG) или используйте workers=1."
534
+ )
535
+
536
+ try:
537
+ from granian import Granian
538
+ except ImportError:
539
+ raise RuntimeError("Для запуска через app.run() установите granian: pip install granian")
540
+
541
+ server = Granian(
542
+ target=self,
543
+ address=host,
544
+ port=port,
545
+ interface="rsgi",
546
+ workers=workers,
547
+ **granian_kwargs
548
+ )
549
+ server.serve()