Python-3xui 0.0.9__tar.gz → 0.0.9.post3__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 (23) hide show
  1. {python_3xui-0.0.9 → python_3xui-0.0.9.post3}/PKG-INFO +7 -12
  2. python_3xui-0.0.9.post3/README.md +12 -0
  3. {python_3xui-0.0.9 → python_3xui-0.0.9.post3}/pyproject.toml +1 -2
  4. python_3xui-0.0.9.post3/python_3xui/__init__.py +10 -0
  5. {python_3xui-0.0.9 → python_3xui-0.0.9.post3}/python_3xui/api.py +224 -87
  6. {python_3xui-0.0.9 → python_3xui-0.0.9.post3}/python_3xui/base_model.py +11 -8
  7. python_3xui-0.0.9.post3/python_3xui/custom_exceptions.py +27 -0
  8. {python_3xui-0.0.9 → python_3xui-0.0.9.post3}/python_3xui/endpoints.py +33 -28
  9. {python_3xui-0.0.9 → python_3xui-0.0.9.post3}/python_3xui/models.py +31 -21
  10. {python_3xui-0.0.9 → python_3xui-0.0.9.post3}/python_3xui/util.py +42 -12
  11. {python_3xui-0.0.9 → python_3xui-0.0.9.post3}/tests/conftest.py +1 -1
  12. {python_3xui-0.0.9 → python_3xui-0.0.9.post3}/tests/test_non_idempotent_endpoints_clients.py +1 -1
  13. {python_3xui-0.0.9 → python_3xui-0.0.9.post3}/tests/test_xuiclient_helpers.py +1 -1
  14. python_3xui-0.0.9/README.md +0 -16
  15. python_3xui-0.0.9/python_3xui/__init__.py +0 -7
  16. python_3xui-0.0.9/python_3xui/custom_exceptions.py +0 -12
  17. {python_3xui-0.0.9 → python_3xui-0.0.9.post3}/.gitignore +0 -0
  18. {python_3xui-0.0.9 → python_3xui-0.0.9.post3}/LICENSE +0 -0
  19. {python_3xui-0.0.9 → python_3xui-0.0.9.post3}/tests/gather_response_stubs.py +0 -0
  20. {python_3xui-0.0.9 → python_3xui-0.0.9.post3}/tests/pytest.ini +0 -0
  21. {python_3xui-0.0.9 → python_3xui-0.0.9.post3}/tests/test_endpoints_clients.py +0 -0
  22. {python_3xui-0.0.9 → python_3xui-0.0.9.post3}/tests/test_endpoints_inbounds.py +0 -0
  23. {python_3xui-0.0.9 → python_3xui-0.0.9.post3}/tests/test_non_idempotent_endpoints_inbounds.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: Python-3xui
3
- Version: 0.0.9
3
+ Version: 0.0.9.post3
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
@@ -17,7 +17,6 @@ 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
21
20
  Provides-Extra: testing
22
21
  Requires-Dist: pytest; extra == 'testing'
23
22
  Requires-Dist: pytest-asyncio; extra == 'testing'
@@ -29,15 +28,11 @@ Description-Content-Type: text/markdown
29
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>
30
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>
31
30
 
32
- <h2>0.0.9 Release Notes</h2>
31
+ <h2>0.0.9-r3 Release Notes</h2>
33
32
  <ul>
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>
33
+ <li>HOTFIX: the importing of util.py fixed with from __future__ import annotations</li>
34
+ <li>Make panel_id for better accounting & logging clarity</li>
35
+ <li>Fix __aenter__ in XUIClient to not log a warning</li>
36
+ <li>Fix total_gb to be int and not float, since that would need refactoring which I don't have time for yet.</li>
37
+ <li>ClientsSettings now has extra=ignore instead of extra=forbid.</li>
43
38
  </ul>
@@ -0,0 +1,12 @@
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-r3 Release Notes</h2>
6
+ <ul>
7
+ <li>HOTFIX: the importing of util.py fixed with from __future__ import annotations</li>
8
+ <li>Make panel_id for better accounting & logging clarity</li>
9
+ <li>Fix __aenter__ in XUIClient to not log a warning</li>
10
+ <li>Fix total_gb to be int and not float, since that would need refactoring which I don't have time for yet.</li>
11
+ <li>ClientsSettings now has extra=ignore instead of extra=forbid.</li>
12
+ </ul>
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "Python-3xui"
3
- version = "0.0.9"
3
+ version = "0.0.9r3"
4
4
  authors = [
5
5
  { name="JustMe_001", email="justme001.causation755@passinbox.com" },
6
6
  ]
@@ -23,7 +23,6 @@ dependencies = [
23
23
  "pydantic ~= 2.12.5, < 3",
24
24
  "httpx ~=0.28.1",
25
25
  "dotenv ~= 0.9.9",
26
- "python-dotenv",
27
26
  "async_lru ~= 2.3.0",
28
27
  "pyotp ~= 2.9.0"
29
28
  ]
@@ -0,0 +1,10 @@
1
+ """Public package interface for the python_3xui API wrapper."""
2
+
3
+ from .api import XUIClient
4
+ import python_3xui.custom_exceptions as exceptions
5
+
6
+ __author__ = "JustMe_001"
7
+ __version__ = "0.0.9r3"
8
+ __email__ = ""
9
+
10
+
@@ -8,6 +8,7 @@ from datetime import datetime, UTC
8
8
  from inspect import iscoroutinefunction
9
9
  from logging import DEBUG
10
10
  from typing import Self, Optional, Dict, Iterable, AsyncIterable, Type, Union, Any, List, Tuple, Literal, Callable, Awaitable, overload
11
+ import contextlib
11
12
 
12
13
  import httpx
13
14
  import pyotp
@@ -17,19 +18,20 @@ from pydantic import SecretStr
17
18
 
18
19
  from . import custom_exceptions
19
20
  from . import util
21
+ from . import endpoints
20
22
  from .models import Inbound, SingleInboundClient, ClientStats
21
- from .util import JsonType, async_range
23
+ from .util import JsonType, async_range, get_inbound_in_client
22
24
 
23
25
  DataType: Type[str | bytes | Iterable[bytes] | AsyncIterable[bytes]] = Union[str, bytes, Iterable[bytes], AsyncIterable[bytes]]
24
26
  PrimitiveData = Optional[Union[str, int, float, bool]]
25
27
  ParamType = Union[
26
28
  Mapping[str, Union[PrimitiveData, Sequence[PrimitiveData]]],
27
- List[Tuple[str, PrimitiveData]],
28
- Tuple[Tuple[str, PrimitiveData], ...],
29
+ list[Tuple[str, PrimitiveData]],
30
+ tuple[Tuple[str, PrimitiveData], ...],
29
31
  str,
30
32
  bytes,
31
33
  ]
32
- CookieType = Union[Dict[str, str], List[Tuple[str, str]]]
34
+ CookieType = Union[Dict[str, str], list[tuple[str, str]]]
33
35
  HeaderType = Union[
34
36
  Mapping[str, str],
35
37
  Mapping[bytes, bytes],
@@ -43,21 +45,26 @@ class XUIClient:
43
45
 
44
46
  This class provides methods for authenticating with the 3X-UI panel,
45
47
  managing sessions, and performing operations on inbounds and clients.
48
+ It also owns the endpoint handlers and the per-instance production
49
+ inbound cache.
46
50
 
47
51
  Attributes:
48
- PROD_STRING: String used to identify production inbounds.
49
- session: The async HTTP client session.
52
+ connected: Whether an HTTP session is currently open.
53
+ PROD_STRING: Compiled regex used to identify production inbounds.
54
+ session: The async HTTP client session, if connected.
50
55
  base_host: The server hostname.
51
56
  base_port: The server port.
52
57
  base_path: The base path for the API.
53
58
  base_url: The full base URL for API requests.
54
59
  session_start: Timestamp of when the session was created.
55
60
  session_duration: Maximum session duration in seconds.
56
- username: Username for authentication.
57
- password: Password for authentication.
58
- two_fac_code: Two-factor authentication code (if enabled).
61
+ xui_username: Username for authentication.
62
+ xui_password: Password for authentication.
63
+ two_fac_secret: TOTP secret or one-shot 2FA code, if configured.
64
+ totp: TOTP generator used for repeated logins when a secret is provided.
59
65
  max_retries: Maximum number of retry attempts for failed requests.
60
66
  retry_delay: Delay in seconds between retries.
67
+ sub_gen: Callable used to derive subscription IDs from Telegram IDs.
61
68
  server_end: Server endpoint handler.
62
69
  clients_end: Clients endpoint handler.
63
70
  inbounds_end: Inbounds endpoint handler.
@@ -67,8 +74,9 @@ class XUIClient:
67
74
  *, username: str | None = None, password: str | None = None,
68
75
  two_fac_code: str | None = None, session_duration: int = 3600,
69
76
  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,
77
+ max_retries: int = 5, retry_delay=1,
78
+ custom_sub_generator: Callable[[int], str] | Callable[[int], Awaitable[str]] = util.default_sub_from_tgid,
79
+ panel_id: Any = None
72
80
  ) -> None:
73
81
  """Initialize the XUIClient.
74
82
 
@@ -78,10 +86,16 @@ class XUIClient:
78
86
  base_path: The base path for the API (e.g., "/panel").
79
87
  username: Username for authentication.
80
88
  password: Password for authentication.
81
- two_fac_code: Two-factor authentication code (if enabled).
89
+ two_fac_code: TOTP secret for 2FA. Short one-shot codes are
90
+ accepted for the current login only.
82
91
  session_duration: Maximum session duration in seconds. Defaults to 3600.
92
+ custom_prod_string: Regex pattern used to select production inbounds.
93
+ max_retries: Maximum retries for database-lock responses.
94
+ retry_delay: Seconds to wait between database-lock retries.
95
+ custom_sub_generator: Sync or async callable that receives a
96
+ Telegram ID and returns the subscription ID for new clients.
97
+ panel_id: this is solely for user's purposes to increase logging and accounting clarity. Default is None.
83
98
  """
84
- from . import endpoints # look, I know it's bad, but we need to evade cyclical imports
85
99
  self.connected: bool = False
86
100
  self.PROD_STRING = re.compile(custom_prod_string)
87
101
  self.session: AsyncClient | None = None
@@ -98,6 +112,7 @@ class XUIClient:
98
112
  self.max_retries: int = max_retries
99
113
  self.retry_delay: int = retry_delay
100
114
  self.sub_gen = custom_sub_generator
115
+ self.panel_id: int | str | Any = panel_id
101
116
  # endpoints
102
117
  self.server_end = endpoints.Server(self)
103
118
  self.clients_end = endpoints.Clients(self)
@@ -107,7 +122,7 @@ class XUIClient:
107
122
  # a new XUIClient on a fresh loop (e.g. each pytest-asyncio test). Building the wrapper here gives every
108
123
  # instance its own cache bound to its own loop.
109
124
  self.get_production_inbounds = alru_cache(maxsize=128)(self._get_production_inbounds_impl)
110
- self._cache_cleaner_task: Task|None = None
125
+ self._cache_cleaner_task: Task | None = None
111
126
  #init self.totp
112
127
  if self.two_fac_secret:
113
128
  if len(self.two_fac_secret.get_secret_value()) <= 8:
@@ -129,33 +144,52 @@ class XUIClient:
129
144
  ...
130
145
 
131
146
  async def _safe_request(self,
132
- method: Literal["get", "post", "patch", "delete", "put"]|None=None,
147
+ method: Literal["get", "post", "patch", "delete", "put"] | None = None,
133
148
  **kwargs) -> Response:
134
149
  """Execute an HTTP request with automatic retry on database lock.
135
150
 
136
- This method handles automatic session refresh and retries when
137
- the 3X-UI database is locked.
151
+ The request can be made either from a prebuilt ``request_to_send`` or
152
+ from an HTTP method plus keyword arguments accepted by ``httpx``.
153
+ The method handles automatic session refresh on expired 404 responses
154
+ and retries when the 3X-UI database is locked.
138
155
 
139
156
  Args:
140
- method: The HTTP method to use.
141
- **kwargs: Additional arguments passed to the HTTP request.
157
+ method: The HTTP method to use when building a new request.
158
+ **kwargs: Either ``request_to_send`` by itself, or request
159
+ arguments such as ``url``, ``json``, ``params``, and headers.
142
160
 
143
161
  Returns:
144
162
  The HTTP response.
145
163
 
146
164
  Raises:
147
- RuntimeError: If max retries exceeded or session is invalid.
165
+ ValueError: If neither a method nor a prebuilt request is provided,
166
+ or both request styles are mixed.
167
+ RuntimeError: If max retries are exceeded or a valid session gets
168
+ an unexpected 404 response.
148
169
  """
149
170
  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.")
171
+ raise ValueError("Provide either a prebuilt request or arguments to build one.")
151
172
  if not "request_to_send" in kwargs:
152
173
  if method is None:
153
174
  raise ValueError("If there's no prebuilt request, you must provide a method.")
154
175
 
155
- #FIXME: make it also extract JSON out of a ready request
176
+ url = kwargs["url"] if "url" in kwargs.keys() else kwargs["request_to_send"].url
177
+ if "json" in kwargs:
178
+ json_payload = kwargs["json"]
179
+ elif "request_to_send" in kwargs:
180
+ _req = kwargs["request_to_send"]
181
+ if _req.content:
182
+ try:
183
+ json_payload = json.loads(_req.content.decode())
184
+ except (json.JSONDecodeError, UnicodeDecodeError):
185
+ json_payload = None
186
+ else:
187
+ json_payload = None
188
+ else:
189
+ json_payload = None
156
190
  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)")
191
+ method, str(self.session.base_url), str(url),
192
+ json.dumps(json_payload) if json_payload is not None else "(no payload)")
159
193
  async for attempt in async_range(self.max_retries):
160
194
  if "request_to_send" in kwargs:
161
195
  _request: Request = kwargs["request_to_send"]
@@ -167,7 +201,7 @@ class XUIClient:
167
201
  if resp.status_code == 404:
168
202
  now: float = datetime.now(UTC).timestamp()
169
203
  if self.session_start is None or now - self.session_start > self.session_duration:
170
- logging.info("Client with IP/Domain %s is not logged in, logging in...", self.base_host)
204
+ logging.info("Client (panel: %s) is not logged in, logging in...", self.panel_id or self.base_host)
171
205
  await self.login()
172
206
  continue
173
207
  else:
@@ -291,15 +325,16 @@ class XUIClient:
291
325
  if self.two_fac_secret:
292
326
  payload["twoFactorCode"] = self.two_fac_secret.get_secret_value()
293
327
 
294
- logging.info("Client is logging in with IP/Domain: %s", self.base_host)
328
+ logging.info("Client is logging in (panel: %s)", self.panel_id or self.base_host)
295
329
  resp = await self.session.post("/login", data=payload)
296
330
  if resp.status_code == 200:
297
331
  resp_json = resp.json()
298
- if resp_json["success"]:
299
- self.session_start: float = (datetime.now(UTC).timestamp())
300
- return
301
- else:
332
+ if "success" not in resp_json:
333
+ raise RuntimeError(f"Error: server returned a status code of {resp.status_code} but the response is not valid: {resp_json}")
334
+ if not resp_json["success"]:
302
335
  raise ValueError("Error: wrong credentials (including status code) or failed login.")
336
+ self.session_start: float = (datetime.now(UTC).timestamp())
337
+ return
303
338
  else:
304
339
  raise RuntimeError(f"Error: server returned a status code of {resp.status_code}")
305
340
 
@@ -311,7 +346,7 @@ class XUIClient:
311
346
  Returns:
312
347
  Self: The XUIClient instance.
313
348
  """
314
- logging.log(DEBUG, "Client connected with IP/domain %s", self.base_url)
349
+ logging.log(DEBUG, "Client connected (panel: %s)", self.panel_id or self.base_url)
315
350
  self.session = AsyncClient(base_url=self.base_url)
316
351
  self.connected = True
317
352
  return self
@@ -322,7 +357,8 @@ class XUIClient:
322
357
  This method closes the async HTTP client session.
323
358
  """
324
359
  if self._cache_cleaner_task is not None:
325
- self._cache_cleaner_task.cancel("Panel is exiting.")
360
+ with contextlib.suppress(asyncio.CancelledError):
361
+ self._cache_cleaner_task.cancel("Panel is exiting.")
326
362
  self.connected = False
327
363
 
328
364
  if self.session is not None:
@@ -340,9 +376,10 @@ class XUIClient:
340
376
  """
341
377
  self.connect()
342
378
  await self.login()
343
- self._cache_cleaner_task = asyncio.create_task(
344
- self.clear_prod_inbound_cache(), name=f"inb_cache_clearer_for_{self.base_url}"
345
- )
379
+ if not self._cache_cleaner_task:
380
+ self._cache_cleaner_task = asyncio.create_task(
381
+ self._clear_prod_inbound_cache_task(create_new=True), name=f"inb_cache_clearer_for_{self.base_url}"
382
+ )
346
383
  return self
347
384
 
348
385
  async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:
@@ -356,13 +393,13 @@ class XUIClient:
356
393
  exc_val: The exception value, if an exception occurred.
357
394
  exc_tb: The exception traceback, if an exception occurred.
358
395
  """
359
- if exc_type is None or exc_type == asyncio.exceptions.CancelledError:
360
- logging.info("Client is disconnecting at time with IP/Domain %s", self.base_host)
396
+ if exc_type is None or exc_type is asyncio.exceptions.CancelledError:
397
+ logging.info("Client is disconnecting (panel: %s)", self.panel_id or self.base_host)
361
398
  else:
362
399
  logging.warning("Client is disconnecting due to an error (may be unrelated):"
363
400
  "\n%s, with value %s\nStacktrace:%s",
364
401
  exc_type, exc_val, exc_tb, exc_info=exc_tb)
365
- print(f"Client is disconnecting: {self.base_host}")
402
+ print(f"Client is disconnecting: {self.panel_id or self.base_host}")
366
403
  await self.disconnect()
367
404
  return
368
405
 
@@ -391,24 +428,27 @@ class XUIClient:
391
428
 
392
429
  return tuple(usable_inbounds)
393
430
 
394
- async def clear_prod_inbound_cache(self):
395
- """Clear the production inbound cache.
431
+ async def _clear_prod_inbound_cache_task(self, *, create_new: bool = False):
432
+ """Refresh the production inbound cache in the background.
396
433
 
397
- This method clears the cache of production inbounds and refills it
398
- by fetching the inbounds again. It is intended to be run as a
399
- background task.
434
+ The async context manager starts this loop after login. Each cycle
435
+ clears the cached production inbound list, repopulates it from the
436
+ panel, and then waits before refreshing again.
400
437
 
401
- Note:
402
- This method currently runs every 10 seconds. Please change the
403
- timer from 5 to 60*60*24 in the code.
438
+ create_new param is kw-only and for people who know what they're doing, so they won't get the warning.
404
439
  """
440
+ if (self._cache_cleaner_task is not None) and (not create_new):
441
+ logging.warning("You're trying to create another cache cleaner task, which is a FaF (Fire-And-Forget)."
442
+ "Please destroy the previous task and set _cache_cleaner_task to None, if you know what you're doing.")
443
+ return
444
+ logging.info("Initializing cache cleaner task for %s", self.panel_id)
405
445
  while self.connected:
406
446
  self.get_production_inbounds.cache_clear()
407
- await self.get_production_inbounds() #fill the cache
408
- await asyncio.sleep(3600) #update every 1h
447
+ await self.get_production_inbounds() # fill the cache
448
+ await asyncio.sleep(3600) # update every 1h
409
449
 
410
450
  #========================clients management========================
411
- async def get_client_with_tgid(self, tgid: int, inbound_id: int | None = None) -> List[ClientStats]:
451
+ async def get_client_with_tgid(self, tgid: int, inbound_id: int | None = None) -> list[ClientStats]:
412
452
  """Retrieve client information by Telegram ID.
413
453
 
414
454
  This method fetches client information using the Telegram ID. If
@@ -436,25 +476,31 @@ class XUIClient:
436
476
 
437
477
  async def create_and_add_prod_client(self, telegram_id: int, *,
438
478
  additional_remark: str | None = None,
439
- expiry_time: int=0,
479
+ expiry_time: int = 0,
440
480
  exist_ok: bool = False
441
481
  ) -> list[Response]:
442
482
  """Create and add a production client.
443
483
 
444
484
  This method creates a new client with the given Telegram ID and
445
485
  adds it to the production inbounds. The client is configured with
446
- default settings and the additional remark.
447
- Note that the sub id is created by util.generate_email_from_tgid_inbid, so use that to retrieve.
486
+ default settings and the additional remark. The subscription ID is
487
+ created by ``self.sub_gen``; by default this is
488
+ ``util.default_sub_from_tgid``.
448
489
 
449
490
  Args:
450
491
  telegram_id: The Telegram ID of the client.
451
492
  additional_remark: An optional additional remark for the client.
452
493
  expiry_time: Expiry time in SECONDS as a UNIX timestamp.
453
- exist_ok: Don't raise any errors if the client is already there (good if you need a refresh job)
494
+ exist_ok: If True, return API responses even when the panel reports
495
+ a duplicate email.
454
496
 
455
497
  Returns:
456
498
  List[Response]: A list of responses from the server for each
457
499
  inbound the client was added to.
500
+
501
+ Raises:
502
+ ClientEmailAlreadyExistsError: If a duplicate client is reported
503
+ and ``exist_ok`` is False.
458
504
  """
459
505
  production_inbounds: tuple[Inbound, ...] = await self.get_production_inbounds()
460
506
 
@@ -466,15 +512,15 @@ class XUIClient:
466
512
  custom_sub = self.sub_gen(telegram_id)
467
513
  for inb in production_inbounds:
468
514
  tmp_email = util.generate_email_from_tgid_inbid(telegram_id, inb.id)
469
- client = SingleInboundClient.model_construct(
515
+ client = SingleInboundClient(
470
516
  uuid=util.get_uuid_from_tgid(telegram_id),
471
517
  flow="",
472
518
  email=tmp_email,
473
519
  limit_gb=0,
474
520
  enable=True,
475
521
  subscription_id=custom_sub,
476
- comment=f"{additional_remark}, created at {datetime.now(UTC)}",
477
- expiry_time=expiry_time * 1000
522
+ comment=f"{additional_remark + ", " if additional_remark else ""}created at {datetime.now(UTC)}",
523
+ expiry_time=expiry_time * 1000,
478
524
  )
479
525
  tasks.append(asyncio.create_task(self.clients_end.add_client(client, inb.id)))
480
526
  responses: list[Response] = await asyncio.gather(*tasks)
@@ -487,19 +533,29 @@ class XUIClient:
487
533
  raise custom_exceptions.ClientEmailAlreadyExistsError(json_resp["msg"])
488
534
  return responses
489
535
 
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
536
+ async def _find_client_in_inbound(self, client_uuid: str, inbound_id: int,
537
+ use_cache=False) -> SingleInboundClient | None:
538
+ """Note:
539
+ Cached production inbounds can be stale because the panel may be
540
+ changed by another actor. If a cached production inbound misses the
541
+ client, the production cache is cleared and fetched once more
542
+ before falling back to a direct inbound lookup.
543
+ """
544
+ if use_cache:
545
+ prod_inbs = await self.get_production_inbounds()
546
+ prod_inb_index = None
547
+ for i, prod_inb in enumerate(prod_inbs): # see if inbound is production
548
+ if inbound_id == prod_inb.id:
549
+ prod_inb_index = i
550
+
551
+ if prod_inb_index is not None:
552
+ needed_inb: Inbound = prod_inbs[prod_inb_index]
553
+ result = get_inbound_in_client(client_uuid, needed_inb)
554
+ if result is None:
555
+ self.get_production_inbounds.cache_clear() # this means client is in a prod inbound but it's not refreshed
556
+ new_inb = (await self.get_production_inbounds())[prod_inb_index]
557
+ new_result = get_inbound_in_client(client_uuid, new_inb)
558
+ return new_result
503
559
 
504
560
  inb = await self.inbounds_end.get_specific_inbound(inbound_id)
505
561
  for client in inb.settings.clients:
@@ -507,19 +563,98 @@ class XUIClient:
507
563
  return client
508
564
  return None
509
565
 
510
- async def update_client_by_tgid(self, telegram_id: int, inbound_id: int, /, *,
511
- security: str | None = None,
512
- password: str | None = None,
513
- flow: Literal["", "xtls-rprx-vision", "xtls-rprx-vision-udp443"] | None = None,
514
- limit_ip: int | None = None,
515
- limit_gb: int | None = None,
516
- expiry_time: int | None = None,
517
- enable: bool | None = None,
518
- sub_id: str | None = None,
519
- comment: str | None = None,
520
- verbose: bool=True) -> Response:
566
+ async def update_client_by_tgid_only(self, telegram_id: int, prod_only: bool, /, *,
567
+ security: str | None = None,
568
+ password: str | None = None,
569
+ flow: Literal["", "xtls-rprx-vision", "xtls-rprx-vision-udp443"] | None = None,
570
+ limit_ip: int | None = None,
571
+ limit_gb: int | None = None,
572
+ expiry_time: int | None = None,
573
+ enable: bool | None = None,
574
+ sub_id: str | None = None,
575
+ comment: str | None = None,
576
+ verbose: bool = True
577
+ ) -> list[Response]:
578
+ """Update every matching client found by Telegram ID.
579
+
580
+ The client UUID is derived from ``telegram_id`` and searched across
581
+ either production inbounds or all inbounds. Only keyword arguments with
582
+ non-None values are applied to the client model before sending update
583
+ requests.
584
+
585
+ Args:
586
+ telegram_id: Telegram ID used to derive the client UUID.
587
+ prod_only: If True, search only production inbounds. If False,
588
+ search every inbound returned by the panel.
589
+ security: New security setting.
590
+ password: New password.
591
+ flow: New VLESS flow value.
592
+ limit_ip: New simultaneous IP connection limit.
593
+ limit_gb: New traffic limit in gigabytes.
594
+ expiry_time: New expiry timestamp in seconds.
595
+ enable: New enabled state.
596
+ sub_id: New subscription ID.
597
+ comment: New client comment.
598
+ verbose: If True, warn when ``expiry_time`` looks like a duration
599
+ instead of a UNIX timestamp.
600
+
601
+ Returns:
602
+ Responses from each inbound where a matching client was updated.
603
+ """
604
+ updates = {
605
+ "security": security,
606
+ "password": password,
607
+ "flow": flow,
608
+ "limit_ip": limit_ip,
609
+ "limit_gb": limit_gb,
610
+ "expiry_time": expiry_time,
611
+ "enable": enable,
612
+ "sub_id": sub_id,
613
+ "comment": comment,
614
+ }
615
+ # remove None values
616
+ updates = {k: v for k, v in updates.items() if v is not None}
617
+
618
+ if verbose:
619
+ if expiry_time and expiry_time < 1e9:
620
+ logging.warning("Warning: You're trying to update a client with expiry time %s. "
621
+ "You set it to expire before 2001, likely because you provided the DURATION. "
622
+ "You need to provide a TIMESTAMP. "
623
+ "If you want to disable this message, set verbose=false.",
624
+ expiry_time)
625
+
626
+ _to_exec: list[Task] = []
627
+ if prod_only:
628
+ self.get_production_inbounds.cache_clear()
629
+ inbounds = await self.get_production_inbounds()
630
+ else:
631
+ inbounds = await self.inbounds_end.get_all()
632
+ for inbound in inbounds:
633
+ found_client = util.get_inbound_in_client(util.get_uuid_from_tgid(telegram_id), inbound)
634
+ if found_client:
635
+ new_client = found_client.model_copy(update=updates, deep=True)
636
+ _to_exec.append(
637
+ asyncio.create_task(self.clients_end.request_update_client(
638
+ new_client, inbound.id, original_uuid=util.get_uuid_from_tgid(telegram_id)
639
+ ))
640
+ )
641
+ responses = await asyncio.gather(*_to_exec)
642
+ return responses
643
+
644
+ async def update_client_by_tgid_inbid(self, telegram_id: int, inbound_id: int, /, *,
645
+ security: str | None = None,
646
+ password: str | None = None,
647
+ flow: Literal["", "xtls-rprx-vision", "xtls-rprx-vision-udp443"] | None = None,
648
+ limit_ip: int | None = None,
649
+ limit_gb: int | None = None,
650
+ expiry_time: int | None = None,
651
+ enable: bool | None = None,
652
+ sub_id: str | None = None,
653
+ comment: str | None = None,
654
+ email: str | None = None,
655
+ verbose: bool = True) -> Response:
521
656
  """
522
- Update a client in a specific inbound by Telegram ID.
657
+ Update a client in a specific inbound by Telegram ID. NOT optimized for multiple inbounds.
523
658
 
524
659
  Args:
525
660
  telegram_id: The Telegram ID of the client
@@ -533,6 +668,7 @@ class XUIClient:
533
668
  enable: Whether the client is enabled (optional)
534
669
  sub_id: Subscription ID (optional)
535
670
  comment: Client comment/note (optional)
671
+ email: New client email (optional). USE WITH CAUTION BECAUSE THE PANEL WILL NOT TRACK THE NEW EMAIL.
536
672
 
537
673
  Returns:
538
674
  Response from the API
@@ -549,6 +685,7 @@ class XUIClient:
549
685
  inbound_id=inbound_id, client_uuid=util.get_uuid_from_tgid(telegram_id),
550
686
  security=security,
551
687
  password=password,
688
+ email=email,
552
689
  flow=flow,
553
690
  limit_ip=limit_ip,
554
691
  limit_gb=limit_gb,
@@ -583,12 +720,12 @@ class XUIClient:
583
720
  List of Response objects from each deletion attempt
584
721
  """
585
722
  production_inbounds = await self.get_production_inbounds()
586
- responses = []
587
-
723
+ _to_exec: list[Task] = []
588
724
  for inbound in production_inbounds:
589
725
  email = util.generate_email_from_tgid_inbid(telegram_id, inbound.id)
590
- resp = await self.clients_end.delete_client_by_email(email, inbound.id)
591
- responses.append(resp)
592
- logging.info("Clients of of tgid %s deleted", telegram_id)
593
-
726
+ _to_exec.append(
727
+ asyncio.create_task(self.clients_end.delete_client_by_email(email, inbound.id))
728
+ )
729
+ logging.info("Clients of of tgid %s pending deletion", telegram_id)
730
+ responses = await asyncio.gather(*_to_exec)
594
731
  return responses
@@ -7,7 +7,8 @@ import pydantic
7
7
  from . import util
8
8
 
9
9
  if TYPE_CHECKING:
10
- from api import XUIClient
10
+ from .api import XUIClient
11
+
11
12
 
12
13
  class BaseModel(pydantic.BaseModel):
13
14
  """Base model for all 3X-UI API data models.
@@ -22,13 +23,12 @@ class BaseModel(pydantic.BaseModel):
22
23
  ERROR_RETRIES: ClassVar[int] = 5
23
24
  ERROR_RETRY_COOLDOWN: ClassVar[int] = 1
24
25
 
25
- model_config = pydantic.ConfigDict(ignored_types=(cached_property, ))
26
+ model_config = pydantic.ConfigDict(ignored_types=(cached_property,), validate_by_name=True, validate_by_alias=True)
26
27
 
27
28
  # def model_post_init(self, context: Any, /) -> None:
28
29
  # #print(f"Model {self.__class__}, {self} initialized")
29
30
  # ...
30
31
 
31
-
32
32
  @classmethod
33
33
  def from_list(cls, args: List[Dict[str, Any]],
34
34
  ) -> List[Self]:
@@ -41,7 +41,7 @@ class BaseModel(pydantic.BaseModel):
41
41
  A list of model instances initialized with the provided data.
42
42
 
43
43
  Examples:
44
- inbounds = Inbound.from_list([{"id": 1}, {"id": 2}], client=xui_client)
44
+ inbounds = Inbound.from_list([{"id": 1}, {"id": 2}])
45
45
  """
46
46
  return [cls(**obj) for obj in args]
47
47
 
@@ -50,7 +50,7 @@ class BaseModel(pydantic.BaseModel):
50
50
  cls,
51
51
  response: httpx.Response,
52
52
  client: "XUIClient",
53
- expect: list|dict,
53
+ expect: list | dict,
54
54
  auto_retry: bool = True
55
55
  ) -> Union[Self, List[Self]]:
56
56
  """Create model instance(s) from an HTTP response.
@@ -86,6 +86,9 @@ class BaseModel(pydantic.BaseModel):
86
86
  if expect is dict:
87
87
  return cls(**obj)
88
88
  else:
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)
89
+ if auto_retry:
90
+ req = response.request
91
+ new_resp = await client._safe_request(request_to_send=req)
92
+ return await cls.from_response(new_resp, client=client, expect=expect, auto_retry=False)
93
+ else:
94
+ raise util.DBLockedError("Failed to create model instance from response")
@@ -0,0 +1,27 @@
1
+
2
+ class ClientEmailAlreadyExistsError(Exception):
3
+ """Raised when the panel rejects a new client because its email exists."""
4
+
5
+ def __init__(self, *args):
6
+ if len(args) == 1:
7
+ super().__init__(args[0])
8
+ else:
9
+ super().__init__(*args)
10
+
11
+ class EmailNotExistsError(Exception):
12
+ """Raised when a requested client email cannot be found on the panel."""
13
+
14
+ def __init__(self, *args):
15
+ if len(args) == 1:
16
+ super().__init__(args[0])
17
+ else:
18
+ super().__init__(*args)
19
+
20
+ class ClientDoesNotExistError(Exception):
21
+ """Raised when a requested client UUID is absent from the target inbound."""
22
+
23
+ def __init__(self, *args):
24
+ if len(args) == 1:
25
+ super().__init__(args[0])
26
+ else:
27
+ super().__init__(*args)
@@ -1,17 +1,21 @@
1
1
  import json
2
2
  import logging
3
3
  from datetime import datetime, UTC
4
- from typing import Generic, Literal, List, Dict
4
+ from typing import Generic, Literal, List, Dict, TypeVar, TYPE_CHECKING
5
5
 
6
6
  from httpx import Response
7
- from pydantic import ValidationError
8
- from pydantic.main import ModelT
7
+ from pydantic import ValidationError, BaseModel
9
8
 
10
- from .api import XUIClient
9
+ import pydantic
10
+
11
+ if TYPE_CHECKING:
12
+ from .api import XUIClient
11
13
  from .custom_exceptions import ClientDoesNotExistError
12
14
  from .models import Inbound, SingleInboundClient, ClientStats, InboundClients, timestamp_seconds, ClientsSettings
13
15
  from .util import JsonType
14
16
 
17
+ ModelT = TypeVar("ModelT", bound=BaseModel)
18
+
15
19
 
16
20
  class BaseEndpoint(Generic[ModelT]):
17
21
  """Base class for API endpoint handlers.
@@ -120,7 +124,7 @@ class Inbounds(BaseEndpoint):
120
124
  """
121
125
  _url = "panel/api/inbounds"
122
126
 
123
- async def get_all(self) -> List[Inbound]:
127
+ async def get_all(self) -> list[Inbound]:
124
128
  """Retrieve all inbounds from the server.
125
129
 
126
130
  Returns:
@@ -131,7 +135,7 @@ class Inbounds(BaseEndpoint):
131
135
  inbounds = Inbound.from_list(json_resp)
132
136
  return inbounds
133
137
 
134
- async def get_specific_inbound(self, inbound_id) -> Inbound:
138
+ async def get_specific_inbound(self, inbound_id: int) -> Inbound:
135
139
  """Retrieve a specific inbound by ID.
136
140
 
137
141
  Args:
@@ -159,7 +163,7 @@ class Clients(BaseEndpoint):
159
163
  - /panel/api/inbounds/delDepletedClients/{inbound_id}
160
164
  - /panel/api/inbounds/{inbound_id}/delClient/{email|uuid}
161
165
  """
162
- _url = "panel/api/inbounds/"
166
+ _url = "panel/api/inbounds"
163
167
 
164
168
  #although it's the same url, they should be differentiated
165
169
 
@@ -172,11 +176,11 @@ class Clients(BaseEndpoint):
172
176
  Returns:
173
177
  A ClientStats model instance with the client's statistics.
174
178
  """
175
- endpoint = f"getClientTraffics/{email}"
179
+ endpoint = f"/getClientTraffics/{email}"
176
180
  resp = await self._simple_get(endpoint)
177
181
  return ClientStats.model_validate(resp)
178
182
 
179
- async def get_client_with_uuid(self, uuid: str) -> List[ClientStats]:
183
+ async def get_client_with_uuid(self, uuid: str) -> list[ClientStats]:
180
184
  """Retrieve client statistics by UUID.
181
185
 
182
186
  Args:
@@ -185,7 +189,7 @@ class Clients(BaseEndpoint):
185
189
  Returns:
186
190
  A list of ClientStats model instances matching the UUID.
187
191
  """
188
- endpoint = f"getClientTrafficsById/{uuid}"
192
+ endpoint = f"/getClientTrafficsById/{uuid}"
189
193
  resp = await self._simple_get(endpoint)
190
194
  client_stats = ClientStats.from_list(resp)
191
195
  return client_stats
@@ -209,8 +213,8 @@ class Clients(BaseEndpoint):
209
213
  ValueError: If a single client is provided without an inbound_id.
210
214
  TypeError: If the client type is not supported.
211
215
  """
212
- endpoint = f"addClient"
213
- if isinstance(client, Dict):
216
+ endpoint = f"/addClient"
217
+ if isinstance(client, dict):
214
218
  try:
215
219
  final = InboundClients.model_validate(client)
216
220
  except ValidationError:
@@ -222,6 +226,8 @@ class Clients(BaseEndpoint):
222
226
  else:
223
227
  raise ValueError("A single client was provided to be added but no parent inbound id")
224
228
  elif isinstance(client, SingleInboundClient):
229
+ if not inbound_id:
230
+ raise ValueError("A single client was provided to be added but no parent inbound id")
225
231
  final = InboundClients(id=inbound_id,
226
232
  settings=ClientsSettings(clients=[client]))
227
233
  elif isinstance(client, InboundClients):
@@ -236,33 +242,31 @@ class Clients(BaseEndpoint):
236
242
  #YOU NEED TO PASS SETTINGS AS A STRING, NOT AS A DICT, YOU IDIOT!
237
243
  return resp
238
244
 
239
- async def _request_update_client(self, client: InboundClients | SingleInboundClient,
240
- inbound_id: int | None = None,
241
- *, original_uuid: str | None = None) -> Response:
245
+ async def request_update_client(self, client: InboundClients | SingleInboundClient,
246
+ inbound_id: int | None = None,
247
+ *, original_uuid: str | None = None) -> Response:
242
248
  """Request to update an existing client.
243
249
 
244
250
  Args:
245
251
  client: The client data to update. Can be:
246
- - A ClientUpdatePayload - Recommended (requires inbound_id)
247
252
  - A SingleInboundClient (requires inbound_id)
248
253
  - An InboundClients object (with one client)
249
254
  inbound_id: The ID of the inbound the client belongs to.
250
- Required if client is a SingleInboundClient or ClientUpdatePayload.
255
+ Required if client is a SingleInboundClient.
251
256
  original_uuid: The original UUID of the client to update.
252
- Required if client is a SingleInboundClient or ClientUpdatePayload.
257
+ Required by the 3X-UI update endpoint.
253
258
 
254
259
  Returns:
255
260
  The HTTP response from the API.
256
261
  """
257
262
  if isinstance(client, SingleInboundClient):
258
263
  client = InboundClients(id=inbound_id, settings=ClientsSettings(clients=[client]))
259
- _endpoint = f"updateClient/{original_uuid if original_uuid else client.settings.clients[0].uuid}"
260
- #we have to do this because if we do model.dump() it will return a Settings **OBJECT** which we DON'T want.
264
+ _endpoint = f"/updateClient/{original_uuid}"
265
+ # we have to do this because if we do model.dump() it will return a Settings **OBJECT** which we DON'T want.
261
266
  resp = await self.client.safe_post(f"{self._url}{_endpoint}",
262
267
  json=json.loads(client.model_dump_json(exclude_none=True, by_alias=True)))
263
268
  return resp
264
269
 
265
-
266
270
  async def update_single_client(self, inbound_id: int, client_uuid: str, *,
267
271
  security: str | None = None,
268
272
  password: str | None = None,
@@ -278,15 +282,15 @@ class Clients(BaseEndpoint):
278
282
  """Update an existing client's details.
279
283
 
280
284
  Args:
281
- client_uuid: The UUID of the original client.
282
285
  inbound_id: The ID of the inbound the client belongs to.
286
+ client_uuid: The UUID of the original client.
283
287
  security: New security settings (optional).
284
288
  password: New password (optional).
285
289
  flow: New flow settings (optional).
286
290
  email: New email address (optional).
287
291
  limit_ip: New IP limit (optional).
288
- limit_gb: New GB limit (optional).
289
- expiry_time: New expiry time (optional).
292
+ limit_gb: New traffic limit in gigabytes (optional).
293
+ expiry_time: New expiry time as a UNIX timestamp in seconds (optional).
290
294
  enable: New enable status (optional).
291
295
  sub_id: New subscription ID (optional).
292
296
  comment: New comment (optional).
@@ -306,8 +310,9 @@ class Clients(BaseEndpoint):
306
310
  raise ClientDoesNotExistError(f"The target inbound was checked but client {client_uuid} was not found.")
307
311
 
308
312
  changes["updated_at"] = int(datetime.now(UTC).timestamp())
313
+ #TODO: see if model_copy actually does validation
309
314
  updated = found_inbound.model_copy(update=changes)
310
- resp = await self._request_update_client(updated, inbound_id)
315
+ resp = await self.request_update_client(updated, inbound_id, original_uuid=client_uuid)
311
316
  return resp
312
317
 
313
318
  async def delete_expired_clients(self, inbound_id: int) -> Response:
@@ -319,7 +324,7 @@ class Clients(BaseEndpoint):
319
324
  Returns:
320
325
  The HTTP response from the API.
321
326
  """
322
- _endpoint = f"delDepletedClients/"
327
+ _endpoint = f"/delDepletedClients/"
323
328
  resp = await self.client.safe_post(f"{self._url}{_endpoint}{inbound_id}")
324
329
  return resp
325
330
 
@@ -333,7 +338,7 @@ class Clients(BaseEndpoint):
333
338
  Returns:
334
339
  The HTTP response from the API.
335
340
  """
336
- _endpoint = f"{inbound_id}/delClientByEmail/{email}"
341
+ _endpoint = f"/{inbound_id}/delClientByEmail/{email}"
337
342
  resp = await self.client.safe_post(f"{self._url}{_endpoint}")
338
343
  return resp
339
344
 
@@ -347,6 +352,6 @@ class Clients(BaseEndpoint):
347
352
  Returns:
348
353
  The HTTP response from the API.
349
354
  """
350
- _endpoint = f"{inbound_id}/delClient/{uuid}"
355
+ _endpoint = f"/{inbound_id}/delClient/{uuid}"
351
356
  resp = await self.client.safe_post(f"{self._url}{_endpoint}")
352
357
  return resp
@@ -2,7 +2,6 @@ import json
2
2
  from datetime import datetime, UTC
3
3
  from typing import Union, TypeAlias, Any, Annotated, Literal, List, Dict, ClassVar
4
4
 
5
- import pydantic
6
5
  from pydantic import field_validator, Field, field_serializer
7
6
  from typing_extensions import TypeVar
8
7
 
@@ -31,7 +30,8 @@ def exclude_if_none(field) -> bool:
31
30
  _IntNone = TypeVar("_IntNone", bound=int | None)
32
31
 
33
32
 
34
- class SingleInboundClient(pydantic.BaseModel):
33
+ # noinspection PyNestedDecorators
34
+ class SingleInboundClient(base_model.BaseModel):
35
35
  """Represents a single client within a VLESS/VMess inbound.
36
36
 
37
37
  This model represents an individual VPN client with all its configuration
@@ -58,10 +58,11 @@ class SingleInboundClient(pydantic.BaseModel):
58
58
  security: str = ""
59
59
  password: str = ""
60
60
  flow: Literal["", "xtls-rprx-vision", "xtls-rprx-vision-udp443"]
61
- email: Annotated[str, Field(alias="email")]
61
+ email: str
62
62
  limit_ip: Annotated[int, Field(alias="limitIp")] = 20
63
63
  reset: int = 0
64
64
  #Interestingly, the API expects this value to be called GB but it's actually bytes.
65
+ # I want the pythonic side to be in GB (hence why floats, i.e. 2.5GB), but the API expects bytes.
65
66
  limit_gb: Annotated[int, Field(alias="totalGB")] = 0 # total flow
66
67
  expiry_time: Annotated[timestamp_seconds, Field(alias="expiryTime")] = 0
67
68
  enable: bool = True
@@ -75,26 +76,30 @@ class SingleInboundClient(pydantic.BaseModel):
75
76
  default_factory=(lambda: int(datetime.now(UTC).timestamp())))
76
77
  ]
77
78
 
78
- # noinspection PyNestedDecorators
79
79
  @field_validator(TIME_FIELDS[0], *TIME_FIELDS[1:], mode="after")
80
80
  @classmethod
81
81
  def ensure_s_timestamp(cls, value: _IntNone) -> _IntNone:
82
82
  return auto_ms_to_s_timestamp(value)
83
83
 
84
- # noinspection PyNestedDecorators
85
84
  @field_serializer(TIME_FIELDS[0], *TIME_FIELDS[1:])
86
85
  @classmethod
87
- def serialize_ms_timestamp(cls, value: _IntNone) -> _IntNone:
88
- return auto_s_to_ms_timestamp(value) if value is not None else None
86
+ def serialize_ms_timestamp(cls, value: int) -> int:
87
+ return auto_s_to_ms_timestamp(value)
89
88
 
90
- # noinspection PyNestedDecorators
91
89
  @field_serializer("limit_gb")
92
90
  @classmethod
93
- def serialize_total_gb(cls, value: _IntNone) -> _IntNone:
94
- return value * (1024 ** 3) if value is not None else None
91
+ def serialize_total_gb(cls, value: int) -> int:
92
+ #API expects an integer of bytes.
93
+ return value * (1024 ** 3)
95
94
 
95
+ @field_validator("limit_gb", mode="after")
96
+ @classmethod
97
+ def parse_total_gb(cls, value: int) -> int:
98
+ #Python wants an int/float of GB.
99
+ return value // (1024 ** 3)
96
100
 
97
- class ClientsSettings(pydantic.BaseModel):
101
+
102
+ class ClientsSettings(base_model.BaseModel):
98
103
  """Settings container for inbound clients.
99
104
 
100
105
  Attributes:
@@ -103,10 +108,10 @@ class ClientsSettings(pydantic.BaseModel):
103
108
  clients: list[SingleInboundClient]
104
109
  decryption: Annotated[str, Field(exclude_if=lambda x: x == "none")] = "none"
105
110
  encryption: Annotated[str, Field(exclude_if=lambda x: x == "none")] = "none"
106
- fallbacks: Annotated[list|None, Field(exclude_if=exclude_if_none)] = None
111
+ fallbacks: Annotated[list | None, Field(exclude_if=exclude_if_none)] = None
107
112
 
108
113
 
109
- class InboundClients(pydantic.BaseModel):
114
+ class InboundClients(base_model.BaseModel):
110
115
  """Represents a collection of clients for an inbound connection.
111
116
 
112
117
  This model is used when adding or updating clients on an inbound,
@@ -187,6 +192,7 @@ class InboundClients(pydantic.BaseModel):
187
192
  # external_proxy: Annotated[list[ExternalProxy], Field(alias="externalProxy")]
188
193
  # tcp_settings: TCPSettings
189
194
 
195
+ # noinspection PyNestedDecorators
190
196
  class ClientStats(base_model.BaseModel):
191
197
  """Statistics and configuration for a VPN client.
192
198
 
@@ -203,10 +209,12 @@ class ClientStats(base_model.BaseModel):
203
209
  up: Total uploaded bytes.
204
210
  down: Total downloaded bytes.
205
211
  allTime: Total bytes transferred (up + down).
206
- expiryTime: Client expiry time as UNIX timestamp in MILLISECONDS.
212
+ expiryTime: Client expiry time as a UNIX timestamp in seconds on the
213
+ Python model, serialized to milliseconds for the API.
207
214
  total: Total data limit in bytes.
208
215
  reset: Counter for traffic resets.
209
- lastOnline: UNIX timestamp of last connection.
216
+ lastOnline: UNIX timestamp of last connection in seconds on the
217
+ Python model, serialized to milliseconds for the API.
210
218
  """
211
219
  TIME_FIELDS: ClassVar[List[str]] = ["expiryTime", "lastOnline"]
212
220
  id: int
@@ -223,17 +231,18 @@ class ClientStats(base_model.BaseModel):
223
231
  reset: int
224
232
  lastOnline: timestamp_seconds
225
233
 
226
- @classmethod
227
234
  @field_validator(TIME_FIELDS[0], *TIME_FIELDS[1:], mode="after")
235
+ @classmethod
228
236
  def ensure_s_timestamp(cls, value: int) -> int:
229
237
  return auto_ms_to_s_timestamp(value)
230
238
 
231
- @classmethod
232
239
  @field_serializer(TIME_FIELDS[0], *TIME_FIELDS[1:])
240
+ @classmethod
233
241
  def serialize_ms_timestamp(cls, value: int) -> int:
234
242
  return auto_s_to_ms_timestamp(value)
235
243
 
236
244
 
245
+ # noinspection PyNestedDecorators
237
246
  class Inbound(base_model.BaseModel):
238
247
  """Represents a VPN inbound connection configuration.
239
248
 
@@ -274,7 +283,8 @@ class Inbound(base_model.BaseModel):
274
283
  clientStats: list[ClientStats] | None
275
284
  listen: str
276
285
  port: int
277
- protocol: Literal["vless", "vmess", "trojan", "shadowsocks", "wireguard"] # note: there are some "deprecated" like wireguard
286
+ #TODO: add trojan, shadowsocks, wireguard back in when they are supported by the API
287
+ protocol: Literal["vless", "vmess"] #"trojan", "shadowsocks", "wireguard"] # note: there are some "deprecated" like wireguard
278
288
  settings: ClientsSettings # JSON packed value, stringified
279
289
  streamSettings: Union[json_string, Dict[Any, Any]] # JSON packed value, stringified
280
290
  tag: str
@@ -285,7 +295,7 @@ class Inbound(base_model.BaseModel):
285
295
  def parse_settings(cls, value: str) -> ClientsSettings:
286
296
  if value == "":
287
297
  return ClientsSettings(clients=[])
288
- return ClientsSettings.model_validate_json(value, by_alias=True, extra="forbid")
298
+ return ClientsSettings.model_validate_json(value, by_alias=True, extra="ignore")
289
299
 
290
300
  @field_serializer("settings")
291
301
  @classmethod
@@ -315,12 +325,12 @@ class Inbound(base_model.BaseModel):
315
325
  return ""
316
326
  return json.dumps(value, ensure_ascii=False)
317
327
 
318
- @classmethod
319
328
  @field_validator(TIME_FIELDS[0], *TIME_FIELDS[1:], mode="after")
329
+ @classmethod
320
330
  def ensure_s_timestamp(cls, value: int) -> int:
321
331
  return auto_ms_to_s_timestamp(value)
322
332
 
323
- @classmethod
324
333
  @field_serializer(TIME_FIELDS[0], *TIME_FIELDS[1:])
334
+ @classmethod
325
335
  def serialize_ms_timestamp(cls, value: int) -> int:
326
336
  return auto_s_to_ms_timestamp(value)
@@ -1,12 +1,14 @@
1
- """Utility functions and helpers for the VPNAPIHandler.
1
+ """Utility functions and helpers for the python_3xui package.
2
2
 
3
- This module provides common utilities used across the API handler including:
3
+ This module provides common utilities used across the 3X-UI API wrapper,
4
+ including:
4
5
  - String conversion helpers (camelCase to snake_case)
5
6
  - Async generators
6
7
  - Base64 encoding utilities
7
8
  - Telegram ID-based UUID/email generation
8
9
  - Response validation
9
10
  """
11
+ from __future__ import annotations
10
12
 
11
13
  import asyncio
12
14
  import base64
@@ -14,10 +16,13 @@ import logging
14
16
  import random
15
17
  import re
16
18
  from datetime import UTC, datetime, tzinfo
17
- from typing import TypeAlias, Union, Dict, Any, List
19
+ from typing import TYPE_CHECKING, TypeAlias, Union, Dict, Any, List
18
20
 
19
21
  import httpx
20
22
 
23
+ if TYPE_CHECKING:
24
+ from python_3xui.models import Inbound, SingleInboundClient
25
+
21
26
  JsonType: TypeAlias = Union[Dict[Any, Any], List[Any]]
22
27
 
23
28
  _RE_CAMEL_TO_SNAKE1 = re.compile("(.)([A-Z][a-z]+)")
@@ -57,13 +62,12 @@ async def async_range(start: int, stop: int|None=None, step: int=1):
57
62
  Yields:
58
63
  int: The next value in the range sequence.
59
64
  """
60
- if stop:
65
+ if stop is not None:
61
66
  range_ = range(start, stop, step)
62
67
  else:
63
68
  range_ = range(start)
64
69
  for i in range_:
65
70
  yield i
66
- await asyncio.sleep(0)
67
71
 
68
72
 
69
73
  def base64_from_string(string: str, omit_trailing_equals: bool = False) -> str:
@@ -71,10 +75,12 @@ def base64_from_string(string: str, omit_trailing_equals: bool = False) -> str:
71
75
 
72
76
  Args:
73
77
  string: The input string to encode.
74
- omit_trailing_equals: If True, removes trailing '=' padding characters.
78
+ omit_trailing_equals: Reserved for callers that do not want trailing
79
+ ``=`` padding. The current implementation returns standard padded
80
+ base64 output.
75
81
 
76
82
  Returns:
77
- The base64 encoded string.
83
+ The base64-encoded string.
78
84
  """
79
85
  return base64.b64encode(bytes(str(string).encode("utf-8"))).decode()
80
86
 
@@ -124,7 +130,15 @@ def get_uuid_from_tgid(telegram_id: int, fixed: bool = True) -> str:
124
130
  return f"{now.year}{mon}{day}-{hr}{mn}-1111-1111-{resid}"
125
131
 
126
132
 
127
- def random_string(length: int):
133
+ def random_string(length: int) -> str:
134
+ """Generate a random alphanumeric string.
135
+
136
+ Args:
137
+ length: Number of characters to generate.
138
+
139
+ Returns:
140
+ A string made from ASCII letters and digits.
141
+ """
128
142
  s = "".join([random.choice(
129
143
  "1234567890abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ") for _ in range(length)
130
144
  ])
@@ -145,6 +159,22 @@ def generate_random_email(length: int = 8) -> str:
145
159
  return random_string(length)
146
160
 
147
161
 
162
+ def get_inbound_in_client(client_uuid: str, inbound: Inbound) -> SingleInboundClient|None:
163
+ """Find a client inside an inbound by UUID.
164
+
165
+ Args:
166
+ client_uuid: UUID of the client to find.
167
+ inbound: Inbound model whose client list should be searched.
168
+
169
+ Returns:
170
+ The matching client, or None if the inbound does not contain it.
171
+ """
172
+ for client in inbound.settings.clients:
173
+ if client.uuid == client_uuid:
174
+ return client
175
+ return None
176
+
177
+
148
178
  def generate_email_from_tgid_inbid(telegram_id: int, /, inbound_id: int) -> str:
149
179
  """Generate a deterministic email from Telegram ID and inbound ID.
150
180
 
@@ -165,7 +195,7 @@ def generate_email_from_tgid_inbid(telegram_id: int, /, inbound_id: int) -> str:
165
195
  return f"TG{telegram_id}IB{inbound_id}"
166
196
 
167
197
 
168
- def generate_new_subscription(length: int = 16):
198
+ def generate_new_subscription(length: int = 16) -> str:
169
199
  """Generate a random subscription ID.
170
200
 
171
201
  Args:
@@ -180,7 +210,7 @@ def generate_new_subscription(length: int = 16):
180
210
  return random_string(length)
181
211
 
182
212
 
183
- async def check_xui_response(response: JsonType | httpx.Response) -> str:
213
+ async def check_xui_response(response: dict | httpx.Response) -> str:
184
214
  """Validate a 3X-UI API response.
185
215
 
186
216
  Checks if the response follows the expected 3X-UI API format with
@@ -209,7 +239,7 @@ async def check_xui_response(response: JsonType | httpx.Response) -> str:
209
239
  json_resp = response.json()
210
240
  else:
211
241
  json_resp = response
212
-
242
+
213
243
  if len(json_resp) == 3:
214
244
  if tuple(json_resp.keys()) == ("success", "msg", "obj"):
215
245
  success: bool = json_resp["success"]
@@ -285,4 +315,4 @@ def auto_ms_to_s_timestamp(ms_or_s: int) -> int:
285
315
 
286
316
  def datetime_now_ms(tzinfo: tzinfo|None=UTC) -> int:
287
317
  """Get the current time as a UNIX timestamp in milliseconds."""
288
- return int(datetime.now(tzinfo).timestamp()) * 1000
318
+ return int(datetime.now(tzinfo).timestamp() * 1000)
@@ -62,5 +62,5 @@ async def xui_client() -> XUIClient:
62
62
  try:
63
63
  await client.disconnect()
64
64
  except Exception:
65
- pass
65
+ raise
66
66
 
@@ -33,7 +33,7 @@ class TestClientsEndpoint:
33
33
  # Try to find a suitable inbound (preferably with PROD_STRING in remark)
34
34
  test_inbound = None
35
35
  for inbound in all_inbounds:
36
- if xui_client.PROD_STRING.search(inbound.remark.lower()):
36
+ if xui_client.PROD_STRING.search(inbound.remark):
37
37
  test_inbound = inbound
38
38
  break
39
39
 
@@ -129,7 +129,7 @@ class TestXUIClientHelpers:
129
129
  before = await xui_client.clients_end.get_client_with_email(email)
130
130
  assert before.enable is True, "Newly created client should start enabled"
131
131
 
132
- resp = await xui_client.update_client_by_tgid(
132
+ resp = await xui_client.update_client_by_tgid_inbid(
133
133
  _TGID_UPDATE, target_inbound.id, verbose=False, sub_id=_TEST_SUB_ID,
134
134
  )
135
135
  assert resp.status_code == 200
@@ -1,16 +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.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,7 +0,0 @@
1
- from .api import XUIClient
2
-
3
- __author__ = "JustMe_001"
4
- __version__ = "0.0.9"
5
- __email__ = ""
6
-
7
-
@@ -1,12 +0,0 @@
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)
File without changes