prismacore 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. prismacore-0.1.0/LICENSE +21 -0
  2. prismacore-0.1.0/PKG-INFO +179 -0
  3. prismacore-0.1.0/README.md +148 -0
  4. prismacore-0.1.0/client/__init__.py +37 -0
  5. prismacore-0.1.0/client/api_funcs.py +131 -0
  6. prismacore-0.1.0/client/cache.py +81 -0
  7. prismacore-0.1.0/client/config.py +69 -0
  8. prismacore-0.1.0/client/exceptions.py +52 -0
  9. prismacore-0.1.0/client/models.py +185 -0
  10. prismacore-0.1.0/client/nodes/__init__.py +6 -0
  11. prismacore-0.1.0/client/nodes/node.py +50 -0
  12. prismacore-0.1.0/client/nodes/vpn.py +56 -0
  13. prismacore-0.1.0/client/prismacore.py +87 -0
  14. prismacore-0.1.0/client/regions/__init__.py +6 -0
  15. prismacore-0.1.0/client/regions/prober.py +102 -0
  16. prismacore-0.1.0/client/regions/registry.py +49 -0
  17. prismacore-0.1.0/client/resources/__init__.py +15 -0
  18. prismacore-0.1.0/client/resources/base.py +48 -0
  19. prismacore-0.1.0/client/resources/inbounds.py +18 -0
  20. prismacore-0.1.0/client/resources/outbounds.py +18 -0
  21. prismacore-0.1.0/client/resources/routes.py +10 -0
  22. prismacore-0.1.0/client/resources/system.py +14 -0
  23. prismacore-0.1.0/client/resources/users.py +37 -0
  24. prismacore-0.1.0/client/subscriptions/__init__.py +7 -0
  25. prismacore-0.1.0/client/subscriptions/aggregator.py +90 -0
  26. prismacore-0.1.0/client/transport/__init__.py +5 -0
  27. prismacore-0.1.0/client/transport/http.py +5 -0
  28. prismacore-0.1.0/prismacore.egg-info/PKG-INFO +179 -0
  29. prismacore-0.1.0/prismacore.egg-info/SOURCES.txt +32 -0
  30. prismacore-0.1.0/prismacore.egg-info/dependency_links.txt +1 -0
  31. prismacore-0.1.0/prismacore.egg-info/requires.txt +7 -0
  32. prismacore-0.1.0/prismacore.egg-info/top_level.txt +1 -0
  33. prismacore-0.1.0/pyproject.toml +51 -0
  34. prismacore-0.1.0/setup.cfg +4 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 PrismaCore Authors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,179 @@
1
+ Metadata-Version: 2.4
2
+ Name: prismacore
3
+ Version: 0.1.0
4
+ Summary: Python SDK for orchestrating sing-box VPN nodes
5
+ Author: PrismaCore Authors
6
+ License-Expression: MIT
7
+ Project-URL: Documentation, https://github.com/DevGMoree/PrismaCore#readme
8
+ Project-URL: Repository, https://github.com/DevGMoree/PrismaCore
9
+ Project-URL: Issues, https://github.com/DevGMoree/PrismaCore/issues
10
+ Keywords: sing-box,vpn,sdk,proxy
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Topic :: Internet :: Proxy Servers
19
+ Classifier: Topic :: System :: Networking
20
+ Classifier: Typing :: Typed
21
+ Requires-Python: >=3.10
22
+ Description-Content-Type: text/markdown
23
+ License-File: LICENSE
24
+ Requires-Dist: httpx>=0.27
25
+ Requires-Dist: pydantic>=2.5
26
+ Requires-Dist: redis>=5.0
27
+ Provides-Extra: dev
28
+ Requires-Dist: build; extra == "dev"
29
+ Requires-Dist: twine; extra == "dev"
30
+ Dynamic: license-file
31
+
32
+ # PrismaCore
33
+
34
+ Monorepo: REST API для одной sing-box-ноды (`server/`) и Python SDK для управления несколькими нодами (`client/`).
35
+
36
+ **License:** MIT · **English:** [README.en.md](README.en.md)
37
+
38
+ ---
39
+
40
+ ## Состав
41
+
42
+ | Часть | Что делает |
43
+ |-------|------------|
44
+ | `server/` | FastAPI: CRUD пользователей, инбаундов, аутбаундов, роутов; генерация share-ссылок; запись конфига sing-box |
45
+ | `client/` | SDK: несколько нод, выбор по региону/latency, сборка подписок |
46
+ | `deploy/` | Установка server на Linux (systemd) |
47
+
48
+ SDK не отдаёт HTTP-подписку клиентам — вы сами оборачиваете `core.subscriptions.build()` во FastAPI/Flask/nginx.
49
+
50
+ ---
51
+
52
+ ## Требования
53
+
54
+ - Python 3.10+
55
+ - sing-box (для server)
56
+ - Redis — опционально (кэш на server и в SDK)
57
+
58
+ ---
59
+
60
+ ## Установка
61
+
62
+ **SDK (PyPI):**
63
+
64
+ ```bash
65
+ pip install prismacore
66
+ ```
67
+
68
+ **SDK (из исходников / dev):**
69
+
70
+ ```bash
71
+ git clone https://github.com/YOUR_USER/PrismaCore.git
72
+ cd PrismaCore
73
+ pip install -e .
74
+ ```
75
+
76
+ **Server:**
77
+
78
+ ```bash
79
+ pip install -r server/requirements.txt
80
+ # или production: sudo bash deploy/install_server.sh
81
+ ```
82
+
83
+ Импорт SDK: `from client import PrismaCore`. Публикация новых версий на PyPI — [docs/publishing.md](docs/publishing.md).
84
+
85
+ ---
86
+
87
+ ## Быстрый старт
88
+
89
+ ```python
90
+ import asyncio
91
+ from client import PrismaCore
92
+
93
+ async def main():
94
+ async with PrismaCore.single_node(
95
+ url="http://127.0.0.1:8000",
96
+ token="<api-token>",
97
+ public_host="203.0.113.10", # публичный IP/домен для share-ссылок на server
98
+ ) as core:
99
+ await core.node().users.create_simple("alice", inbound_tags=["vless-in"])
100
+ sub = await core.subscriptions.build("alice")
101
+ print(sub[:80])
102
+
103
+ asyncio.run(main())
104
+ ```
105
+
106
+ Токен API создаётся один раз: `POST /api/v1/auth/token` (см. [docs/server.md](docs/server.md)).
107
+
108
+ ---
109
+
110
+ ## Документация
111
+
112
+ | Документ | Содержание |
113
+ |----------|------------|
114
+ | [docs/sdk.md](docs/sdk.md) | SDK: конфиг, CRUD, регионы, подписки, кэш, ошибки, модели |
115
+ | [docs/server.md](docs/server.md) | Server API: эндпoинты, auth, env, sing-box, share-ссылки |
116
+ | [docs/deploy.md](docs/deploy.md) | Production-установка: `install_server.sh`, systemd, `.env` |
117
+ | [docs/publishing.md](docs/publishing.md) | PyPI: сборка, первая загрузка, новые версии |
118
+
119
+ ---
120
+
121
+ ## Архитектура
122
+
123
+ ```
124
+ PrismaCore (SDK)
125
+ ├── vpn_servers: VpnServerManager # реестр нод
126
+ ├── regions: RegionService # fastest_node / best_nodes
127
+ ├── subscriptions: SubscriptionAggregator
128
+ └── cache: CacheManager # Redis, опционально
129
+
130
+ Node (на каждую ноду)
131
+ ├── users / inbounds / outbounds / routes / system
132
+ └── HttpClient → server /api/v1/*
133
+ ```
134
+
135
+ Типичный поток подписки:
136
+
137
+ 1. SDK выбирает ноды по регионам (`fastest_only=True` — одна на регион)
138
+ 2. На каждой ноде: `GET /api/v1/sublink/{user_name}/all`
139
+ 3. Server собирает `vless://` / `hysteria2://` из БД + `HOST`
140
+ 4. SDK добавляет meta-строки Happ, кодирует в base64
141
+
142
+ ---
143
+
144
+ ## Структура репозитория
145
+
146
+ ```
147
+ client/
148
+ ├── prismacore.py
149
+ ├── config.py, models.py, api_funcs.py, cache.py, exceptions.py
150
+ ├── nodes/ node.py, vpn.py
151
+ ├── resources/ users, inbounds, outbounds, routes, system
152
+ ├── regions/ prober.py, registry.py
153
+ └── subscriptions/ aggregator.py
154
+
155
+ server/
156
+ ├── app.py, routes.py, config.py
157
+ ├── database.py, models.py, schemas.py
158
+ └── core/ singbox.py, cache.py
159
+
160
+ deploy/
161
+ ├── install_server.sh
162
+ ├── prismacore.service
163
+ └── .env.example
164
+ ```
165
+
166
+ ---
167
+
168
+ ## Частые ошибки
169
+
170
+ - **`HOST=localhost` на server** — share-ссылки будут с localhost, клиенты не подключатся. Задайте публичный IP/домен.
171
+ - **Забыли `invalidate` после CRUD** — подписчики получат закэшированную подписку без новых ссылок.
172
+ - **Несколько нод, `core.node()` без имени** — `ConfigError`. Указывайте `core.node("de-1")`.
173
+ - **Токен в заголовке** — server ждёт `?token=...` в query, не Bearer.
174
+
175
+ ---
176
+
177
+ ## English
178
+
179
+ See [README.en.md](README.en.md) and [docs/en/](docs/en/).
@@ -0,0 +1,148 @@
1
+ # PrismaCore
2
+
3
+ Monorepo: REST API для одной sing-box-ноды (`server/`) и Python SDK для управления несколькими нодами (`client/`).
4
+
5
+ **License:** MIT · **English:** [README.en.md](README.en.md)
6
+
7
+ ---
8
+
9
+ ## Состав
10
+
11
+ | Часть | Что делает |
12
+ |-------|------------|
13
+ | `server/` | FastAPI: CRUD пользователей, инбаундов, аутбаундов, роутов; генерация share-ссылок; запись конфига sing-box |
14
+ | `client/` | SDK: несколько нод, выбор по региону/latency, сборка подписок |
15
+ | `deploy/` | Установка server на Linux (systemd) |
16
+
17
+ SDK не отдаёт HTTP-подписку клиентам — вы сами оборачиваете `core.subscriptions.build()` во FastAPI/Flask/nginx.
18
+
19
+ ---
20
+
21
+ ## Требования
22
+
23
+ - Python 3.10+
24
+ - sing-box (для server)
25
+ - Redis — опционально (кэш на server и в SDK)
26
+
27
+ ---
28
+
29
+ ## Установка
30
+
31
+ **SDK (PyPI):**
32
+
33
+ ```bash
34
+ pip install prismacore
35
+ ```
36
+
37
+ **SDK (из исходников / dev):**
38
+
39
+ ```bash
40
+ git clone https://github.com/YOUR_USER/PrismaCore.git
41
+ cd PrismaCore
42
+ pip install -e .
43
+ ```
44
+
45
+ **Server:**
46
+
47
+ ```bash
48
+ pip install -r server/requirements.txt
49
+ # или production: sudo bash deploy/install_server.sh
50
+ ```
51
+
52
+ Импорт SDK: `from client import PrismaCore`. Публикация новых версий на PyPI — [docs/publishing.md](docs/publishing.md).
53
+
54
+ ---
55
+
56
+ ## Быстрый старт
57
+
58
+ ```python
59
+ import asyncio
60
+ from client import PrismaCore
61
+
62
+ async def main():
63
+ async with PrismaCore.single_node(
64
+ url="http://127.0.0.1:8000",
65
+ token="<api-token>",
66
+ public_host="203.0.113.10", # публичный IP/домен для share-ссылок на server
67
+ ) as core:
68
+ await core.node().users.create_simple("alice", inbound_tags=["vless-in"])
69
+ sub = await core.subscriptions.build("alice")
70
+ print(sub[:80])
71
+
72
+ asyncio.run(main())
73
+ ```
74
+
75
+ Токен API создаётся один раз: `POST /api/v1/auth/token` (см. [docs/server.md](docs/server.md)).
76
+
77
+ ---
78
+
79
+ ## Документация
80
+
81
+ | Документ | Содержание |
82
+ |----------|------------|
83
+ | [docs/sdk.md](docs/sdk.md) | SDK: конфиг, CRUD, регионы, подписки, кэш, ошибки, модели |
84
+ | [docs/server.md](docs/server.md) | Server API: эндпoинты, auth, env, sing-box, share-ссылки |
85
+ | [docs/deploy.md](docs/deploy.md) | Production-установка: `install_server.sh`, systemd, `.env` |
86
+ | [docs/publishing.md](docs/publishing.md) | PyPI: сборка, первая загрузка, новые версии |
87
+
88
+ ---
89
+
90
+ ## Архитектура
91
+
92
+ ```
93
+ PrismaCore (SDK)
94
+ ├── vpn_servers: VpnServerManager # реестр нод
95
+ ├── regions: RegionService # fastest_node / best_nodes
96
+ ├── subscriptions: SubscriptionAggregator
97
+ └── cache: CacheManager # Redis, опционально
98
+
99
+ Node (на каждую ноду)
100
+ ├── users / inbounds / outbounds / routes / system
101
+ └── HttpClient → server /api/v1/*
102
+ ```
103
+
104
+ Типичный поток подписки:
105
+
106
+ 1. SDK выбирает ноды по регионам (`fastest_only=True` — одна на регион)
107
+ 2. На каждой ноде: `GET /api/v1/sublink/{user_name}/all`
108
+ 3. Server собирает `vless://` / `hysteria2://` из БД + `HOST`
109
+ 4. SDK добавляет meta-строки Happ, кодирует в base64
110
+
111
+ ---
112
+
113
+ ## Структура репозитория
114
+
115
+ ```
116
+ client/
117
+ ├── prismacore.py
118
+ ├── config.py, models.py, api_funcs.py, cache.py, exceptions.py
119
+ ├── nodes/ node.py, vpn.py
120
+ ├── resources/ users, inbounds, outbounds, routes, system
121
+ ├── regions/ prober.py, registry.py
122
+ └── subscriptions/ aggregator.py
123
+
124
+ server/
125
+ ├── app.py, routes.py, config.py
126
+ ├── database.py, models.py, schemas.py
127
+ └── core/ singbox.py, cache.py
128
+
129
+ deploy/
130
+ ├── install_server.sh
131
+ ├── prismacore.service
132
+ └── .env.example
133
+ ```
134
+
135
+ ---
136
+
137
+ ## Частые ошибки
138
+
139
+ - **`HOST=localhost` на server** — share-ссылки будут с localhost, клиенты не подключатся. Задайте публичный IP/домен.
140
+ - **Забыли `invalidate` после CRUD** — подписчики получат закэшированную подписку без новых ссылок.
141
+ - **Несколько нод, `core.node()` без имени** — `ConfigError`. Указывайте `core.node("de-1")`.
142
+ - **Токен в заголовке** — server ждёт `?token=...` в query, не Bearer.
143
+
144
+ ---
145
+
146
+ ## English
147
+
148
+ See [README.en.md](README.en.md) and [docs/en/](docs/en/).
@@ -0,0 +1,37 @@
1
+ """PrismaCore SDK — управление sing-box VPN."""
2
+
3
+ from .prismacore import PrismaCore
4
+ from .config import ServerConfig, CacheConfig, ProbeConfig
5
+ from .models import (
6
+ UserCreate, UserResponse,
7
+ InboundCreate, InboundResponse,
8
+ OutboundCreate, OutboundResponse,
9
+ RouteCreate, RouteResponse,
10
+ StatusResponse, Token,
11
+ )
12
+ from .subscriptions.aggregator import SubscriptionAggregator
13
+ from .exceptions import (
14
+ PrismaCoreError,
15
+ ConfigError,
16
+ TransportError,
17
+ AllServersDownError,
18
+ APIError,
19
+ AuthError,
20
+ NotFoundError,
21
+ ConflictError,
22
+ )
23
+
24
+ __version__ = "0.1.0"
25
+
26
+ __all__ = [
27
+ "PrismaCore",
28
+ "ServerConfig", "CacheConfig", "ProbeConfig",
29
+ "UserCreate", "UserResponse",
30
+ "InboundCreate", "InboundResponse",
31
+ "OutboundCreate", "OutboundResponse",
32
+ "RouteCreate", "RouteResponse",
33
+ "StatusResponse", "Token",
34
+ "SubscriptionAggregator",
35
+ "PrismaCoreError", "ConfigError", "TransportError", "AllServersDownError",
36
+ "APIError", "AuthError", "NotFoundError", "ConflictError",
37
+ ]
@@ -0,0 +1,131 @@
1
+ """HTTP-транспорт SDK (httpx)."""
2
+
3
+ import asyncio
4
+ from typing import Any, Optional
5
+
6
+ import httpx
7
+
8
+ from .exceptions import (
9
+ APIError,
10
+ AuthError,
11
+ ConflictError,
12
+ NotFoundError,
13
+ TransportError,
14
+ )
15
+
16
+ API_PREFIX = "/api/v1"
17
+ _RETRYABLE_STATUS = {429, 500, 502, 503, 504}
18
+
19
+
20
+ class HttpClient:
21
+ """Асинхронный HTTP-клиент к одной ноде."""
22
+
23
+ def __init__(
24
+ self,
25
+ base_url: str,
26
+ token: str,
27
+ *,
28
+ timeout: float = 10.0,
29
+ max_retries: int = 3,
30
+ retry_backoff: float = 0.5,
31
+ ):
32
+ self.base_url = base_url.rstrip("/")
33
+ self.token = token
34
+ self.max_retries = max_retries
35
+ self.retry_backoff = retry_backoff
36
+ self._client = httpx.AsyncClient(base_url=self.base_url, timeout=timeout)
37
+
38
+ async def aclose(self) -> None:
39
+ await self._client.aclose()
40
+
41
+ async def __aenter__(self) -> "HttpClient":
42
+ return self
43
+
44
+ async def __aexit__(self, *exc_info: Any) -> None:
45
+ await self.aclose()
46
+
47
+ async def request(
48
+ self,
49
+ method: str,
50
+ path: str,
51
+ *,
52
+ json: Optional[dict] = None,
53
+ params: Optional[dict] = None,
54
+ ) -> Any:
55
+ url = f"{API_PREFIX}{path}"
56
+ query = dict(params or {})
57
+ query["token"] = self.token
58
+
59
+ last_exc: Optional[Exception] = None
60
+
61
+ for attempt in range(1, self.max_retries + 1):
62
+ try:
63
+ response = await self._client.request(method, url, json=json, params=query)
64
+ except httpx.RequestError as exc:
65
+ last_exc = exc
66
+ if attempt < self.max_retries:
67
+ await asyncio.sleep(self.retry_backoff * attempt)
68
+ continue
69
+ raise TransportError(
70
+ f"Не удалось соединиться с нодой за {self.max_retries} попыток: {exc}",
71
+ url=self.base_url,
72
+ ) from exc
73
+
74
+ if response.status_code in _RETRYABLE_STATUS and attempt < self.max_retries:
75
+ await asyncio.sleep(self.retry_backoff * attempt)
76
+ continue
77
+
78
+ return self._handle_response(response)
79
+
80
+ raise TransportError(
81
+ f"Запрос не выполнен: {last_exc}", url=self.base_url
82
+ )
83
+
84
+ def _handle_response(self, response: httpx.Response) -> Any:
85
+ if 200 <= response.status_code < 300:
86
+ if not response.content:
87
+ return None
88
+ try:
89
+ return response.json()
90
+ except ValueError as exc:
91
+ raise APIError(
92
+ "Сервер вернул не-JSON в успешном ответе",
93
+ status_code=response.status_code,
94
+ url=self.base_url,
95
+ ) from exc
96
+
97
+ detail = None
98
+ try:
99
+ body = response.json()
100
+ if isinstance(body, dict):
101
+ detail = body.get("detail")
102
+ except ValueError:
103
+ detail = response.text or None
104
+
105
+ message = f"Сервер вернул ошибку {response.status_code}: {detail or 'без описания'}"
106
+
107
+ if response.status_code == 403:
108
+ raise AuthError(message, status_code=403, detail=detail, url=self.base_url)
109
+ if response.status_code == 404:
110
+ raise NotFoundError(message, status_code=404, detail=detail, url=self.base_url)
111
+ if response.status_code == 409:
112
+ raise ConflictError(message, status_code=409, detail=detail, url=self.base_url)
113
+
114
+ raise APIError(
115
+ message,
116
+ status_code=response.status_code,
117
+ detail=detail,
118
+ url=self.base_url,
119
+ )
120
+
121
+ async def get(self, path: str, *, params: Optional[dict] = None) -> Any:
122
+ return await self.request("GET", path, params=params)
123
+
124
+ async def post(self, path: str, *, json: Optional[dict] = None, params: Optional[dict] = None) -> Any:
125
+ return await self.request("POST", path, json=json, params=params)
126
+
127
+ async def put(self, path: str, *, json: Optional[dict] = None, params: Optional[dict] = None) -> Any:
128
+ return await self.request("PUT", path, json=json, params=params)
129
+
130
+ async def delete(self, path: str, *, params: Optional[dict] = None) -> Any:
131
+ return await self.request("DELETE", path, params=params)
@@ -0,0 +1,81 @@
1
+ """Redis-кэш SDK."""
2
+
3
+ import json
4
+ from typing import Any, Awaitable, Callable, Optional
5
+
6
+ from pydantic import BaseModel
7
+
8
+ from .config import CacheConfig
9
+
10
+
11
+ class CacheManager:
12
+ """Redis-кэш (опционально)."""
13
+
14
+ def __init__(self, config: Optional[CacheConfig] = None):
15
+ self.config = config or CacheConfig()
16
+ self._redis = None
17
+
18
+ def _get_redis(self):
19
+ if self._redis is None:
20
+ import redis.asyncio as aioredis
21
+
22
+ self._redis = aioredis.from_url(self.config.redis_url, decode_responses=True)
23
+ return self._redis
24
+
25
+ @staticmethod
26
+ def _json_default(value: Any) -> Any:
27
+ if isinstance(value, BaseModel):
28
+ return value.model_dump(mode="json")
29
+ raise TypeError(f"Object of type {type(value).__name__} is not JSON serializable")
30
+
31
+ async def create_cache(self, data: Any, key: str) -> None:
32
+ if not self.config.enabled:
33
+ return
34
+ try:
35
+ json_data = json.dumps(data, ensure_ascii=False, default=self._json_default)
36
+ await self._get_redis().set(name=key, value=json_data)
37
+ except Exception as e:
38
+ print(f"[cache] Ошибка при кэшировании ключа '{key}': {e}")
39
+
40
+ async def get_cache_data(self, key: str) -> Optional[Any]:
41
+ if not self.config.enabled:
42
+ return None
43
+ try:
44
+ cache = await self._get_redis().get(key)
45
+ except Exception as e:
46
+ print(f"[cache] Ошибка при чтении кэша по ключу '{key}': {e}")
47
+ return None
48
+ if not cache:
49
+ return None
50
+ return json.loads(cache)
51
+
52
+ async def delete(self, key: str) -> None:
53
+ if not self.config.enabled:
54
+ return
55
+ try:
56
+ await self._get_redis().delete(key)
57
+ except Exception as e:
58
+ print(f"[cache] Ошибка при удалении ключа '{key}': {e}")
59
+
60
+ async def get_or_fetch(
61
+ self,
62
+ key: str,
63
+ fetcher: Callable[[], Awaitable[Any]],
64
+ *,
65
+ serializer: Optional[Callable[[Any], Any]] = None,
66
+ deserializer: Optional[Callable[[Any], Any]] = None,
67
+ ) -> Any:
68
+ cached = await self.get_cache_data(key)
69
+ if cached is not None:
70
+ return deserializer(cached) if deserializer is not None else cached
71
+ value = await fetcher()
72
+ value_to_cache = serializer(value) if serializer is not None else value
73
+ await self.create_cache(value_to_cache, key)
74
+ return value
75
+
76
+ async def close(self) -> None:
77
+ if self._redis is not None:
78
+ try:
79
+ await self._redis.close()
80
+ except Exception:
81
+ pass
@@ -0,0 +1,69 @@
1
+ """Конфигурация SDK."""
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Literal, Optional
5
+ from urllib.parse import urlparse
6
+
7
+ from .exceptions import ConfigError
8
+
9
+ VpnNodeRole = Literal["vpn", "backup_vpn"]
10
+
11
+
12
+ @dataclass
13
+ class ServerConfig:
14
+ """Параметры одной VPN-ноды."""
15
+
16
+ url: str
17
+ token: str
18
+ region: str = "default"
19
+ role: VpnNodeRole = "vpn"
20
+ name: str = ""
21
+ public_host: str = ""
22
+ timeout: float = 10.0
23
+
24
+ def __post_init__(self) -> None:
25
+ self.url = self.url.rstrip("/")
26
+ self.token = self.token.strip()
27
+
28
+ if not self.url:
29
+ raise ConfigError("ServerConfig.url не может быть пустым")
30
+ if not self.token:
31
+ raise ConfigError("ServerConfig.token не может быть пустым")
32
+ if self.role not in ("vpn", "backup_vpn"):
33
+ raise ConfigError(
34
+ f"Недопустимая роль {self.role!r}. Допустимо: 'vpn', 'backup_vpn'"
35
+ )
36
+
37
+ if not self.name:
38
+ self.name = self.url
39
+
40
+ if self.public_host:
41
+ self.public_host = self.public_host.strip()
42
+ else:
43
+ self.public_host = self._host_from_url(self.url)
44
+
45
+ @staticmethod
46
+ def _host_from_url(url: str) -> str:
47
+ normalized = url if "://" in url else f"http://{url}"
48
+ parsed = urlparse(normalized)
49
+ if parsed.hostname:
50
+ return parsed.hostname
51
+ return url.split("/")[0].split(":")[0]
52
+
53
+
54
+ @dataclass
55
+ class CacheConfig:
56
+ """Redis-кэш SDK."""
57
+
58
+ enabled: bool = False
59
+ redis_url: str = "redis://localhost:6379/0"
60
+
61
+
62
+ @dataclass
63
+ class ProbeConfig:
64
+ """Параметры замера latency."""
65
+
66
+ samples: int = 3
67
+ timeout: float = 5.0
68
+ cache_ttl: float = 60.0
69
+ health_path: Optional[str] = None