Python-3xui 0.0.10.post2__tar.gz → 0.0.11__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 (25) hide show
  1. {python_3xui-0.0.10.post2 → python_3xui-0.0.11}/PKG-INFO +1 -1
  2. {python_3xui-0.0.10.post2 → python_3xui-0.0.11}/pyproject.toml +3 -2
  3. {python_3xui-0.0.10.post2 → python_3xui-0.0.11}/python_3xui/__init__.py +1 -1
  4. python_3xui-0.0.11/python_3xui/api_core/__init__.py +4 -0
  5. python_3xui-0.0.11/python_3xui/api_core/client_service.py +301 -0
  6. python_3xui-0.0.11/python_3xui/api_core/identity.py +28 -0
  7. python_3xui-0.0.11/python_3xui/api_core/prod_cache.py +75 -0
  8. python_3xui-0.0.11/python_3xui/api_core/session_core.py +333 -0
  9. {python_3xui-0.0.10.post2 → python_3xui-0.0.11}/.gitignore +0 -0
  10. {python_3xui-0.0.10.post2 → python_3xui-0.0.11}/LICENSE +0 -0
  11. {python_3xui-0.0.10.post2 → python_3xui-0.0.11}/README.md +0 -0
  12. {python_3xui-0.0.10.post2 → python_3xui-0.0.11}/python_3xui/api.py +0 -0
  13. {python_3xui-0.0.10.post2 → python_3xui-0.0.11}/python_3xui/base_model.py +0 -0
  14. {python_3xui-0.0.10.post2 → python_3xui-0.0.11}/python_3xui/custom_exceptions.py +0 -0
  15. {python_3xui-0.0.10.post2 → python_3xui-0.0.11}/python_3xui/endpoints.py +0 -0
  16. {python_3xui-0.0.10.post2 → python_3xui-0.0.11}/python_3xui/models.py +0 -0
  17. {python_3xui-0.0.10.post2 → python_3xui-0.0.11}/python_3xui/util.py +0 -0
  18. {python_3xui-0.0.10.post2 → python_3xui-0.0.11}/tests/conftest.py +0 -0
  19. {python_3xui-0.0.10.post2 → python_3xui-0.0.11}/tests/gather_response_stubs.py +0 -0
  20. {python_3xui-0.0.10.post2 → python_3xui-0.0.11}/tests/pytest.ini +0 -0
  21. {python_3xui-0.0.10.post2 → python_3xui-0.0.11}/tests/test_endpoints_clients.py +0 -0
  22. {python_3xui-0.0.10.post2 → python_3xui-0.0.11}/tests/test_endpoints_inbounds.py +0 -0
  23. {python_3xui-0.0.10.post2 → python_3xui-0.0.11}/tests/test_non_idempotent_endpoints_clients.py +0 -0
  24. {python_3xui-0.0.10.post2 → python_3xui-0.0.11}/tests/test_non_idempotent_endpoints_inbounds.py +0 -0
  25. {python_3xui-0.0.10.post2 → python_3xui-0.0.11}/tests/test_xuiclient_helpers.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: Python-3xui
3
- Version: 0.0.10.post2
3
+ Version: 0.0.11
4
4
  Summary: 3x-ui wrapper for python
5
5
  Project-URL: Homepage, https://github.com/Artem-Potapov/3x-py
6
6
  Project-URL: Issues, https://github.com/Artem-Potapov/3x-py/issues
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "Python-3xui"
3
- version = "0.0.10r2"
3
+ version = "0.0.11"
4
4
  authors = [
5
5
  { name="JustMe_001", email="justme001.causation755@passinbox.com" },
6
6
  ]
@@ -46,7 +46,8 @@ build-backend = "hatchling.build"
46
46
  include = [
47
47
  "python_3xui/*.py",
48
48
  "/tests/*.py",
49
- "/tests/pytest.ini"
49
+ "/tests/pytest.ini",
50
+ "python_3xui/api_core/*.py"
50
51
  ]
51
52
  exclude = [
52
53
  "requirements.txt",
@@ -4,7 +4,7 @@ from .api import XUIClient
4
4
  import python_3xui.custom_exceptions as exceptions
5
5
 
6
6
  __author__ = "JustMe_001"
7
- __version__ = "0.0.10r2"
7
+ __version__ = "0.0.11"
8
8
  __email__ = ""
9
9
 
10
10
 
@@ -0,0 +1,4 @@
1
+ from .identity import IdentityResolver
2
+ from .prod_cache import ProductionInboundCache
3
+ from .session_core import SessionCore
4
+ from .client_service import TgIDClientService
@@ -0,0 +1,301 @@
1
+ """Telegram-ID-oriented high-level client operations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import logging
7
+ from asyncio import Task
8
+ from datetime import datetime, UTC
9
+ from typing import TYPE_CHECKING, List, Literal
10
+
11
+ from httpx import Response
12
+
13
+ from python_3xui import util
14
+ from python_3xui.custom_exceptions import ClientDoesNotExistError, ClientEmailAlreadyExistsError
15
+ from python_3xui.models import ClientStats, Inbound, SingleInboundClient
16
+ from python_3xui.util import get_client_in_inbound
17
+
18
+ if TYPE_CHECKING:
19
+ from python_3xui.endpoints import Clients, Inbounds
20
+ from python_3xui.api_core.identity import IdentityResolver
21
+ from python_3xui.api_core.prod_cache import ProductionInboundCache
22
+
23
+
24
+ class TgIDClientService:
25
+ """Orchestrates TGID-derived flows against panel endpoints."""
26
+
27
+ __slots__ = ("_clients", "_inbounds", "_identity", "_prod_cache")
28
+
29
+ def __init__(self,
30
+ clients_endpoint: Clients,
31
+ inbounds_endpoint: Inbounds,
32
+ identity: IdentityResolver,
33
+ prod_cache: ProductionInboundCache,
34
+ ) -> None:
35
+ self._clients = clients_endpoint
36
+ self._inbounds = inbounds_endpoint
37
+ self._identity = identity
38
+ self._prod_cache = prod_cache
39
+
40
+ async def get_client_with_tgid(self, tgid: int, inbound_id: int | None = None) -> list[ClientStats]:
41
+ #FIXME: Implement the fallback described in the docstring (api.py)
42
+ if inbound_id:
43
+ email = util.generate_email_from_tgid_inbid(tgid, inbound_id)
44
+ return [await self._clients.get_client_with_email(email)]
45
+ uuid = await self._identity.resolve_uuid(tgid)
46
+ return await self._clients.get_client_with_uuid(uuid)
47
+
48
+ async def create_and_add_prod_client(self,
49
+ telegram_id: int,
50
+ *,
51
+ additional_remark: str | None = None,
52
+ expiry_time: int = 0,
53
+ exist_ok: bool = False,
54
+ replace_if_exist: bool = False,
55
+ ) -> dict[int, Response]:
56
+ production_inbounds: tuple[Inbound, ...] = await self._prod_cache.get()
57
+
58
+ custom_sub = await self._identity.resolve_sub(telegram_id)
59
+ uuid = await self._identity.resolve_uuid(telegram_id)
60
+
61
+ # --- Phase 1: build clients and burst-add them ---
62
+ _to_exec: list[asyncio.Task[Response]] = []
63
+ clients_by_inbound: dict[int, SingleInboundClient] = {}
64
+ for inb in production_inbounds:
65
+ tmp_email = util.generate_email_from_tgid_inbid(telegram_id, inb.id)
66
+ client = SingleInboundClient(
67
+ uuid=uuid,
68
+ flow="",
69
+ email=tmp_email,
70
+ limit_gb=0,
71
+ enable=True,
72
+ subscription_id=custom_sub,
73
+ comment=f"{additional_remark + ', ' if additional_remark else ''}created at {datetime.now(UTC)}",
74
+ expiry_time=expiry_time,
75
+ )
76
+ clients_by_inbound[inb.id] = client
77
+ _to_exec.append(
78
+ asyncio.create_task(self._clients.add_client(client, inb.id))
79
+ )
80
+
81
+ raw_results: list[Response] = await asyncio.gather(*_to_exec)
82
+
83
+ # Map inbound IDs to their add responses
84
+ responses: dict[int, Response] = {}
85
+ for i, inb in enumerate(production_inbounds):
86
+ responses[inb.id] = raw_results[i]
87
+
88
+ # --- Phase 2: replace duplicates when replace_if_exist is set ---
89
+ if replace_if_exist:
90
+ _update_exec: list[asyncio.Task[Response]] = []
91
+ # Track which inbound each update task corresponds to via list index
92
+ update_inbound_ids: list[int] = []
93
+
94
+ for inb_id, resp in list(responses.items()):
95
+ json_resp = resp.json()
96
+ msg = json_resp.get("msg", "")
97
+ if "duplicate email" in msg.lower():
98
+ client = clients_by_inbound[inb_id]
99
+ _update_exec.append(
100
+ asyncio.create_task(
101
+ self._clients.request_update_client(
102
+ client, inb_id, original_uuid=client.uuid,
103
+ )
104
+ )
105
+ )
106
+ update_inbound_ids.append(inb_id)
107
+
108
+ if _update_exec:
109
+ update_results: list[Response] = await asyncio.gather(*_update_exec)
110
+ for i, inb_id in enumerate(update_inbound_ids):
111
+ responses[inb_id] = update_results[i]
112
+
113
+ # --- Phase 3: raise on remaining duplicates if not exist_ok ---
114
+ if not exist_ok:
115
+ for inb_id, resp in responses.items():
116
+ json_resp = resp.json()
117
+ msg = json_resp.get("msg", "")
118
+ if "duplicate email" in msg.lower():
119
+ logging.error(
120
+ "ERROR: Client already exists and exist_ok not set: %s",
121
+ msg,
122
+ )
123
+ raise ClientEmailAlreadyExistsError(msg)
124
+
125
+ return responses
126
+
127
+ async def _find_client_in_inbound(self,
128
+ client_uuid: str,
129
+ inbound_id: int,
130
+ *,
131
+ use_cache: bool = False,
132
+ ) -> SingleInboundClient | None:
133
+ if use_cache:
134
+ prod_inbs = await self._prod_cache.get()
135
+ prod_inb_index = None
136
+ for i, prod_inb in enumerate(prod_inbs):
137
+ if inbound_id == prod_inb.id:
138
+ prod_inb_index = i
139
+
140
+ if prod_inb_index is not None:
141
+ needed_inb: Inbound = prod_inbs[prod_inb_index]
142
+ result = get_client_in_inbound(client_uuid, needed_inb)
143
+ if result is None:
144
+ self._prod_cache.get.cache_clear()
145
+ new_inb = (await self._prod_cache.get())[prod_inb_index]
146
+ return get_client_in_inbound(client_uuid, new_inb)
147
+
148
+ inb = await self._inbounds.get_specific_inbound(inbound_id)
149
+ for client in inb.settings.clients:
150
+ if client.uuid == client_uuid:
151
+ return client
152
+ return None
153
+
154
+ async def update_client_by_tgid_only(self,
155
+ telegram_id: int,
156
+ prod_only: bool,
157
+ /,
158
+ *,
159
+ security: str | None = None,
160
+ password: str | None = None,
161
+ flow: Literal["", "xtls-rprx-vision", "xtls-rprx-vision-udp443"] | None = None,
162
+ limit_ip: int | None = None,
163
+ limit_gb: int | None = None,
164
+ expiry_time: int | None = None,
165
+ enable: bool | None = None,
166
+ sub_id: str | None = None,
167
+ comment: str | None = None,
168
+ verbose: bool = True,
169
+ force_search_by_email: bool = False,
170
+ not_found_action: Literal["raise", "ignore"] = "ignore",
171
+ client_to_create: SingleInboundClient | None = None
172
+ ) -> list[Response]:
173
+ updates = {
174
+ "security": security,
175
+ "password": password,
176
+ "flow": flow,
177
+ "limit_ip": limit_ip,
178
+ "limit_gb": limit_gb,
179
+ "expiry_time": expiry_time,
180
+ "enable": enable,
181
+ "sub_id": sub_id,
182
+ "comment": comment,
183
+ }
184
+ updates = {k: v for k, v in updates.items() if v is not None}
185
+
186
+ if verbose:
187
+ if expiry_time and expiry_time < 1e9:
188
+ logging.warning(
189
+ "Warning: You're trying to update a client with expiry time %s. "
190
+ "You set it to expire before 2001, likely because you provided the DURATION. "
191
+ "You need to provide a TIMESTAMP. "
192
+ "If you want to disable this message, set verbose=false.",
193
+ expiry_time,
194
+ )
195
+
196
+ _to_exec: list[Task] = []
197
+ client_uuid = await self._identity.resolve_uuid(telegram_id)
198
+ if prod_only:
199
+ self._prod_cache.get.cache_clear()
200
+ inbounds = await self._prod_cache.get()
201
+ else:
202
+ inbounds = await self._inbounds.get_all()
203
+ for inbound in inbounds:
204
+ found_client = util.get_client_in_inbound(client_uuid, inbound)
205
+ if not found_client:
206
+ if force_search_by_email:
207
+ _email_to_search = util.generate_email_from_tgid_inbid(telegram_id, inbound.id)
208
+ found_traffics = await self._clients.get_client_with_email(_email_to_search, raise_if_none=False)
209
+ if found_traffics:
210
+ resp = await self._inbounds.get_specific_inbound(inbound.id)
211
+ found_client = util.get_client_in_inbound(found_traffics.uuid, resp)
212
+ # this double-check is better than 2 branches doing the same thing
213
+ if not found_client:
214
+ if not_found_action == "ignore":
215
+ pass
216
+ if not_found_action == "raise":
217
+ raise ClientDoesNotExistError(f"Client not found: {client_uuid}")
218
+
219
+ if found_client:
220
+ new_client = found_client.model_copy(update=updates, deep=True)
221
+ _to_exec.append(
222
+ asyncio.create_task(
223
+ self._clients.request_update_client(
224
+ new_client, inbound.id, original_uuid=client_uuid
225
+ )
226
+ )
227
+ )
228
+ return await asyncio.gather(*_to_exec)
229
+
230
+ async def update_client_by_tgid_inbid(self,
231
+ telegram_id: int,
232
+ inbound_id: int,
233
+ /,
234
+ *,
235
+ security: str | None = None,
236
+ password: str | None = None,
237
+ flow: Literal["", "xtls-rprx-vision", "xtls-rprx-vision-udp443"] | None = None,
238
+ limit_ip: int | None = None,
239
+ limit_gb: int | None = None,
240
+ expiry_time: int | None = None,
241
+ enable: bool | None = None,
242
+ sub_id: str | None = None,
243
+ comment: str | None = None,
244
+ email: str | None = None,
245
+ verbose: bool = True,
246
+ force_resolve_by_email: bool = False,
247
+ ) -> Response:
248
+ if verbose:
249
+ if expiry_time and expiry_time < 1e9:
250
+ logging.warning(
251
+ "Warning: You're trying to update a client with expiry time %s. "
252
+ "You set it to expire before 2001, likely because you provided the DURATION. "
253
+ "You need to provide a TIMESTAMP. "
254
+ "If you want to disable this message, set verbose=false.",
255
+ expiry_time,
256
+ )
257
+
258
+ client_uuid = await self._identity.resolve_uuid(telegram_id)
259
+ found = await self._find_client_in_inbound(client_uuid, inbound_id)
260
+ if not found:
261
+ if force_resolve_by_email:
262
+ _email_to_search = util.generate_email_from_tgid_inbid(telegram_id, inbound_id)
263
+ resp = await self._clients.get_client_with_email(_email_to_search, raise_if_none=False)
264
+ if resp is None:
265
+ raise ClientDoesNotExistError(f"The target inbound was force-checked by email but client {_email_to_search} was not found.")
266
+ client_uuid = resp.uuid
267
+ else:
268
+ raise ClientDoesNotExistError(
269
+ f"The target inbound was checked but client {client_uuid} was not found."
270
+ )
271
+ return await self._clients.update_single_client(
272
+ inbound_id=inbound_id,
273
+ found_client=found,
274
+ security=security,
275
+ password=password,
276
+ email=email,
277
+ flow=flow,
278
+ limit_ip=limit_ip,
279
+ limit_gb=limit_gb,
280
+ expiry_time=expiry_time,
281
+ enable=enable,
282
+ sub_id=sub_id,
283
+ comment=comment,
284
+ )
285
+
286
+ async def delete_client_by_tgid(self, telegram_id: int, inbound_id: int) -> Response:
287
+ email = util.generate_email_from_tgid_inbid(telegram_id, inbound_id)
288
+ return await self._clients.delete_client_by_email(email, inbound_id)
289
+
290
+ async def revoke_client_by_tgid_all_inbounds(self, telegram_id: int) -> List[Response]:
291
+ production_inbounds = await self._prod_cache.get()
292
+ _to_exec: list[Task] = []
293
+ for inbound in production_inbounds:
294
+ email = util.generate_email_from_tgid_inbid(telegram_id, inbound.id)
295
+ _to_exec.append(
296
+ asyncio.create_task(
297
+ self._clients.delete_client_by_email(email, inbound.id)
298
+ )
299
+ )
300
+ logging.info("Clients of of tgid %s pending deletion", telegram_id)
301
+ return await asyncio.gather(*_to_exec)
@@ -0,0 +1,28 @@
1
+ from collections.abc import Awaitable, Callable
2
+ from inspect import iscoroutinefunction
3
+
4
+
5
+ class IdentityResolver:
6
+ """Resolves Telegram IDs to UUIDs and subscription IDs.
7
+
8
+ Wraps user-supplied sync-or-async generator callables and exposes
9
+ consistently-async resolve_* methods. Pure (no I/O, no state beyond the
10
+ injected callables).
11
+ """
12
+
13
+ def __init__(self,
14
+ sub_gen: Callable[[int], str] | Callable[[int], Awaitable[str]],
15
+ uuid_gen: Callable[[int], str] | Callable[[int], Awaitable[str]],
16
+ ) -> None:
17
+ self.sub_gen = sub_gen
18
+ self.uuid_gen = uuid_gen
19
+
20
+ async def resolve_uuid(self, telegram_id: int) -> str:
21
+ if iscoroutinefunction(self.uuid_gen):
22
+ return await self.uuid_gen(telegram_id)
23
+ return self.uuid_gen(telegram_id)
24
+
25
+ async def resolve_sub(self, telegram_id: int) -> str:
26
+ if iscoroutinefunction(self.sub_gen):
27
+ return await self.sub_gen(telegram_id)
28
+ return self.sub_gen(telegram_id)
@@ -0,0 +1,75 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import contextlib
5
+ import logging
6
+ import re
7
+ from asyncio import Task
8
+ from typing import TYPE_CHECKING, Any
9
+
10
+ from async_lru import alru_cache
11
+
12
+ from python_3xui.models import Inbound
13
+
14
+ if TYPE_CHECKING:
15
+ from python_3xui.endpoints import Inbounds
16
+
17
+
18
+ class ProductionInboundCache:
19
+ """Per-instance cache of production inbounds + background refresher.
20
+
21
+ The wrapper around the fetch impl is built per-instance (not via a
22
+ class-level decorator) so each XUIClient owns its own async-lru cache
23
+ bound to its own event loop. Using a class-level ``@alru_cache()`` on
24
+ the underlying coroutine binds the cache to the first event loop that
25
+ touches it (see async_lru._check_loop), which breaks any caller that
26
+ creates a new XUIClient on a fresh loop (e.g. each pytest-asyncio test).
27
+ Building the wrapper in ``__init__`` gives every instance its own cache
28
+ bound to its own loop.
29
+ """
30
+
31
+ def __init__(self, inbounds_endpoint: Inbounds, prod_string_pattern: str,
32
+ *, panel_id: Any = None, refresh_interval: float = 3600, cache_size: int = 128,
33
+ ) -> None:
34
+ self._inbounds = inbounds_endpoint
35
+ self.PROD_STRING = re.compile(prod_string_pattern)
36
+ self.panel_id = panel_id
37
+ self._refresh_interval = refresh_interval
38
+ self.get = alru_cache(maxsize=cache_size)(self._fetch_impl)
39
+ self._task: Task | None = None
40
+ self._running: bool = False
41
+
42
+ async def _fetch_impl(self) -> tuple[Inbound, ...]:
43
+ inbounds = await self._inbounds.get_all()
44
+ usable: list[Inbound] = [inb for inb in inbounds if self.PROD_STRING.search(inb.remark)]
45
+ if not usable:
46
+ # Note: this is meant to fail if the prod_string is not found.
47
+ raise RuntimeError("No production inbounds found! Change prod_string!")
48
+ return tuple(usable)
49
+
50
+ def start(self, *, create_new: bool = False) -> None:
51
+ """Idempotent. Mirrors the create_new guard from the original task."""
52
+ if self._task is not None and not create_new:
53
+ logging.warning(
54
+ "Cache cleaner task already running; pass create_new=True to override."
55
+ )
56
+ return
57
+ self._running = True
58
+ logging.info("Initializing cache cleaner task for %s", self.panel_id)
59
+ self._task = asyncio.create_task(
60
+ self._refresh_loop(),
61
+ name=f"inb_cache_clearer_for_{self.panel_id}",
62
+ )
63
+
64
+ async def stop(self) -> None:
65
+ self._running = False
66
+ if self._task is not None:
67
+ with contextlib.suppress(asyncio.CancelledError):
68
+ self._task.cancel("Panel is exiting.")
69
+ self._task = None
70
+
71
+ async def _refresh_loop(self) -> None:
72
+ while self._running:
73
+ self.get.cache_clear()
74
+ await self.get()
75
+ await asyncio.sleep(self._refresh_interval)
@@ -0,0 +1,333 @@
1
+ """HTTP session, credentials, TOTP, and retry-with-relogin policy for the panel API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ import logging
8
+ from collections.abc import AsyncIterable, Iterable, Mapping, Sequence
9
+ from datetime import UTC, datetime
10
+ from logging import DEBUG
11
+ from typing import Any, Literal, Self, Tuple, Type, Union, overload
12
+
13
+ import httpx
14
+ import pyotp
15
+ from httpx import AsyncClient, Request, Response
16
+ from pydantic import SecretStr
17
+
18
+ from python_3xui import util
19
+ from python_3xui.util import JsonType, async_range
20
+
21
+ DataType: Type[str | bytes | Iterable[bytes] | AsyncIterable[bytes]] = Union[
22
+ str, bytes, Iterable[bytes], AsyncIterable[bytes]
23
+ ]
24
+ PrimitiveData = Union[str, int, float, bool] | None
25
+ ParamType = Union[
26
+ Mapping[str, Union[PrimitiveData, Sequence[PrimitiveData]]],
27
+ list[Tuple[str, PrimitiveData]],
28
+ tuple[Tuple[str, PrimitiveData], ...],
29
+ str,
30
+ bytes,
31
+ ]
32
+ CookieType = Union[dict[str, str], list[tuple[str, str]]]
33
+ HeaderType = Union[
34
+ Mapping[str, str],
35
+ Mapping[bytes, bytes],
36
+ Sequence[tuple[str, str]],
37
+ Sequence[tuple[bytes, bytes]],
38
+ ]
39
+
40
+
41
+ class SessionCore:
42
+ """Owns HTTP session, credentials, TOTP, and the retry-with-relogin policy.
43
+
44
+ Single object that talks to the panel. Endpoint classes receive a
45
+ SessionCore (not the full XUIClient) and call ``safe_get`` / ``safe_post``
46
+ on it. The 404-with-expired-session branch in ``_safe_request`` calls
47
+ ``self.login()``, so transport and auth must live together.
48
+ """
49
+
50
+ def __init__(self, base_website: str, base_port: int, base_path: str,
51
+ *, username: str | None = None, password: str | None = None,
52
+ two_fac_code: str | None = None, session_duration: int = 3600,
53
+ max_retries: int = 5, retry_delay: int = 1, panel_id: Any = None,
54
+ ) -> None:
55
+ self.connected: bool = False
56
+ self.session: AsyncClient | None = None
57
+ self.base_host: str = base_website
58
+ self.base_port: int = base_port
59
+ self.base_path: str = base_path
60
+ self.base_url: str = f"https://{self.base_host}:{self.base_port}{self.base_path}"
61
+ self.session_start: float | None = None
62
+ self.session_duration: int = session_duration
63
+ self.xui_username: str | None = username
64
+ self.xui_password: str | None = password
65
+ self.two_fac_secret: SecretStr | None = (
66
+ SecretStr(two_fac_code) if two_fac_code is not None else None
67
+ )
68
+ self.totp: pyotp.TOTP | None = None
69
+ self.max_retries: int = max_retries
70
+ self.retry_delay: int = retry_delay
71
+ self.panel_id = panel_id
72
+ if self.two_fac_secret:
73
+ if len(self.two_fac_secret.get_secret_value()) <= 8:
74
+ print(
75
+ "WARNING: You seem to have entered a 2FA **code**, not a 2FA secret."
76
+ "Although entering the secret is dangerous, there is no other way to provide a consistent way"
77
+ "for continuous login. This code will only work for this specific login."
78
+ )
79
+ self.totp = None
80
+ else:
81
+ self.totp = pyotp.TOTP(self.two_fac_secret.get_secret_value())
82
+
83
+ @overload
84
+ async def _safe_request(self, *, request_to_send: httpx.Request) -> Response:
85
+ ...
86
+
87
+ @overload
88
+ async def _safe_request(self,
89
+ method: Literal["get", "post", "patch", "delete", "put"],
90
+ **kwargs: Any,
91
+ ) -> Response:
92
+ ...
93
+
94
+ async def _safe_request(self,
95
+ method: Literal["get", "post", "patch", "delete", "put"] | None = None,
96
+ **kwargs: Any,
97
+ ) -> Response:
98
+ """Execute an HTTP request with automatic retry on database lock.
99
+
100
+ The request can be made either from a prebuilt ``request_to_send`` or
101
+ from an HTTP method plus keyword arguments accepted by ``httpx``.
102
+ The method handles automatic session refresh on expired 404 responses
103
+ and retries when the 3X-UI database is locked.
104
+
105
+ Args:
106
+ method: The HTTP method to use when building a new request.
107
+ **kwargs: Either ``request_to_send`` by itself, or request
108
+ arguments such as ``url``, ``json``, ``params``, and headers.
109
+
110
+ Returns:
111
+ The HTTP response.
112
+
113
+ Raises:
114
+ ValueError: If neither a method nor a prebuilt request is provided,
115
+ or both request styles are mixed.
116
+ RuntimeError: If max retries are exceeded or a valid session gets
117
+ an unexpected 404 response.
118
+ """
119
+ if "request_to_send" in kwargs and len(kwargs.keys()) != 1:
120
+ raise ValueError(
121
+ "Provide either a prebuilt request or arguments to build one."
122
+ )
123
+ if "request_to_send" not in kwargs:
124
+ if method is None:
125
+ raise ValueError(
126
+ "If there's no prebuilt request, you must provide a method."
127
+ )
128
+
129
+ url = (
130
+ kwargs["url"]
131
+ if "url" in kwargs.keys()
132
+ else kwargs["request_to_send"].url
133
+ )
134
+ if "json" in kwargs:
135
+ json_payload = kwargs["json"]
136
+ elif "request_to_send" in kwargs:
137
+ _req = kwargs["request_to_send"]
138
+ if _req.content:
139
+ try:
140
+ json_payload = json.loads(_req.content.decode())
141
+ except (json.JSONDecodeError, UnicodeDecodeError):
142
+ json_payload = None
143
+ else:
144
+ json_payload = None
145
+ else:
146
+ json_payload = None
147
+ if __debug__:
148
+ logging.info(
149
+ "Safe %s is running to %s%s\nJSON Payload: %s",
150
+ method,
151
+ str(self.session.base_url),
152
+ str(url),
153
+ json.dumps(json_payload) if json_payload is not None else "(no payload)",
154
+ )
155
+ async for attempt in async_range(self.max_retries):
156
+ if "request_to_send" in kwargs:
157
+ _request: Request = kwargs["request_to_send"]
158
+ resp = await self.session.send(_request)
159
+ else:
160
+ # noinspection PyTypeChecker
161
+ resp = await self.session.request(method, **kwargs)
162
+ if resp.status_code // 100 != 2: # because it can return either 201 or 202
163
+ if resp.status_code == 404:
164
+ now: float = datetime.now(UTC).timestamp()
165
+ if (
166
+ self.session_start is None
167
+ or now - self.session_start > self.session_duration
168
+ ):
169
+ logging.info(
170
+ "Client (panel: %s) is not logged in, logging in...",
171
+ self.panel_id or self.base_host,
172
+ )
173
+ await self.login()
174
+ continue
175
+ else:
176
+ logging.error(
177
+ "Server returned a status code of %s with a valid session",
178
+ resp.status_code,
179
+ )
180
+ raise RuntimeError(
181
+ "Server returned a 404, and the session should still be valid, likely it's a REAL 404"
182
+ )
183
+ else:
184
+ logging.error(
185
+ "Server returned a status code of %s", resp.status_code
186
+ )
187
+ resp.raise_for_status()
188
+
189
+ status = await util.check_xui_response(resp)
190
+ if status == "OK":
191
+ return resp
192
+ if status == "DB_LOCKED":
193
+ if attempt + 1 >= self.max_retries:
194
+ raise RuntimeError("Too many retries")
195
+ await asyncio.sleep(self.retry_delay)
196
+ continue
197
+ logging.error(
198
+ "A %s request was unsuccessful (code 200, but success=false).\nPayload: %s",
199
+ method,
200
+ json.dumps(resp.json()),
201
+ )
202
+ return resp
203
+ raise RuntimeError(
204
+ f"For some reason safe_request didn't exit, dump:\nmethod:\n{method}\n{kwargs}"
205
+ )
206
+
207
+ async def safe_get(self,
208
+ url: httpx.URL | str,
209
+ *,
210
+ params: ParamType | None = None,
211
+ headers: HeaderType | None = None,
212
+ cookies: CookieType | None = None,
213
+ ) -> Response:
214
+ """Execute a safe GET request with automatic retry on database lock.
215
+
216
+ Note:
217
+ "Safe" only means "with retries if database is locked".
218
+
219
+ Args:
220
+ url: The URL to request.
221
+ params: Query parameters (optional).
222
+ headers: Request headers (optional).
223
+ cookies: Request cookies (optional).
224
+
225
+ Returns:
226
+ The HTTP response.
227
+
228
+ Raises:
229
+ RuntimeError: If the session is not initialized.
230
+ """
231
+ if self.session is None:
232
+ raise RuntimeError("Session is not initialized")
233
+
234
+ return await self._safe_request(
235
+ method="get",
236
+ url=url,
237
+ params=params,
238
+ headers=headers,
239
+ cookies=cookies,
240
+ )
241
+
242
+ async def safe_post(self,
243
+ url: httpx.URL | str,
244
+ *,
245
+ content: DataType | None = None,
246
+ data: JsonType | None = None,
247
+ json: Any | None = None,
248
+ params: ParamType | None = None,
249
+ headers: HeaderType | None = None,
250
+ cookies: CookieType | None = None,
251
+ ) -> Response:
252
+ """Execute a safe POST request with automatic retry on database lock.
253
+
254
+ Note:
255
+ "Safe" only means "with retries if database is locked".
256
+
257
+ Args:
258
+ url: The URL to request.
259
+ content: Request content (optional).
260
+ data: Form data (optional).
261
+ json: JSON body (optional).
262
+ params: Query parameters (optional).
263
+ headers: Request headers (optional).
264
+ cookies: Request cookies (optional).
265
+
266
+ Returns:
267
+ The HTTP response.
268
+
269
+ Raises:
270
+ RuntimeError: If the session is not initialized.
271
+ """
272
+ if self.session is None:
273
+ raise RuntimeError("Session is not initialized")
274
+
275
+ return await self._safe_request(
276
+ method="post",
277
+ url=url,
278
+ content=content,
279
+ data=data,
280
+ json=json,
281
+ params=params,
282
+ headers=headers,
283
+ cookies=cookies,
284
+ )
285
+
286
+ async def login(self) -> None:
287
+ """Authenticate with the 3X-UI panel."""
288
+ payload = {
289
+ "username": self.xui_username,
290
+ "password": self.xui_password,
291
+ }
292
+ if self.totp:
293
+ if (
294
+ self.totp.interval - datetime.now().timestamp() % self.totp.interval
295
+ < 3
296
+ ):
297
+ await asyncio.sleep(3.1) # just to not submit an invalid code
298
+ payload["twoFactorCode"] = self.totp.now()
299
+ elif self.two_fac_secret:
300
+ payload["twoFactorCode"] = self.two_fac_secret.get_secret_value()
301
+
302
+ logging.info(
303
+ "Client is logging in (panel: %s)", self.panel_id or self.base_host
304
+ )
305
+ resp = await self.session.post("/login", data=payload)
306
+ if resp.status_code == 200:
307
+ resp_json = resp.json()
308
+ if "success" not in resp_json:
309
+ raise RuntimeError(
310
+ f"Error: server returned a status code of {resp.status_code} but the response is not valid: {resp_json}"
311
+ )
312
+ if not resp_json["success"]:
313
+ raise ValueError(
314
+ "Error: wrong credentials (including status code) or failed login."
315
+ )
316
+ self.session_start = datetime.now(UTC).timestamp()
317
+ return
318
+ raise RuntimeError(f"Error: server returned a status code of {resp.status_code}")
319
+
320
+ def connect(self) -> Self:
321
+ """Create an async HTTP client session."""
322
+ logging.log(
323
+ DEBUG, "Client connected (panel: %s)", self.panel_id or self.base_url
324
+ )
325
+ self.session = AsyncClient(base_url=self.base_url)
326
+ self.connected = True
327
+ return self
328
+
329
+ async def disconnect(self) -> None:
330
+ """Close the HTTP session only (no cache teardown)."""
331
+ self.connected = False
332
+ if self.session is not None:
333
+ await self.session.aclose()
File without changes