Python-3xui 0.0.8.post1__tar.gz → 0.0.9__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 (21) hide show
  1. {python_3xui-0.0.8.post1 → python_3xui-0.0.9}/PKG-INFO +13 -4
  2. python_3xui-0.0.9/README.md +16 -0
  3. {python_3xui-0.0.8.post1 → python_3xui-0.0.9}/pyproject.toml +59 -58
  4. {python_3xui-0.0.8.post1 → python_3xui-0.0.9}/python_3xui/__init__.py +4 -2
  5. {python_3xui-0.0.8.post1 → python_3xui-0.0.9}/python_3xui/api.py +89 -37
  6. {python_3xui-0.0.8.post1 → python_3xui-0.0.9}/python_3xui/base_model.py +9 -12
  7. python_3xui-0.0.9/python_3xui/custom_exceptions.py +12 -0
  8. {python_3xui-0.0.8.post1 → python_3xui-0.0.9}/python_3xui/endpoints.py +46 -48
  9. {python_3xui-0.0.8.post1 → python_3xui-0.0.9}/python_3xui/models.py +54 -29
  10. {python_3xui-0.0.8.post1 → python_3xui-0.0.9}/python_3xui/util.py +13 -13
  11. {python_3xui-0.0.8.post1 → python_3xui-0.0.9}/tests/conftest.py +0 -7
  12. {python_3xui-0.0.8.post1 → python_3xui-0.0.9}/tests/gather_response_stubs.py +2 -2
  13. {python_3xui-0.0.8.post1 → python_3xui-0.0.9}/tests/test_endpoints_inbounds.py +10 -4
  14. {python_3xui-0.0.8.post1 → python_3xui-0.0.9}/tests/test_non_idempotent_endpoints_clients.py +31 -32
  15. {python_3xui-0.0.8.post1 → python_3xui-0.0.9}/tests/test_non_idempotent_endpoints_inbounds.py +1 -0
  16. python_3xui-0.0.9/tests/test_xuiclient_helpers.py +188 -0
  17. python_3xui-0.0.8.post1/README.md +0 -8
  18. {python_3xui-0.0.8.post1 → python_3xui-0.0.9}/.gitignore +0 -0
  19. {python_3xui-0.0.8.post1 → python_3xui-0.0.9}/LICENSE +0 -0
  20. {python_3xui-0.0.8.post1 → python_3xui-0.0.9}/tests/pytest.ini +0 -0
  21. {python_3xui-0.0.8.post1 → python_3xui-0.0.9}/tests/test_endpoints_clients.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: Python-3xui
3
- Version: 0.0.8.post1
3
+ Version: 0.0.9
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
@@ -12,11 +12,12 @@ Classifier: Intended Audience :: Developers
12
12
  Classifier: Operating System :: OS Independent
13
13
  Classifier: Programming Language :: Python :: 3
14
14
  Requires-Python: >=3.11
15
- Requires-Dist: async-lru~=2.2.0
15
+ Requires-Dist: async-lru~=2.3.0
16
16
  Requires-Dist: dotenv~=0.9.9
17
17
  Requires-Dist: httpx~=0.28.1
18
18
  Requires-Dist: pydantic<3,~=2.12.5
19
19
  Requires-Dist: pyotp~=2.9.0
20
+ Requires-Dist: python-dotenv
20
21
  Provides-Extra: testing
21
22
  Requires-Dist: pytest; extra == 'testing'
22
23
  Requires-Dist: pytest-asyncio; extra == 'testing'
@@ -28,7 +29,15 @@ Description-Content-Type: text/markdown
28
29
  <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
30
  <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
31
 
31
- <h2>0.0.81 Release Notes</h2>
32
+ <h2>0.0.9 Release Notes</h2>
32
33
  <ul>
33
- <li>Minor change: make custom sub generators available instead of the default one</li>
34
+ <li>Fix _request_update_client for it to actually work and NOT create "zombies"</li>
35
+ <li>DTO un-split because fields reset when not provided, so full inbounds must be fetched</li>
36
+ <li>New method: update_client_by_tgid</li>
37
+ <li>Fixed test suite</li>
38
+ <li>Fix from_response and from_list</li>
39
+ <li>Remove obsolete and useless client fields from models</li>
40
+ <li>Inbound settings actually get parsed properly into ClientsSettings</li>
41
+ <li>New asyncio task management so they won't get destroyed when GCed</li>
42
+ <li>XUIClient async_lru cache now binds to event loop at runtime, not in initialization</li>
34
43
  </ul>
@@ -0,0 +1,16 @@
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.9 Release Notes</h2>
6
+ <ul>
7
+ <li>Fix _request_update_client for it to actually work and NOT create "zombies"</li>
8
+ <li>DTO un-split because fields reset when not provided, so full inbounds must be fetched</li>
9
+ <li>New method: update_client_by_tgid</li>
10
+ <li>Fixed test suite</li>
11
+ <li>Fix from_response and from_list</li>
12
+ <li>Remove obsolete and useless client fields from models</li>
13
+ <li>Inbound settings actually get parsed properly into ClientsSettings</li>
14
+ <li>New asyncio task management so they won't get destroyed when GCed</li>
15
+ <li>XUIClient async_lru cache now binds to event loop at runtime, not in initialization</li>
16
+ </ul>
@@ -1,59 +1,60 @@
1
- [project]
2
- name = "Python-3xui"
3
- version = "0.0.8r1"
4
- authors = [
5
- { name="JustMe_001", email="justme001.causation755@passinbox.com" },
6
- ]
7
- description = "3x-ui wrapper for python"
8
- readme = "README.md"
9
-
10
- requires-python = ">=3.11"
11
- classifiers = [
12
- "Programming Language :: Python :: 3",
13
- "Operating System :: OS Independent",
14
-
15
- "Development Status :: 3 - Alpha",
16
- "Intended Audience :: Developers",
17
- ]
18
-
19
- license = "Apache-2.0"
20
- license-files = ["LICEN[CS]E*"]
21
-
22
- dependencies = [
23
- "pydantic ~= 2.12.5, < 3",
24
- "httpx ~=0.28.1",
25
- "dotenv ~= 0.9.9",
26
- "async_lru ~= 2.2.0",
27
- "pyotp ~= 2.9.0"
28
- ]
29
-
30
-
31
- [project.optional-dependencies]
32
- testing = ["requests", "pytest", "pytest-asyncio", "pytest-dependency"]
33
-
34
-
35
- [project.urls]
36
- Homepage = "https://github.com/Artem-Potapov/3x-py"
37
- Issues = "https://github.com/Artem-Potapov/3x-py/issues"
38
-
39
-
40
- [build-system]
41
- requires = ["hatchling >= 1.26"]
42
- build-backend = "hatchling.build"
43
-
44
-
45
- [tool.hatch.build.targets.sdist]
46
- include = [
47
- "python_3xui/*.py",
48
- "/tests/*.py",
49
- "/tests/pytest.ini"
50
- ]
51
- exclude = [
52
- "requirements.txt",
53
- "main.py",
54
- ".env"
55
- ]
56
-
57
-
58
- [[tool.hatch.envs.hatch-test.matrix]]
1
+ [project]
2
+ name = "Python-3xui"
3
+ version = "0.0.9"
4
+ authors = [
5
+ { name="JustMe_001", email="justme001.causation755@passinbox.com" },
6
+ ]
7
+ description = "3x-ui wrapper for python"
8
+ readme = "README.md"
9
+
10
+ requires-python = ">=3.11"
11
+ classifiers = [
12
+ "Programming Language :: Python :: 3",
13
+ "Operating System :: OS Independent",
14
+
15
+ "Development Status :: 3 - Alpha",
16
+ "Intended Audience :: Developers",
17
+ ]
18
+
19
+ license = "Apache-2.0"
20
+ license-files = ["LICEN[CS]E*"]
21
+
22
+ dependencies = [
23
+ "pydantic ~= 2.12.5, < 3",
24
+ "httpx ~=0.28.1",
25
+ "dotenv ~= 0.9.9",
26
+ "python-dotenv",
27
+ "async_lru ~= 2.3.0",
28
+ "pyotp ~= 2.9.0"
29
+ ]
30
+
31
+
32
+ [project.optional-dependencies]
33
+ testing = ["requests", "pytest", "pytest-asyncio", "pytest-dependency"]
34
+
35
+
36
+ [project.urls]
37
+ Homepage = "https://github.com/Artem-Potapov/3x-py"
38
+ Issues = "https://github.com/Artem-Potapov/3x-py/issues"
39
+
40
+
41
+ [build-system]
42
+ requires = ["hatchling >= 1.26"]
43
+ build-backend = "hatchling.build"
44
+
45
+
46
+ [tool.hatch.build.targets.sdist]
47
+ include = [
48
+ "python_3xui/*.py",
49
+ "/tests/*.py",
50
+ "/tests/pytest.ini"
51
+ ]
52
+ exclude = [
53
+ "requirements.txt",
54
+ "main.py",
55
+ ".env"
56
+ ]
57
+
58
+
59
+ [[tool.hatch.envs.hatch-test.matrix]]
59
60
  python = ["3.13", "3.12", "3.11"]
@@ -1,5 +1,7 @@
1
1
  from .api import XUIClient
2
2
 
3
3
  __author__ = "JustMe_001"
4
- __version__ = "0.0.1"
5
- __email__ = ""
4
+ __version__ = "0.0.9"
5
+ __email__ = ""
6
+
7
+
@@ -1,22 +1,24 @@
1
+ import asyncio
1
2
  import json
2
3
  import logging
3
4
  import re
4
- import time
5
+ from asyncio import Task
5
6
  from collections.abc import Sequence, Mapping
6
- from inspect import isawaitable
7
- from logging import DEBUG
8
- from typing import Self, Optional, Dict, Iterable, AsyncIterable, Type, Union, Any, List, Tuple, Literal, Callable, Awaitable, Coroutine
9
7
  from datetime import datetime, UTC
8
+ from inspect import iscoroutinefunction
9
+ from logging import DEBUG
10
+ from typing import Self, Optional, Dict, Iterable, AsyncIterable, Type, Union, Any, List, Tuple, Literal, Callable, Awaitable, overload
10
11
 
12
+ import httpx
11
13
  import pyotp
12
- from httpx import Response, AsyncClient
13
14
  from async_lru import alru_cache
14
- import asyncio
15
- import httpx
15
+ from httpx import Response, AsyncClient, Request
16
+ from pydantic import SecretStr
16
17
 
18
+ from . import custom_exceptions
17
19
  from . import util
18
20
  from .models import Inbound, SingleInboundClient, ClientStats
19
- from .util import JsonType, async_range, check_xui_response
21
+ from .util import JsonType, async_range
20
22
 
21
23
  DataType: Type[str | bytes | Iterable[bytes] | AsyncIterable[bytes]] = Union[str, bytes, Iterable[bytes], AsyncIterable[bytes]]
22
24
  PrimitiveData = Optional[Union[str, int, float, bool]]
@@ -42,9 +44,6 @@ class XUIClient:
42
44
  This class provides methods for authenticating with the 3X-UI panel,
43
45
  managing sessions, and performing operations on inbounds and clients.
44
46
 
45
- The client implements a singleton pattern to ensure only one instance
46
- exists at a time.
47
-
48
47
  Attributes:
49
48
  PROD_STRING: String used to identify production inbounds.
50
49
  session: The async HTTP client session.
@@ -63,13 +62,13 @@ class XUIClient:
63
62
  clients_end: Clients endpoint handler.
64
63
  inbounds_end: Inbounds endpoint handler.
65
64
  """
66
- _instance = None
67
65
 
68
66
  def __init__(self, base_website: str, base_port: int, base_path: str,
69
67
  *, username: str | None = None, password: str | None = None,
70
68
  two_fac_code: str | None = None, session_duration: int = 3600,
71
69
  custom_prod_string: str = "testing",
72
- custom_sub_generator: Callable[[int], str]|Callable[[int], Awaitable[str]] = util.default_sub_from_tgid
70
+ max_retries: int = 5, retry_delay = 1,
71
+ custom_sub_generator: Callable[[int], str]|Callable[[int], Awaitable[str]] = util.default_sub_from_tgid,
73
72
  ) -> None:
74
73
  """Initialize the XUIClient.
75
74
 
@@ -94,28 +93,43 @@ class XUIClient:
94
93
  self.session_duration: int = session_duration
95
94
  self.xui_username: str | None = username
96
95
  self.xui_password: str | None = password
97
- self.two_fac_secret: str | None = two_fac_code
96
+ self.two_fac_secret: SecretStr | None = SecretStr(two_fac_code) if two_fac_code is not None else None
98
97
  self.totp: pyotp.TOTP | None = None
99
- self.max_retries: int = 5
100
- self.retry_delay: int = 1
98
+ self.max_retries: int = max_retries
99
+ self.retry_delay: int = retry_delay
101
100
  self.sub_gen = custom_sub_generator
102
101
  # endpoints
103
102
  self.server_end = endpoints.Server(self)
104
103
  self.clients_end = endpoints.Clients(self)
105
104
  self.inbounds_end = endpoints.Inbounds(self)
105
+ # Per-instance cache wrapper. Using a class-level @alru_cache() on the underlying coroutine binds the cache to
106
+ # the first event loop that touches it (see async_lru._check_loop), which breaks any caller that creates
107
+ # a new XUIClient on a fresh loop (e.g. each pytest-asyncio test). Building the wrapper here gives every
108
+ # instance its own cache bound to its own loop.
109
+ self.get_production_inbounds = alru_cache(maxsize=128)(self._get_production_inbounds_impl)
110
+ self._cache_cleaner_task: Task|None = None
106
111
  #init self.totp
107
112
  if self.two_fac_secret:
108
- if self.two_fac_secret.isdigit() and len(self.two_fac_secret) <= 8:
113
+ if len(self.two_fac_secret.get_secret_value()) <= 8:
109
114
  print("WARNING: You seem to have entered a 2FA **code**, not a 2FA secret."
110
115
  "Although entering the secret is dangerous, there is no other way to provide a consistent way"
111
116
  "for continuous login. This code will only work for this specific login.")
112
117
  self.totp = None
113
118
  else:
114
- self.totp = pyotp.TOTP(self.two_fac_secret)
119
+ self.totp = pyotp.TOTP(self.two_fac_secret.get_secret_value())
115
120
 
116
121
  #========================request stuffs========================
122
+ @overload
123
+ async def _safe_request(self, *, request_to_send: httpx.Request) -> Response:
124
+ ...
125
+
126
+ @overload
127
+ async def _safe_request(self, method: Literal["get", "post", "patch", "delete", "put"],
128
+ **kwargs) -> Response:
129
+ ...
130
+
117
131
  async def _safe_request(self,
118
- method: Literal["get", "post", "patch", "delete", "put"],
132
+ method: Literal["get", "post", "patch", "delete", "put"]|None=None,
119
133
  **kwargs) -> Response:
120
134
  """Execute an HTTP request with automatic retry on database lock.
121
135
 
@@ -132,9 +146,23 @@ class XUIClient:
132
146
  Raises:
133
147
  RuntimeError: If max retries exceeded or session is invalid.
134
148
  """
135
- logging.debug("Safe request is running to %s%s", str(self.session.base_url), str(kwargs["url"]))
149
+ if "request_to_send" in kwargs and len(kwargs.keys()) != 1:
150
+ raise ValueError("It's either a predetermined a request or args to build your own.")
151
+ if not "request_to_send" in kwargs:
152
+ if method is None:
153
+ raise ValueError("If there's no prebuilt request, you must provide a method.")
154
+
155
+ #FIXME: make it also extract JSON out of a ready request
156
+ logging.info("Safe %s is running to %s%s\nJSON Payload: %s",
157
+ method, str(self.session.base_url), str(kwargs["url"]) or kwargs["request_to_send"].url,
158
+ json.dumps(kwargs["json"]) if "json" in kwargs.keys() else "(no payload)")
136
159
  async for attempt in async_range(self.max_retries):
137
- resp = await self.session.request(method=method, **kwargs)
160
+ if "request_to_send" in kwargs:
161
+ _request: Request = kwargs["request_to_send"]
162
+ resp = await self.session.send(_request)
163
+ else:
164
+ # noinspection PyTypeChecker
165
+ resp = await self.session.request(method, **kwargs)
138
166
  if resp.status_code // 100 != 2: #because it can return either 201 or 202
139
167
  if resp.status_code == 404:
140
168
  now: float = datetime.now(UTC).timestamp()
@@ -261,7 +289,7 @@ class XUIClient:
261
289
  payload["twoFactorCode"] = self.totp.now()
262
290
  else:
263
291
  if self.two_fac_secret:
264
- payload["twoFactorCode"] = self.two_fac_secret
292
+ payload["twoFactorCode"] = self.two_fac_secret.get_secret_value()
265
293
 
266
294
  logging.info("Client is logging in with IP/Domain: %s", self.base_host)
267
295
  resp = await self.session.post("/login", data=payload)
@@ -293,8 +321,12 @@ class XUIClient:
293
321
 
294
322
  This method closes the async HTTP client session.
295
323
  """
324
+ if self._cache_cleaner_task is not None:
325
+ self._cache_cleaner_task.cancel("Panel is exiting.")
296
326
  self.connected = False
297
- await self.session.aclose()
327
+
328
+ if self.session is not None:
329
+ await self.session.aclose()
298
330
 
299
331
  async def __aenter__(self) -> Self:
300
332
  """Enter the async context manager.
@@ -308,7 +340,9 @@ class XUIClient:
308
340
  """
309
341
  self.connect()
310
342
  await self.login()
311
- asyncio.create_task(self.clear_prod_inbound_cache())
343
+ self._cache_cleaner_task = asyncio.create_task(
344
+ self.clear_prod_inbound_cache(), name=f"inb_cache_clearer_for_{self.base_url}"
345
+ )
312
346
  return self
313
347
 
314
348
  async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:
@@ -327,21 +361,22 @@ class XUIClient:
327
361
  else:
328
362
  logging.warning("Client is disconnecting due to an error (may be unrelated):"
329
363
  "\n%s, with value %s\nStacktrace:%s",
330
- exc_type, exc_val, exc_tb)
364
+ exc_type, exc_val, exc_tb, exc_info=exc_tb)
331
365
  print(f"Client is disconnecting: {self.base_host}")
332
366
  await self.disconnect()
333
367
  return
334
368
 
335
369
  #========================inbound management========================
336
- @alru_cache()
337
- async def get_production_inbounds(self) -> Tuple[Inbound, ...]:
370
+ async def _get_production_inbounds_impl(self) -> tuple[Inbound, ...]:
338
371
  """Retrieve production inbounds.
339
372
 
340
373
  This method fetches all inbounds and filters them based on the
341
- production string. It is cached for efficiency.
374
+ production string. It is wrapped in a per-instance ``alru_cache``
375
+ in ``__init__`` and exposed as ``get_production_inbounds``; do not
376
+ call this method directly outside of that wrapper.
342
377
 
343
378
  Returns:
344
- List[Inbound]: A list of production inbounds.
379
+ tuple[Inbound]: A list of production inbounds.
345
380
 
346
381
  Raises:
347
382
  RuntimeError: If no production inbounds are found.
@@ -404,7 +439,6 @@ class XUIClient:
404
439
  expiry_time: int=0,
405
440
  exist_ok: bool = False
406
441
  ) -> list[Response]:
407
- #TODO: add exist_ok flag
408
442
  """Create and add a production client.
409
443
 
410
444
  This method creates a new client with the given Telegram ID and
@@ -422,11 +456,11 @@ class XUIClient:
422
456
  List[Response]: A list of responses from the server for each
423
457
  inbound the client was added to.
424
458
  """
425
- production_inbounds: List[Inbound] = await self.get_production_inbounds()
459
+ production_inbounds: tuple[Inbound, ...] = await self.get_production_inbounds()
426
460
 
427
461
  tasks = []
428
462
  custom_sub: str
429
- if isawaitable(self.sub_gen(telegram_id)):
463
+ if iscoroutinefunction(self.sub_gen):
430
464
  custom_sub = await self.sub_gen(telegram_id)
431
465
  else:
432
466
  custom_sub = self.sub_gen(telegram_id)
@@ -450,8 +484,29 @@ class XUIClient:
450
484
  json_resp = resp.json()
451
485
  if "duplicate email" in json_resp["msg"].lower():
452
486
  logging.error("ERROR: Client already exists and exist_ok not set: %s", json_resp["msg"])
487
+ raise custom_exceptions.ClientEmailAlreadyExistsError(json_resp["msg"])
453
488
  return responses
454
489
 
490
+ async def _find_client_in_inbound(self, client_uuid: str, inbound_id: int) -> SingleInboundClient|None:
491
+ prod_inbs = await self.get_production_inbounds() #check production first since they're all cached
492
+ prod_inb_index = None
493
+ for i, prod_inb in enumerate(prod_inbs): # see if inbound is production
494
+ if inbound_id == prod_inb.id:
495
+ prod_inb_index = i
496
+
497
+ if prod_inb_index is not None:
498
+ needed_inb: Inbound = prod_inbs[prod_inb_index]
499
+ for client in needed_inb.settings.clients:
500
+ if client.uuid == client_uuid:
501
+ return client
502
+ self.get_production_inbounds.cache_clear() # this means client is in a prod inbound but it's not refreshed
503
+
504
+ inb = await self.inbounds_end.get_specific_inbound(inbound_id)
505
+ for client in inb.settings.clients:
506
+ if client.uuid == client_uuid:
507
+ return client
508
+ return None
509
+
455
510
  async def update_client_by_tgid(self, telegram_id: int, inbound_id: int, /, *,
456
511
  security: str | None = None,
457
512
  password: str | None = None,
@@ -482,10 +537,8 @@ class XUIClient:
482
537
  Returns:
483
538
  Response from the API
484
539
  """
485
- email = util.generate_email_from_tgid_inbid(telegram_id, inbound_id)
486
- existing_client = await self.clients_end.get_client_with_email(email)
487
540
  if verbose:
488
- if expiry_time < 1e9:
541
+ if expiry_time and expiry_time < 1e9:
489
542
  logging.warning("Warning: You're trying to update a client with expiry time %s. "
490
543
  "You set it to expire before 2001, likely because you provided the DURATION. "
491
544
  "You need to provide a TIMESTAMP. "
@@ -493,8 +546,7 @@ class XUIClient:
493
546
  expiry_time)
494
547
 
495
548
  resp = await self.clients_end.update_single_client(
496
- SingleInboundClient.model_validate(existing_client.model_dump()),
497
- inbound_id,
549
+ inbound_id=inbound_id, client_uuid=util.get_uuid_from_tgid(telegram_id),
498
550
  security=security,
499
551
  password=password,
500
552
  flow=flow,
@@ -1,12 +1,9 @@
1
- import asyncio
2
- import logging
3
- from typing import TYPE_CHECKING, Any, Dict, List, Optional, Type, Union, overload, Self, ClassVar, Annotated, Literal, Callable
1
+ from functools import cached_property
2
+ from typing import TYPE_CHECKING, Any, Dict, List, Union, Self, ClassVar
4
3
 
5
- import pydantic
6
4
  import httpx
7
- from functools import cached_property
5
+ import pydantic
8
6
 
9
- from . import models
10
7
  from . import util
11
8
 
12
9
  if TYPE_CHECKING:
@@ -34,13 +31,11 @@ class BaseModel(pydantic.BaseModel):
34
31
 
35
32
  @classmethod
36
33
  def from_list(cls, args: List[Dict[str, Any]],
37
- client: "XUIClient"
38
34
  ) -> List[Self]:
39
35
  """Create a list of model instances from a list of dictionaries.
40
36
 
41
37
  Args:
42
38
  args: A list of dictionaries containing model data.
43
- client: The XUIClient instance to associate with each model.
44
39
 
45
40
  Returns:
46
41
  A list of model instances initialized with the provided data.
@@ -83,12 +78,14 @@ class BaseModel(pydantic.BaseModel):
83
78
  inbounds = await Inbound.from_response(response, client, list)
84
79
  """
85
80
  json_resp: util.JsonType = response.json()
86
- valid = util.check_xui_response(json_resp)
81
+ valid = await util.check_xui_response(json_resp)
87
82
  if valid == "OK":
88
83
  obj = json_resp["obj"]
89
84
  if expect is list:
90
- return cls.from_list(obj, client=client)
85
+ return cls.from_list(obj)
91
86
  if expect is dict:
92
- return cls(**obj, client=client)
87
+ return cls(**obj)
93
88
  else:
94
- raise ValueError(f"Invalid 3X-UI response, code {valid}. Don't use from_response on failed requests.")
89
+ req = response.request
90
+ new_resp = await client._safe_request(request_to_send=req)
91
+ return await cls.from_response(new_resp, client=client, expect=expect, auto_retry=False)
@@ -0,0 +1,12 @@
1
+
2
+ class ClientEmailAlreadyExistsError(Exception):
3
+ def __init__(self, *args):
4
+ super().__init__(args[0] if len(args) == 0 else args)
5
+
6
+ class EmailNotExistsError(Exception):
7
+ def __init__(self, *args):
8
+ super().__init__(args[0] if len(args) == 0 else args)
9
+
10
+ class ClientDoesNotExistError(Exception):
11
+ def __init__(self, *args):
12
+ super().__init__(args[0] if len(args) == 0 else args)