Python-3xui 0.0.8__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: Python-3xui
3
- Version: 0.0.8
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,9 +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.8 Release Notes</h2>
32
+ <h2>0.0.9 Release Notes</h2>
32
33
  <ul>
33
- <li>Improve create_and_add_prod_client to have an expiry_time</li>
34
- <li>delete_client_by_tgid_all_inbounds -> revoke_client_by_tgid_all_inbounds</li>
35
- <li>Change vulnerable requirements</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>
36
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.8"
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]]
59
- python = ["3.12", "3.11"]
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]]
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,21 +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 logging import DEBUG
7
- from typing import Self, Optional, Dict, Iterable, AsyncIterable, Type, Union, Any, List, Tuple, Literal
8
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
9
11
 
12
+ import httpx
10
13
  import pyotp
11
- from httpx import Response, AsyncClient
12
14
  from async_lru import alru_cache
13
- import asyncio
14
- import httpx
15
+ from httpx import Response, AsyncClient, Request
16
+ from pydantic import SecretStr
15
17
 
18
+ from . import custom_exceptions
16
19
  from . import util
17
20
  from .models import Inbound, SingleInboundClient, ClientStats
18
- from .util import JsonType, async_range, check_xui_response
21
+ from .util import JsonType, async_range
19
22
 
20
23
  DataType: Type[str | bytes | Iterable[bytes] | AsyncIterable[bytes]] = Union[str, bytes, Iterable[bytes], AsyncIterable[bytes]]
21
24
  PrimitiveData = Optional[Union[str, int, float, bool]]
@@ -41,9 +44,6 @@ class XUIClient:
41
44
  This class provides methods for authenticating with the 3X-UI panel,
42
45
  managing sessions, and performing operations on inbounds and clients.
43
46
 
44
- The client implements a singleton pattern to ensure only one instance
45
- exists at a time.
46
-
47
47
  Attributes:
48
48
  PROD_STRING: String used to identify production inbounds.
49
49
  session: The async HTTP client session.
@@ -62,12 +62,14 @@ class XUIClient:
62
62
  clients_end: Clients endpoint handler.
63
63
  inbounds_end: Inbounds endpoint handler.
64
64
  """
65
- _instance = None
66
65
 
67
66
  def __init__(self, base_website: str, base_port: int, base_path: str,
68
67
  *, username: str | None = None, password: str | None = None,
69
68
  two_fac_code: str | None = None, session_duration: int = 3600,
70
- custom_prod_string: str = "testing") -> None:
69
+ custom_prod_string: str = "testing",
70
+ max_retries: int = 5, retry_delay = 1,
71
+ custom_sub_generator: Callable[[int], str]|Callable[[int], Awaitable[str]] = util.default_sub_from_tgid,
72
+ ) -> None:
71
73
  """Initialize the XUIClient.
72
74
 
73
75
  Args:
@@ -91,27 +93,43 @@ class XUIClient:
91
93
  self.session_duration: int = session_duration
92
94
  self.xui_username: str | None = username
93
95
  self.xui_password: str | None = password
94
- 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
95
97
  self.totp: pyotp.TOTP | None = None
96
- self.max_retries: int = 5
97
- self.retry_delay: int = 1
98
+ self.max_retries: int = max_retries
99
+ self.retry_delay: int = retry_delay
100
+ self.sub_gen = custom_sub_generator
98
101
  # endpoints
99
102
  self.server_end = endpoints.Server(self)
100
103
  self.clients_end = endpoints.Clients(self)
101
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
102
111
  #init self.totp
103
112
  if self.two_fac_secret:
104
- if self.two_fac_secret.isdigit() and len(self.two_fac_secret) <= 8:
113
+ if len(self.two_fac_secret.get_secret_value()) <= 8:
105
114
  print("WARNING: You seem to have entered a 2FA **code**, not a 2FA secret."
106
115
  "Although entering the secret is dangerous, there is no other way to provide a consistent way"
107
116
  "for continuous login. This code will only work for this specific login.")
108
117
  self.totp = None
109
118
  else:
110
- self.totp = pyotp.TOTP(self.two_fac_secret)
119
+ self.totp = pyotp.TOTP(self.two_fac_secret.get_secret_value())
111
120
 
112
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
+
113
131
  async def _safe_request(self,
114
- method: Literal["get", "post", "patch", "delete", "put"],
132
+ method: Literal["get", "post", "patch", "delete", "put"]|None=None,
115
133
  **kwargs) -> Response:
116
134
  """Execute an HTTP request with automatic retry on database lock.
117
135
 
@@ -128,9 +146,23 @@ class XUIClient:
128
146
  Raises:
129
147
  RuntimeError: If max retries exceeded or session is invalid.
130
148
  """
131
- 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)")
132
159
  async for attempt in async_range(self.max_retries):
133
- 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)
134
166
  if resp.status_code // 100 != 2: #because it can return either 201 or 202
135
167
  if resp.status_code == 404:
136
168
  now: float = datetime.now(UTC).timestamp()
@@ -257,7 +289,7 @@ class XUIClient:
257
289
  payload["twoFactorCode"] = self.totp.now()
258
290
  else:
259
291
  if self.two_fac_secret:
260
- payload["twoFactorCode"] = self.two_fac_secret
292
+ payload["twoFactorCode"] = self.two_fac_secret.get_secret_value()
261
293
 
262
294
  logging.info("Client is logging in with IP/Domain: %s", self.base_host)
263
295
  resp = await self.session.post("/login", data=payload)
@@ -289,8 +321,12 @@ class XUIClient:
289
321
 
290
322
  This method closes the async HTTP client session.
291
323
  """
324
+ if self._cache_cleaner_task is not None:
325
+ self._cache_cleaner_task.cancel("Panel is exiting.")
292
326
  self.connected = False
293
- await self.session.aclose()
327
+
328
+ if self.session is not None:
329
+ await self.session.aclose()
294
330
 
295
331
  async def __aenter__(self) -> Self:
296
332
  """Enter the async context manager.
@@ -304,7 +340,9 @@ class XUIClient:
304
340
  """
305
341
  self.connect()
306
342
  await self.login()
307
- 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
+ )
308
346
  return self
309
347
 
310
348
  async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:
@@ -323,21 +361,22 @@ class XUIClient:
323
361
  else:
324
362
  logging.warning("Client is disconnecting due to an error (may be unrelated):"
325
363
  "\n%s, with value %s\nStacktrace:%s",
326
- exc_type, exc_val, exc_tb)
364
+ exc_type, exc_val, exc_tb, exc_info=exc_tb)
327
365
  print(f"Client is disconnecting: {self.base_host}")
328
366
  await self.disconnect()
329
367
  return
330
368
 
331
369
  #========================inbound management========================
332
- @alru_cache()
333
- async def get_production_inbounds(self) -> Tuple[Inbound, ...]:
370
+ async def _get_production_inbounds_impl(self) -> tuple[Inbound, ...]:
334
371
  """Retrieve production inbounds.
335
372
 
336
373
  This method fetches all inbounds and filters them based on the
337
- 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.
338
377
 
339
378
  Returns:
340
- List[Inbound]: A list of production inbounds.
379
+ tuple[Inbound]: A list of production inbounds.
341
380
 
342
381
  Raises:
343
382
  RuntimeError: If no production inbounds are found.
@@ -400,7 +439,6 @@ class XUIClient:
400
439
  expiry_time: int=0,
401
440
  exist_ok: bool = False
402
441
  ) -> list[Response]:
403
- #TODO: add exist_ok flag
404
442
  """Create and add a production client.
405
443
 
406
444
  This method creates a new client with the given Telegram ID and
@@ -418,9 +456,14 @@ class XUIClient:
418
456
  List[Response]: A list of responses from the server for each
419
457
  inbound the client was added to.
420
458
  """
421
- production_inbounds: List[Inbound] = await self.get_production_inbounds()
459
+ production_inbounds: tuple[Inbound, ...] = await self.get_production_inbounds()
422
460
 
423
461
  tasks = []
462
+ custom_sub: str
463
+ if iscoroutinefunction(self.sub_gen):
464
+ custom_sub = await self.sub_gen(telegram_id)
465
+ else:
466
+ custom_sub = self.sub_gen(telegram_id)
424
467
  for inb in production_inbounds:
425
468
  tmp_email = util.generate_email_from_tgid_inbid(telegram_id, inb.id)
426
469
  client = SingleInboundClient.model_construct(
@@ -429,7 +472,7 @@ class XUIClient:
429
472
  email=tmp_email,
430
473
  limit_gb=0,
431
474
  enable=True,
432
- subscription_id=util.sub_from_tgid(telegram_id),
475
+ subscription_id=custom_sub,
433
476
  comment=f"{additional_remark}, created at {datetime.now(UTC)}",
434
477
  expiry_time=expiry_time * 1000
435
478
  )
@@ -441,8 +484,29 @@ class XUIClient:
441
484
  json_resp = resp.json()
442
485
  if "duplicate email" in json_resp["msg"].lower():
443
486
  logging.error("ERROR: Client already exists and exist_ok not set: %s", json_resp["msg"])
487
+ raise custom_exceptions.ClientEmailAlreadyExistsError(json_resp["msg"])
444
488
  return responses
445
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
+
446
510
  async def update_client_by_tgid(self, telegram_id: int, inbound_id: int, /, *,
447
511
  security: str | None = None,
448
512
  password: str | None = None,
@@ -460,23 +524,21 @@ class XUIClient:
460
524
  Args:
461
525
  telegram_id: The Telegram ID of the client
462
526
  inbound_id: The ID of the inbound where the client exists
463
- security: Client security setting
464
- password: Client password
465
- flow: VLESS flow type
466
- limit_ip: IP connection limit
467
- limit_gb: Data limit in GB
468
- expiry_time: Client expiry time (UNIX timestamp)
469
- enable: Whether the client is enabled
470
- sub_id: Subscription ID
471
- comment: Client comment/note
527
+ security: Client security setting (optional)
528
+ password: Client password (optional)
529
+ flow: VLESS flow type (optional)
530
+ limit_ip: IP connection limit (optional)
531
+ limit_gb: Data limit in GB (optional)
532
+ expiry_time: Client expiry time (UNIX timestamp) (optional)
533
+ enable: Whether the client is enabled (optional)
534
+ sub_id: Subscription ID (optional)
535
+ comment: Client comment/note (optional)
472
536
 
473
537
  Returns:
474
538
  Response from the API
475
539
  """
476
- email = util.generate_email_from_tgid_inbid(telegram_id, inbound_id)
477
- existing_client = await self.clients_end.get_client_with_email(email)
478
540
  if verbose:
479
- if expiry_time < 1e9:
541
+ if expiry_time and expiry_time < 1e9:
480
542
  logging.warning("Warning: You're trying to update a client with expiry time %s. "
481
543
  "You set it to expire before 2001, likely because you provided the DURATION. "
482
544
  "You need to provide a TIMESTAMP. "
@@ -484,8 +546,7 @@ class XUIClient:
484
546
  expiry_time)
485
547
 
486
548
  resp = await self.clients_end.update_single_client(
487
- SingleInboundClient.model_validate(existing_client.model_dump()),
488
- inbound_id,
549
+ inbound_id=inbound_id, client_uuid=util.get_uuid_from_tgid(telegram_id),
489
550
  security=security,
490
551
  password=password,
491
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)