Python-3xui 0.0.10.post2__tar.gz → 0.0.12__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 (26) hide show
  1. {python_3xui-0.0.10.post2 → python_3xui-0.0.12}/PKG-INFO +4 -8
  2. python_3xui-0.0.12/README.md +6 -0
  3. {python_3xui-0.0.10.post2 → python_3xui-0.0.12}/pyproject.toml +4 -3
  4. {python_3xui-0.0.10.post2 → python_3xui-0.0.12}/python_3xui/__init__.py +1 -1
  5. {python_3xui-0.0.10.post2 → python_3xui-0.0.12}/python_3xui/api.py +10 -5
  6. python_3xui-0.0.12/python_3xui/api_core/__init__.py +4 -0
  7. python_3xui-0.0.12/python_3xui/api_core/client_service.py +328 -0
  8. python_3xui-0.0.12/python_3xui/api_core/identity.py +28 -0
  9. python_3xui-0.0.12/python_3xui/api_core/prod_cache.py +75 -0
  10. python_3xui-0.0.12/python_3xui/api_core/session_core.py +333 -0
  11. python_3xui-0.0.10.post2/README.md +0 -10
  12. {python_3xui-0.0.10.post2 → python_3xui-0.0.12}/.gitignore +0 -0
  13. {python_3xui-0.0.10.post2 → python_3xui-0.0.12}/LICENSE +0 -0
  14. {python_3xui-0.0.10.post2 → python_3xui-0.0.12}/python_3xui/base_model.py +0 -0
  15. {python_3xui-0.0.10.post2 → python_3xui-0.0.12}/python_3xui/custom_exceptions.py +0 -0
  16. {python_3xui-0.0.10.post2 → python_3xui-0.0.12}/python_3xui/endpoints.py +0 -0
  17. {python_3xui-0.0.10.post2 → python_3xui-0.0.12}/python_3xui/models.py +0 -0
  18. {python_3xui-0.0.10.post2 → python_3xui-0.0.12}/python_3xui/util.py +0 -0
  19. {python_3xui-0.0.10.post2 → python_3xui-0.0.12}/tests/conftest.py +0 -0
  20. {python_3xui-0.0.10.post2 → python_3xui-0.0.12}/tests/gather_response_stubs.py +0 -0
  21. {python_3xui-0.0.10.post2 → python_3xui-0.0.12}/tests/pytest.ini +0 -0
  22. {python_3xui-0.0.10.post2 → python_3xui-0.0.12}/tests/test_endpoints_clients.py +0 -0
  23. {python_3xui-0.0.10.post2 → python_3xui-0.0.12}/tests/test_endpoints_inbounds.py +0 -0
  24. {python_3xui-0.0.10.post2 → python_3xui-0.0.12}/tests/test_non_idempotent_endpoints_clients.py +0 -0
  25. {python_3xui-0.0.10.post2 → python_3xui-0.0.12}/tests/test_non_idempotent_endpoints_inbounds.py +0 -0
  26. {python_3xui-0.0.10.post2 → python_3xui-0.0.12}/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.12
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
@@ -11,7 +11,7 @@ Classifier: Development Status :: 3 - Alpha
11
11
  Classifier: Intended Audience :: Developers
12
12
  Classifier: Operating System :: OS Independent
13
13
  Classifier: Programming Language :: Python :: 3
14
- Requires-Python: >=3.11
14
+ Requires-Python: >=3.12
15
15
  Requires-Dist: async-lru~=2.3.0
16
16
  Requires-Dist: httpx~=0.28.1
17
17
  Requires-Dist: pydantic<3,~=2.12.5
@@ -28,9 +28,5 @@ Description-Content-Type: text/markdown
28
28
  <p>I'm not expecting much to be honest, so please feel free to fork it if I abandon the project and you need it!</p>
29
29
  <p>Also, if you REALLY want it I can give you the ownership if I step down, you can find my email in the pyproject.toml (I don't check it that much but trust me I do)</p>
30
30
 
31
- <h2>0.0.10 Release Notes</h2>
32
- <ul>
33
- <li>HOTFIX: make models.SingleInboundClient default flow "", because turns out panel can not return it because of zombification...</li>
34
- <li>Add a custom uuid generator for XUIClient that <i>defaults</i> to method in util but you can make your own!</li>
35
- <li>Uncomplicate self.sub_gen into self._resolve_sub</li>
36
- </ul>
31
+ <h2>0.0.12 Release Notes</h2>
32
+ Mainly just bugfixes. Fix email fallback, fix adding clients, add inbound management.
@@ -0,0 +1,6 @@
1
+ <h1>Hi! This is my example python 3x-ui wrapper!</h1>
2
+ <p>I'm not expecting much to be honest, so please feel free to fork it if I abandon the project and you need it!</p>
3
+ <p>Also, if you REALLY want it I can give you the ownership if I step down, you can find my email in the pyproject.toml (I don't check it that much but trust me I do)</p>
4
+
5
+ <h2>0.0.12 Release Notes</h2>
6
+ Mainly just bugfixes. Fix email fallback, fix adding clients, add inbound management.
@@ -1,13 +1,13 @@
1
1
  [project]
2
2
  name = "Python-3xui"
3
- version = "0.0.10r2"
3
+ version = "0.0.12"
4
4
  authors = [
5
5
  { name="JustMe_001", email="justme001.causation755@passinbox.com" },
6
6
  ]
7
7
  description = "3x-ui wrapper for python"
8
8
  readme = "README.md"
9
9
 
10
- requires-python = ">=3.11"
10
+ requires-python = ">=3.12"
11
11
  classifiers = [
12
12
  "Programming Language :: Python :: 3",
13
13
  "Operating System :: OS Independent",
@@ -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.12"
8
8
  __email__ = ""
9
9
 
10
10
 
@@ -470,7 +470,8 @@ class XUIClient:
470
470
  sub_id: str | None = None,
471
471
  comment: str | None = None,
472
472
  email: str | None = None,
473
- verbose: bool = True) -> Response:
473
+ verbose: bool = True,
474
+ force_resolve_by_email: bool = False) -> Response:
474
475
  """
475
476
  Update a client in a specific inbound by Telegram ID. NOT optimized for multiple inbounds.
476
477
 
@@ -486,8 +487,9 @@ class XUIClient:
486
487
  enable: Whether the client is enabled (optional)
487
488
  sub_id: Subscription ID (optional)
488
489
  comment: Client comment/note (optional)
489
- email: New client email (optional). USE WITH CAUTION BECAUSE THE PANEL WILL NOT TRACK THE NEW EMAIL.
490
-
490
+ email: New client email (optional). USE WITH CAUTION BECAUSE THE XUIClient WILL NOT TRACK THE NEW EMAIL.
491
+ verbose: Enables guardrails.
492
+ force_resolve_by_email: Whether to enable fetch-thru-email fallback when a client is not found, uses ~3 extra fetches but provides an extra layer of protection.
491
493
  Returns:
492
494
  Response from the API
493
495
  """
@@ -505,19 +507,22 @@ class XUIClient:
505
507
  comment=comment,
506
508
  email=email,
507
509
  verbose=verbose,
510
+ force_resolve_by_email=force_resolve_by_email,
508
511
  )
509
512
 
510
- async def delete_client_by_tgid(self, telegram_id: int, inbound_id: int) -> Response:
513
+ async def delete_client_by_tgid(self, telegram_id: int, inbound_id: int, *, suffix: str = "") -> Response:
511
514
  """Delete a client from a specific inbound by Telegram ID.
512
515
 
513
516
  Args:
514
517
  telegram_id: The Telegram ID of the client
515
518
  inbound_id: The ID of the inbound
519
+ suffix: Appended to the generated email before deletion (use when the
520
+ target client was created with a custom email suffix).
516
521
 
517
522
  Returns:
518
523
  Response from the API
519
524
  """
520
- return await self._tg_client_service.delete_client_by_tgid(telegram_id, inbound_id)
525
+ return await self._tg_client_service.delete_client_by_tgid(telegram_id, inbound_id, suffix=suffix)
521
526
 
522
527
  async def revoke_client_by_tgid_all_inbounds(self, telegram_id: int) -> List[Response]:
523
528
  """Delete a client from all production inbounds by Telegram ID.
@@ -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,328 @@
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
+ _search_update_exec: list[Task] = []
111
+ _search_update_resp: dict[int, Task] = {}
112
+ for i, inb_id in enumerate(update_inbound_ids):
113
+ _resp = update_results[i]
114
+ _msg: str = _resp.json()["msg"]
115
+ if "empty client id" in _msg.lower():
116
+ t = asyncio.create_task(
117
+ self._resolve_update_client(telegram_id, inb_id, clients_by_inbound[inb_id])
118
+ )
119
+ _search_update_exec.append(t)
120
+ _search_update_resp[inb_id] = t
121
+ else:
122
+ responses[inb_id] = _resp
123
+
124
+ if _search_update_exec:
125
+ await asyncio.gather(*_search_update_exec)
126
+ for inb_id, task in _search_update_resp.items():
127
+ responses[inb_id] = task.result()
128
+
129
+ # --- Phase 3: raise on remaining duplicates if not exist_ok ---
130
+ if not exist_ok:
131
+ for inb_id, resp in responses.items():
132
+ json_resp: dict = resp.json()
133
+ msg = json_resp.get("msg", "")
134
+ if "duplicate email" in msg.lower():
135
+ logging.error(
136
+ "ERROR: Client already exists and exist_ok not set: %s",
137
+ msg,
138
+ )
139
+ raise ClientEmailAlreadyExistsError(msg)
140
+
141
+ return responses
142
+
143
+ async def _resolve_update_client(self, telegram_id: int, inb_id: int,
144
+ inbound_client: SingleInboundClient) -> Response:
145
+ _found = await self._clients.get_client_with_email(
146
+ util.generate_email_from_tgid_inbid(telegram_id, inb_id)
147
+ )
148
+ return await self._clients.request_update_client(
149
+ inbound_client, inb_id, original_uuid=_found.uuid,
150
+ )
151
+
152
+ async def _find_client_in_inbound(self,
153
+ client_uuid: str,
154
+ inbound_id: int,
155
+ *,
156
+ use_prod_cache: bool = False,
157
+ ) -> SingleInboundClient | None:
158
+ if use_prod_cache:
159
+ prod_inbs = await self._prod_cache.get()
160
+ prod_inb_index = None
161
+ for i, prod_inb in enumerate(prod_inbs):
162
+ if inbound_id == prod_inb.id:
163
+ prod_inb_index = i
164
+
165
+ if prod_inb_index is not None:
166
+ needed_inb: Inbound = prod_inbs[prod_inb_index]
167
+ result = get_client_in_inbound(client_uuid, needed_inb)
168
+ if result is None:
169
+ self._prod_cache.get.cache_clear()
170
+ new_inb = (await self._prod_cache.get())[prod_inb_index]
171
+ return get_client_in_inbound(client_uuid, new_inb)
172
+
173
+ inb = await self._inbounds.get_specific_inbound(inbound_id)
174
+ for client in inb.settings.clients:
175
+ if client.uuid == client_uuid:
176
+ return client
177
+ return None
178
+
179
+ async def update_client_by_tgid_only(self,
180
+ telegram_id: int,
181
+ prod_only: bool,
182
+ /,
183
+ *,
184
+ security: str | None = None,
185
+ password: str | None = None,
186
+ flow: Literal["", "xtls-rprx-vision", "xtls-rprx-vision-udp443"] | None = None,
187
+ limit_ip: int | None = None,
188
+ limit_gb: int | None = None,
189
+ expiry_time: int | None = None,
190
+ enable: bool | None = None,
191
+ sub_id: str | None = None,
192
+ comment: str | None = None,
193
+ verbose: bool = True,
194
+ force_search_by_email: bool = False,
195
+ not_found_action: Literal["raise", "ignore"] = "ignore",
196
+ client_to_create: SingleInboundClient | None = None
197
+ ) -> list[Response]:
198
+ updates = {
199
+ "security": security,
200
+ "password": password,
201
+ "flow": flow,
202
+ "limit_ip": limit_ip,
203
+ "limit_gb": limit_gb,
204
+ "expiry_time": expiry_time,
205
+ "enable": enable,
206
+ "sub_id": sub_id,
207
+ "comment": comment,
208
+ }
209
+ updates = {k: v for k, v in updates.items() if v is not None}
210
+
211
+ if verbose:
212
+ if expiry_time and expiry_time < 1e9:
213
+ logging.warning(
214
+ "Warning: You're trying to update a client with expiry time %s. "
215
+ "You set it to expire before 2001, likely because you provided the DURATION. "
216
+ "You need to provide a TIMESTAMP. "
217
+ "If you want to disable this message, set verbose=false.",
218
+ expiry_time,
219
+ )
220
+
221
+ _to_exec: list[Task] = []
222
+ client_uuid = await self._identity.resolve_uuid(telegram_id)
223
+ if prod_only:
224
+ self._prod_cache.get.cache_clear()
225
+ inbounds = await self._prod_cache.get()
226
+ else:
227
+ inbounds = await self._inbounds.get_all()
228
+ for inbound in inbounds:
229
+ found_client = util.get_client_in_inbound(client_uuid, inbound)
230
+ if not found_client:
231
+ if force_search_by_email:
232
+ _email_to_search = util.generate_email_from_tgid_inbid(telegram_id, inbound.id)
233
+ found_traffics = await self._clients.get_client_with_email(_email_to_search, raise_if_none=False)
234
+ if found_traffics:
235
+ resp = await self._inbounds.get_specific_inbound(inbound.id)
236
+ found_client = util.get_client_in_inbound(found_traffics.uuid, resp)
237
+ # this double-check is better than 2 branches doing the same thing
238
+ if not found_client:
239
+ if not_found_action == "ignore":
240
+ pass
241
+ if not_found_action == "raise":
242
+ raise ClientDoesNotExistError(f"Client not found: {client_uuid}")
243
+
244
+ if found_client:
245
+ new_client = found_client.model_copy(update=updates, deep=True)
246
+ _to_exec.append(
247
+ asyncio.create_task(
248
+ self._clients.request_update_client(
249
+ new_client, inbound.id, original_uuid=client_uuid
250
+ )
251
+ )
252
+ )
253
+ return await asyncio.gather(*_to_exec)
254
+
255
+ async def update_client_by_tgid_inbid(self,
256
+ telegram_id: int,
257
+ inbound_id: int,
258
+ /,
259
+ *,
260
+ security: str | None = None,
261
+ password: str | None = None,
262
+ flow: Literal["", "xtls-rprx-vision", "xtls-rprx-vision-udp443"] | None = None,
263
+ limit_ip: int | None = None,
264
+ limit_gb: int | None = None,
265
+ expiry_time: int | None = None,
266
+ enable: bool | None = None,
267
+ sub_id: str | None = None,
268
+ comment: str | None = None,
269
+ email: str | None = None,
270
+ verbose: bool = True,
271
+ force_resolve_by_email: bool = False,
272
+ ) -> Response:
273
+ if verbose:
274
+ if expiry_time and expiry_time < 1e9:
275
+ logging.warning(
276
+ "Warning: You're trying to update a client with expiry time %s. "
277
+ "You set it to expire before 2001, likely because you provided the DURATION. "
278
+ "You need to provide a TIMESTAMP. "
279
+ "If you want to disable this message, set verbose=false.",
280
+ expiry_time,
281
+ )
282
+
283
+ client_uuid = await self._identity.resolve_uuid(telegram_id)
284
+ found = await self._find_client_in_inbound(client_uuid, inbound_id, use_prod_cache=True)
285
+ if not found:
286
+ if force_resolve_by_email:
287
+ _email_to_search = util.generate_email_from_tgid_inbid(telegram_id, inbound_id)
288
+ resp = await self._clients.get_client_with_email(_email_to_search, raise_if_none=False)
289
+ if resp is None:
290
+ raise ClientDoesNotExistError(f"The target inbound was force-checked by email but client {_email_to_search} was not found.")
291
+ client_uuid = resp.uuid
292
+ found_inbound_id = resp.inboundId
293
+ found = await self._find_client_in_inbound(client_uuid, found_inbound_id, use_prod_cache=True)
294
+ else:
295
+ raise ClientDoesNotExistError(
296
+ f"The target inbound was checked but client {client_uuid} was not found."
297
+ )
298
+ return await self._clients.update_single_client(
299
+ inbound_id=inbound_id,
300
+ found_client=found,
301
+ security=security,
302
+ password=password,
303
+ email=email,
304
+ flow=flow,
305
+ limit_ip=limit_ip,
306
+ limit_gb=limit_gb,
307
+ expiry_time=expiry_time,
308
+ enable=enable,
309
+ sub_id=sub_id,
310
+ comment=comment,
311
+ )
312
+
313
+ async def delete_client_by_tgid(self, telegram_id: int, inbound_id: int, *, suffix: str = "") -> Response:
314
+ email = util.generate_email_from_tgid_inbid(telegram_id, inbound_id) + suffix
315
+ return await self._clients.delete_client_by_email(email, inbound_id)
316
+
317
+ async def revoke_client_by_tgid_all_inbounds(self, telegram_id: int) -> List[Response]:
318
+ production_inbounds = await self._prod_cache.get()
319
+ _to_exec: list[Task] = []
320
+ for inbound in production_inbounds:
321
+ email = util.generate_email_from_tgid_inbid(telegram_id, inbound.id)
322
+ _to_exec.append(
323
+ asyncio.create_task(
324
+ self._clients.delete_client_by_email(email, inbound.id)
325
+ )
326
+ )
327
+ logging.info("Clients of of tgid %s pending deletion", telegram_id)
328
+ 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()
@@ -1,10 +0,0 @@
1
- <h1>Hi! This is my example python 3x-ui wrapper!</h1>
2
- <p>I'm not expecting much to be honest, so please feel free to fork it if I abandon the project and you need it!</p>
3
- <p>Also, if you REALLY want it I can give you the ownership if I step down, you can find my email in the pyproject.toml (I don't check it that much but trust me I do)</p>
4
-
5
- <h2>0.0.10 Release Notes</h2>
6
- <ul>
7
- <li>HOTFIX: make models.SingleInboundClient default flow "", because turns out panel can not return it because of zombification...</li>
8
- <li>Add a custom uuid generator for XUIClient that <i>defaults</i> to method in util but you can make your own!</li>
9
- <li>Uncomplicate self.sub_gen into self._resolve_sub</li>
10
- </ul>
File without changes