Python-3xui 0.0.9__tar.gz → 0.0.9.post2__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 (22) hide show
  1. {python_3xui-0.0.9 → python_3xui-0.0.9.post2}/PKG-INFO +1 -2
  2. {python_3xui-0.0.9 → python_3xui-0.0.9.post2}/pyproject.toml +1 -2
  3. python_3xui-0.0.9.post2/python_3xui/__init__.py +9 -0
  4. {python_3xui-0.0.9 → python_3xui-0.0.9.post2}/python_3xui/api.py +206 -81
  5. {python_3xui-0.0.9 → python_3xui-0.0.9.post2}/python_3xui/base_model.py +9 -6
  6. python_3xui-0.0.9.post2/python_3xui/custom_exceptions.py +27 -0
  7. {python_3xui-0.0.9 → python_3xui-0.0.9.post2}/python_3xui/endpoints.py +33 -28
  8. {python_3xui-0.0.9 → python_3xui-0.0.9.post2}/python_3xui/models.py +31 -21
  9. {python_3xui-0.0.9 → python_3xui-0.0.9.post2}/python_3xui/util.py +41 -12
  10. {python_3xui-0.0.9 → python_3xui-0.0.9.post2}/tests/conftest.py +1 -1
  11. {python_3xui-0.0.9 → python_3xui-0.0.9.post2}/tests/test_non_idempotent_endpoints_clients.py +1 -1
  12. {python_3xui-0.0.9 → python_3xui-0.0.9.post2}/tests/test_xuiclient_helpers.py +1 -1
  13. python_3xui-0.0.9/python_3xui/__init__.py +0 -7
  14. python_3xui-0.0.9/python_3xui/custom_exceptions.py +0 -12
  15. {python_3xui-0.0.9 → python_3xui-0.0.9.post2}/.gitignore +0 -0
  16. {python_3xui-0.0.9 → python_3xui-0.0.9.post2}/LICENSE +0 -0
  17. {python_3xui-0.0.9 → python_3xui-0.0.9.post2}/README.md +0 -0
  18. {python_3xui-0.0.9 → python_3xui-0.0.9.post2}/tests/gather_response_stubs.py +0 -0
  19. {python_3xui-0.0.9 → python_3xui-0.0.9.post2}/tests/pytest.ini +0 -0
  20. {python_3xui-0.0.9 → python_3xui-0.0.9.post2}/tests/test_endpoints_clients.py +0 -0
  21. {python_3xui-0.0.9 → python_3xui-0.0.9.post2}/tests/test_endpoints_inbounds.py +0 -0
  22. {python_3xui-0.0.9 → python_3xui-0.0.9.post2}/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.post2
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'
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "Python-3xui"
3
- version = "0.0.9"
3
+ version = "0.0.9r2"
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,9 @@
1
+ """Public package interface for the python_3xui API wrapper."""
2
+
3
+ from .api import XUIClient
4
+
5
+ __author__ = "JustMe_001"
6
+ __version__ = "0.0.9r2"
7
+ __email__ = ""
8
+
9
+
@@ -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,8 @@ 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,
72
79
  ) -> None:
73
80
  """Initialize the XUIClient.
74
81
 
@@ -78,10 +85,15 @@ class XUIClient:
78
85
  base_path: The base path for the API (e.g., "/panel").
79
86
  username: Username for authentication.
80
87
  password: Password for authentication.
81
- two_fac_code: Two-factor authentication code (if enabled).
88
+ two_fac_code: TOTP secret for 2FA. Short one-shot codes are
89
+ accepted for the current login only.
82
90
  session_duration: Maximum session duration in seconds. Defaults to 3600.
91
+ custom_prod_string: Regex pattern used to select production inbounds.
92
+ max_retries: Maximum retries for database-lock responses.
93
+ retry_delay: Seconds to wait between database-lock retries.
94
+ custom_sub_generator: Sync or async callable that receives a
95
+ Telegram ID and returns the subscription ID for new clients.
83
96
  """
84
- from . import endpoints # look, I know it's bad, but we need to evade cyclical imports
85
97
  self.connected: bool = False
86
98
  self.PROD_STRING = re.compile(custom_prod_string)
87
99
  self.session: AsyncClient | None = None
@@ -107,7 +119,7 @@ class XUIClient:
107
119
  # a new XUIClient on a fresh loop (e.g. each pytest-asyncio test). Building the wrapper here gives every
108
120
  # instance its own cache bound to its own loop.
109
121
  self.get_production_inbounds = alru_cache(maxsize=128)(self._get_production_inbounds_impl)
110
- self._cache_cleaner_task: Task|None = None
122
+ self._cache_cleaner_task: Task | None = None
111
123
  #init self.totp
112
124
  if self.two_fac_secret:
113
125
  if len(self.two_fac_secret.get_secret_value()) <= 8:
@@ -129,33 +141,52 @@ class XUIClient:
129
141
  ...
130
142
 
131
143
  async def _safe_request(self,
132
- method: Literal["get", "post", "patch", "delete", "put"]|None=None,
144
+ method: Literal["get", "post", "patch", "delete", "put"] | None = None,
133
145
  **kwargs) -> Response:
134
146
  """Execute an HTTP request with automatic retry on database lock.
135
147
 
136
- This method handles automatic session refresh and retries when
137
- the 3X-UI database is locked.
148
+ The request can be made either from a prebuilt ``request_to_send`` or
149
+ from an HTTP method plus keyword arguments accepted by ``httpx``.
150
+ The method handles automatic session refresh on expired 404 responses
151
+ and retries when the 3X-UI database is locked.
138
152
 
139
153
  Args:
140
- method: The HTTP method to use.
141
- **kwargs: Additional arguments passed to the HTTP request.
154
+ method: The HTTP method to use when building a new request.
155
+ **kwargs: Either ``request_to_send`` by itself, or request
156
+ arguments such as ``url``, ``json``, ``params``, and headers.
142
157
 
143
158
  Returns:
144
159
  The HTTP response.
145
160
 
146
161
  Raises:
147
- RuntimeError: If max retries exceeded or session is invalid.
162
+ ValueError: If neither a method nor a prebuilt request is provided,
163
+ or both request styles are mixed.
164
+ RuntimeError: If max retries are exceeded or a valid session gets
165
+ an unexpected 404 response.
148
166
  """
149
167
  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.")
168
+ raise ValueError("Provide either a prebuilt request or arguments to build one.")
151
169
  if not "request_to_send" in kwargs:
152
170
  if method is None:
153
171
  raise ValueError("If there's no prebuilt request, you must provide a method.")
154
172
 
155
- #FIXME: make it also extract JSON out of a ready request
173
+ url = kwargs["url"] if "url" in kwargs.keys() else kwargs["request_to_send"].url
174
+ if "json" in kwargs:
175
+ json_payload = kwargs["json"]
176
+ elif "request_to_send" in kwargs:
177
+ _req = kwargs["request_to_send"]
178
+ if _req.content:
179
+ try:
180
+ json_payload = json.loads(_req.content.decode())
181
+ except (json.JSONDecodeError, UnicodeDecodeError):
182
+ json_payload = None
183
+ else:
184
+ json_payload = None
185
+ else:
186
+ json_payload = None
156
187
  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)")
188
+ method, str(self.session.base_url), str(url),
189
+ json.dumps(json_payload) if json_payload is not None else "(no payload)")
159
190
  async for attempt in async_range(self.max_retries):
160
191
  if "request_to_send" in kwargs:
161
192
  _request: Request = kwargs["request_to_send"]
@@ -295,11 +326,12 @@ class XUIClient:
295
326
  resp = await self.session.post("/login", data=payload)
296
327
  if resp.status_code == 200:
297
328
  resp_json = resp.json()
298
- if resp_json["success"]:
299
- self.session_start: float = (datetime.now(UTC).timestamp())
300
- return
301
- else:
329
+ if "success" not in resp_json:
330
+ raise RuntimeError(f"Error: server returned a status code of {resp.status_code} but the response is not valid: {resp_json}")
331
+ if not resp_json["success"]:
302
332
  raise ValueError("Error: wrong credentials (including status code) or failed login.")
333
+ self.session_start: float = (datetime.now(UTC).timestamp())
334
+ return
303
335
  else:
304
336
  raise RuntimeError(f"Error: server returned a status code of {resp.status_code}")
305
337
 
@@ -322,7 +354,8 @@ class XUIClient:
322
354
  This method closes the async HTTP client session.
323
355
  """
324
356
  if self._cache_cleaner_task is not None:
325
- self._cache_cleaner_task.cancel("Panel is exiting.")
357
+ with contextlib.suppress(asyncio.CancelledError):
358
+ self._cache_cleaner_task.cancel("Panel is exiting.")
326
359
  self.connected = False
327
360
 
328
361
  if self.session is not None:
@@ -341,7 +374,7 @@ class XUIClient:
341
374
  self.connect()
342
375
  await self.login()
343
376
  self._cache_cleaner_task = asyncio.create_task(
344
- self.clear_prod_inbound_cache(), name=f"inb_cache_clearer_for_{self.base_url}"
377
+ self._clear_prod_inbound_cache_task(), name=f"inb_cache_clearer_for_{self.base_url}"
345
378
  )
346
379
  return self
347
380
 
@@ -356,7 +389,7 @@ class XUIClient:
356
389
  exc_val: The exception value, if an exception occurred.
357
390
  exc_tb: The exception traceback, if an exception occurred.
358
391
  """
359
- if exc_type is None or exc_type == asyncio.exceptions.CancelledError:
392
+ if exc_type is None or exc_type is asyncio.exceptions.CancelledError:
360
393
  logging.info("Client is disconnecting at time with IP/Domain %s", self.base_host)
361
394
  else:
362
395
  logging.warning("Client is disconnecting due to an error (may be unrelated):"
@@ -391,24 +424,23 @@ class XUIClient:
391
424
 
392
425
  return tuple(usable_inbounds)
393
426
 
394
- async def clear_prod_inbound_cache(self):
395
- """Clear the production inbound cache.
427
+ async def _clear_prod_inbound_cache_task(self):
428
+ """Refresh the production inbound cache in the background.
396
429
 
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.
400
-
401
- Note:
402
- This method currently runs every 10 seconds. Please change the
403
- timer from 5 to 60*60*24 in the code.
430
+ The async context manager starts this loop after login. Each cycle
431
+ clears the cached production inbound list, repopulates it from the
432
+ panel, and then waits before refreshing again.
404
433
  """
434
+ if self._cache_cleaner_task is not None:
435
+ logging.warning("You're trying to create another cache cleaner task, which is a FaF (Fire-And-Forget)."
436
+ "Please destroy the previous task and set _cache_cleaner_task to None, if you know what you're doing.")
405
437
  while self.connected:
406
438
  self.get_production_inbounds.cache_clear()
407
- await self.get_production_inbounds() #fill the cache
408
- await asyncio.sleep(3600) #update every 1h
439
+ await self.get_production_inbounds() # fill the cache
440
+ await asyncio.sleep(3600) # update every 1h
409
441
 
410
442
  #========================clients management========================
411
- async def get_client_with_tgid(self, tgid: int, inbound_id: int | None = None) -> List[ClientStats]:
443
+ async def get_client_with_tgid(self, tgid: int, inbound_id: int | None = None) -> list[ClientStats]:
412
444
  """Retrieve client information by Telegram ID.
413
445
 
414
446
  This method fetches client information using the Telegram ID. If
@@ -436,25 +468,31 @@ class XUIClient:
436
468
 
437
469
  async def create_and_add_prod_client(self, telegram_id: int, *,
438
470
  additional_remark: str | None = None,
439
- expiry_time: int=0,
471
+ expiry_time: int = 0,
440
472
  exist_ok: bool = False
441
473
  ) -> list[Response]:
442
474
  """Create and add a production client.
443
475
 
444
476
  This method creates a new client with the given Telegram ID and
445
477
  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.
478
+ default settings and the additional remark. The subscription ID is
479
+ created by ``self.sub_gen``; by default this is
480
+ ``util.default_sub_from_tgid``.
448
481
 
449
482
  Args:
450
483
  telegram_id: The Telegram ID of the client.
451
484
  additional_remark: An optional additional remark for the client.
452
485
  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)
486
+ exist_ok: If True, return API responses even when the panel reports
487
+ a duplicate email.
454
488
 
455
489
  Returns:
456
490
  List[Response]: A list of responses from the server for each
457
491
  inbound the client was added to.
492
+
493
+ Raises:
494
+ ClientEmailAlreadyExistsError: If a duplicate client is reported
495
+ and ``exist_ok`` is False.
458
496
  """
459
497
  production_inbounds: tuple[Inbound, ...] = await self.get_production_inbounds()
460
498
 
@@ -466,15 +504,15 @@ class XUIClient:
466
504
  custom_sub = self.sub_gen(telegram_id)
467
505
  for inb in production_inbounds:
468
506
  tmp_email = util.generate_email_from_tgid_inbid(telegram_id, inb.id)
469
- client = SingleInboundClient.model_construct(
507
+ client = SingleInboundClient(
470
508
  uuid=util.get_uuid_from_tgid(telegram_id),
471
509
  flow="",
472
510
  email=tmp_email,
473
511
  limit_gb=0,
474
512
  enable=True,
475
513
  subscription_id=custom_sub,
476
- comment=f"{additional_remark}, created at {datetime.now(UTC)}",
477
- expiry_time=expiry_time * 1000
514
+ comment=f"{additional_remark + ", " if additional_remark else ""}created at {datetime.now(UTC)}",
515
+ expiry_time=expiry_time * 1000,
478
516
  )
479
517
  tasks.append(asyncio.create_task(self.clients_end.add_client(client, inb.id)))
480
518
  responses: list[Response] = await asyncio.gather(*tasks)
@@ -487,19 +525,28 @@ class XUIClient:
487
525
  raise custom_exceptions.ClientEmailAlreadyExistsError(json_resp["msg"])
488
526
  return responses
489
527
 
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
528
+ async def _find_client_in_inbound(self, client_uuid: str, inbound_id: int, use_cache=False) -> SingleInboundClient | None:
529
+ """Note:
530
+ Cached production inbounds can be stale because the panel may be
531
+ changed by another actor. If a cached production inbound misses the
532
+ client, the production cache is cleared and fetched once more
533
+ before falling back to a direct inbound lookup.
534
+ """
535
+ if use_cache:
536
+ prod_inbs = await self.get_production_inbounds()
537
+ prod_inb_index = None
538
+ for i, prod_inb in enumerate(prod_inbs): # see if inbound is production
539
+ if inbound_id == prod_inb.id:
540
+ prod_inb_index = i
541
+
542
+ if prod_inb_index is not None:
543
+ needed_inb: Inbound = prod_inbs[prod_inb_index]
544
+ result = get_inbound_in_client(client_uuid, needed_inb)
545
+ if result is None:
546
+ self.get_production_inbounds.cache_clear() # this means client is in a prod inbound but it's not refreshed
547
+ new_inb = (await self.get_production_inbounds())[prod_inb_index]
548
+ new_result = get_inbound_in_client(client_uuid, new_inb)
549
+ return new_result
503
550
 
504
551
  inb = await self.inbounds_end.get_specific_inbound(inbound_id)
505
552
  for client in inb.settings.clients:
@@ -507,19 +554,97 @@ class XUIClient:
507
554
  return client
508
555
  return None
509
556
 
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:
557
+ async def update_client_by_tgid_only(self, telegram_id: int, prod_only: bool, /, *,
558
+ security: str | None = None,
559
+ password: str | None = None,
560
+ flow: Literal["", "xtls-rprx-vision", "xtls-rprx-vision-udp443"] | None = None,
561
+ limit_ip: int | None = None,
562
+ limit_gb: int | None = None,
563
+ expiry_time: int | None = None,
564
+ enable: bool | None = None,
565
+ sub_id: str | None = None,
566
+ comment: str | None = None,
567
+ verbose: bool = True
568
+ ) -> list[Response]:
569
+ """Update every matching client found by Telegram ID.
570
+
571
+ The client UUID is derived from ``telegram_id`` and searched across
572
+ either production inbounds or all inbounds. Only keyword arguments with
573
+ non-None values are applied to the client model before sending update
574
+ requests.
575
+
576
+ Args:
577
+ telegram_id: Telegram ID used to derive the client UUID.
578
+ prod_only: If True, search only production inbounds. If False,
579
+ search every inbound returned by the panel.
580
+ security: New security setting.
581
+ password: New password.
582
+ flow: New VLESS flow value.
583
+ limit_ip: New simultaneous IP connection limit.
584
+ limit_gb: New traffic limit in gigabytes.
585
+ expiry_time: New expiry timestamp in seconds.
586
+ enable: New enabled state.
587
+ sub_id: New subscription ID.
588
+ comment: New client comment.
589
+ verbose: If True, warn when ``expiry_time`` looks like a duration
590
+ instead of a UNIX timestamp.
591
+
592
+ Returns:
593
+ Responses from each inbound where a matching client was updated.
594
+ """
595
+ updates = {
596
+ "security": security,
597
+ "password": password,
598
+ "flow": flow,
599
+ "limit_ip": limit_ip,
600
+ "limit_gb": limit_gb,
601
+ "expiry_time": expiry_time,
602
+ "enable": enable,
603
+ "sub_id": sub_id,
604
+ "comment": comment,
605
+ }
606
+ # remove None values
607
+ updates = {k: v for k, v in updates.items() if v is not None}
608
+
609
+ if verbose:
610
+ if expiry_time and expiry_time < 1e9:
611
+ logging.warning("Warning: You're trying to update a client with expiry time %s. "
612
+ "You set it to expire before 2001, likely because you provided the DURATION. "
613
+ "You need to provide a TIMESTAMP. "
614
+ "If you want to disable this message, set verbose=false.",
615
+ expiry_time)
616
+
617
+ _to_exec: list[Task] = []
618
+ if prod_only:
619
+ self.get_production_inbounds.cache_clear()
620
+ inbounds = await self.get_production_inbounds()
621
+ else:
622
+ inbounds = await self.inbounds_end.get_all()
623
+ for inbound in inbounds:
624
+ found_client = util.get_inbound_in_client(util.get_uuid_from_tgid(telegram_id), inbound)
625
+ if found_client:
626
+ new_client = found_client.model_copy(update=updates, deep=True)
627
+ _to_exec.append(
628
+ asyncio.create_task(self.clients_end.request_update_client(
629
+ new_client, inbound.id, original_uuid=util.get_uuid_from_tgid(telegram_id)
630
+ ))
631
+ )
632
+ responses = await asyncio.gather(*_to_exec)
633
+ return responses
634
+
635
+ async def update_client_by_tgid_inbid(self, telegram_id: int, inbound_id: int, /, *,
636
+ security: str | None = None,
637
+ password: str | None = None,
638
+ flow: Literal["", "xtls-rprx-vision", "xtls-rprx-vision-udp443"] | None = None,
639
+ limit_ip: int | None = None,
640
+ limit_gb: int | None = None,
641
+ expiry_time: int | None = None,
642
+ enable: bool | None = None,
643
+ sub_id: str | None = None,
644
+ comment: str | None = None,
645
+ verbose: bool = True) -> Response:
521
646
  """
522
- Update a client in a specific inbound by Telegram ID.
647
+ Update a client in a specific inbound by Telegram ID. NOT optimized for multiple inbounds.
523
648
 
524
649
  Args:
525
650
  telegram_id: The Telegram ID of the client
@@ -583,12 +708,12 @@ class XUIClient:
583
708
  List of Response objects from each deletion attempt
584
709
  """
585
710
  production_inbounds = await self.get_production_inbounds()
586
- responses = []
587
-
711
+ _to_exec: list[Task] = []
588
712
  for inbound in production_inbounds:
589
713
  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
-
714
+ _to_exec.append(
715
+ asyncio.create_task(self.clients_end.delete_client_by_email(email, inbound.id))
716
+ )
717
+ logging.info("Clients of of tgid %s pending deletion", telegram_id)
718
+ responses = await asyncio.gather(*_to_exec)
594
719
  return responses
@@ -7,7 +7,7 @@ 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
  class BaseModel(pydantic.BaseModel):
13
13
  """Base model for all 3X-UI API data models.
@@ -22,7 +22,7 @@ class BaseModel(pydantic.BaseModel):
22
22
  ERROR_RETRIES: ClassVar[int] = 5
23
23
  ERROR_RETRY_COOLDOWN: ClassVar[int] = 1
24
24
 
25
- model_config = pydantic.ConfigDict(ignored_types=(cached_property, ))
25
+ model_config = pydantic.ConfigDict(ignored_types=(cached_property, ), validate_by_name=True, validate_by_alias=True)
26
26
 
27
27
  # def model_post_init(self, context: Any, /) -> None:
28
28
  # #print(f"Model {self.__class__}, {self} initialized")
@@ -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
 
@@ -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,11 +58,12 @@ 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
- limit_gb: Annotated[int, Field(alias="totalGB")] = 0 # total flow
65
+ # I want the pythonic side to be in GB (hence why floats, i.e. 2.5GB), but the API expects bytes.
66
+ limit_gb: Annotated[int | float, Field(alias="totalGB")] = 0 # total flow
66
67
  expiry_time: Annotated[timestamp_seconds, Field(alias="expiryTime")] = 0
67
68
  enable: bool = True
68
69
  tg_id: Annotated[Union[int, str], Field(alias="tgId")] = ""
@@ -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 | float) -> 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 | float:
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
@@ -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,6 +1,7 @@
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
@@ -14,10 +15,13 @@ import logging
14
15
  import random
15
16
  import re
16
17
  from datetime import UTC, datetime, tzinfo
17
- from typing import TypeAlias, Union, Dict, Any, List
18
+ from typing import TYPE_CHECKING, TypeAlias, Union, Dict, Any, List
18
19
 
19
20
  import httpx
20
21
 
22
+ if TYPE_CHECKING:
23
+ from python_3xui.models import Inbound, SingleInboundClient
24
+
21
25
  JsonType: TypeAlias = Union[Dict[Any, Any], List[Any]]
22
26
 
23
27
  _RE_CAMEL_TO_SNAKE1 = re.compile("(.)([A-Z][a-z]+)")
@@ -57,13 +61,12 @@ async def async_range(start: int, stop: int|None=None, step: int=1):
57
61
  Yields:
58
62
  int: The next value in the range sequence.
59
63
  """
60
- if stop:
64
+ if stop is not None:
61
65
  range_ = range(start, stop, step)
62
66
  else:
63
67
  range_ = range(start)
64
68
  for i in range_:
65
69
  yield i
66
- await asyncio.sleep(0)
67
70
 
68
71
 
69
72
  def base64_from_string(string: str, omit_trailing_equals: bool = False) -> str:
@@ -71,10 +74,12 @@ def base64_from_string(string: str, omit_trailing_equals: bool = False) -> str:
71
74
 
72
75
  Args:
73
76
  string: The input string to encode.
74
- omit_trailing_equals: If True, removes trailing '=' padding characters.
77
+ omit_trailing_equals: Reserved for callers that do not want trailing
78
+ ``=`` padding. The current implementation returns standard padded
79
+ base64 output.
75
80
 
76
81
  Returns:
77
- The base64 encoded string.
82
+ The base64-encoded string.
78
83
  """
79
84
  return base64.b64encode(bytes(str(string).encode("utf-8"))).decode()
80
85
 
@@ -124,7 +129,15 @@ def get_uuid_from_tgid(telegram_id: int, fixed: bool = True) -> str:
124
129
  return f"{now.year}{mon}{day}-{hr}{mn}-1111-1111-{resid}"
125
130
 
126
131
 
127
- def random_string(length: int):
132
+ def random_string(length: int) -> str:
133
+ """Generate a random alphanumeric string.
134
+
135
+ Args:
136
+ length: Number of characters to generate.
137
+
138
+ Returns:
139
+ A string made from ASCII letters and digits.
140
+ """
128
141
  s = "".join([random.choice(
129
142
  "1234567890abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ") for _ in range(length)
130
143
  ])
@@ -145,6 +158,22 @@ def generate_random_email(length: int = 8) -> str:
145
158
  return random_string(length)
146
159
 
147
160
 
161
+ def get_inbound_in_client(client_uuid: str, inbound: Inbound) -> SingleInboundClient|None:
162
+ """Find a client inside an inbound by UUID.
163
+
164
+ Args:
165
+ client_uuid: UUID of the client to find.
166
+ inbound: Inbound model whose client list should be searched.
167
+
168
+ Returns:
169
+ The matching client, or None if the inbound does not contain it.
170
+ """
171
+ for client in inbound.settings.clients:
172
+ if client.uuid == client_uuid:
173
+ return client
174
+ return None
175
+
176
+
148
177
  def generate_email_from_tgid_inbid(telegram_id: int, /, inbound_id: int) -> str:
149
178
  """Generate a deterministic email from Telegram ID and inbound ID.
150
179
 
@@ -165,7 +194,7 @@ def generate_email_from_tgid_inbid(telegram_id: int, /, inbound_id: int) -> str:
165
194
  return f"TG{telegram_id}IB{inbound_id}"
166
195
 
167
196
 
168
- def generate_new_subscription(length: int = 16):
197
+ def generate_new_subscription(length: int = 16) -> str:
169
198
  """Generate a random subscription ID.
170
199
 
171
200
  Args:
@@ -180,7 +209,7 @@ def generate_new_subscription(length: int = 16):
180
209
  return random_string(length)
181
210
 
182
211
 
183
- async def check_xui_response(response: JsonType | httpx.Response) -> str:
212
+ async def check_xui_response(response: dict | httpx.Response) -> str:
184
213
  """Validate a 3X-UI API response.
185
214
 
186
215
  Checks if the response follows the expected 3X-UI API format with
@@ -209,7 +238,7 @@ async def check_xui_response(response: JsonType | httpx.Response) -> str:
209
238
  json_resp = response.json()
210
239
  else:
211
240
  json_resp = response
212
-
241
+
213
242
  if len(json_resp) == 3:
214
243
  if tuple(json_resp.keys()) == ("success", "msg", "obj"):
215
244
  success: bool = json_resp["success"]
@@ -285,4 +314,4 @@ def auto_ms_to_s_timestamp(ms_or_s: int) -> int:
285
314
 
286
315
  def datetime_now_ms(tzinfo: tzinfo|None=UTC) -> int:
287
316
  """Get the current time as a UNIX timestamp in milliseconds."""
288
- return int(datetime.now(tzinfo).timestamp()) * 1000
317
+ 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,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
File without changes