Python-3xui 0.0.7__tar.gz → 0.0.8.post1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: Python-3xui
3
- Version: 0.0.7
3
+ Version: 0.0.8.post1
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
@@ -28,9 +28,7 @@ Description-Content-Type: text/markdown
28
28
  <p>I'm not expecting much to be honest, so please feel free to fork it if I abandon the project and you need it!</p>
29
29
  <p>Also, if you REALLY want it I can give you the ownership if I step down, you can find my email in the pyproject.toml (I don't check it that much but trust me I do)</p>
30
30
 
31
- <h2>0.0.7 Release Notes</h2>
31
+ <h2>0.0.81 Release Notes</h2>
32
32
  <ul>
33
- <li>Purge prints and replace them with logging (except when absolutely needed)</li>
34
- <li>Make prod_string regEx</li>
35
- <li>Change the test suite</li>
33
+ <li>Minor change: make custom sub generators available instead of the default one</li>
36
34
  </ul>
@@ -2,9 +2,7 @@
2
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
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
4
 
5
- <h2>0.0.7 Release Notes</h2>
5
+ <h2>0.0.81 Release Notes</h2>
6
6
  <ul>
7
- <li>Purge prints and replace them with logging (except when absolutely needed)</li>
8
- <li>Make prod_string regEx</li>
9
- <li>Change the test suite</li>
7
+ <li>Minor change: make custom sub generators available instead of the default one</li>
10
8
  </ul>
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "Python-3xui"
3
- version = "0.0.7"
3
+ version = "0.0.8r1"
4
4
  authors = [
5
5
  { name="JustMe_001", email="justme001.causation755@passinbox.com" },
6
6
  ]
@@ -56,4 +56,4 @@ exclude = [
56
56
 
57
57
 
58
58
  [[tool.hatch.envs.hatch-test.matrix]]
59
- python = ["3.12", "3.11"]
59
+ python = ["3.13", "3.12", "3.11"]
@@ -1,9 +1,11 @@
1
+ import json
1
2
  import logging
2
3
  import re
3
4
  import time
4
5
  from collections.abc import Sequence, Mapping
6
+ from inspect import isawaitable
5
7
  from logging import DEBUG
6
- from typing import Self, Optional, Dict, Iterable, AsyncIterable, Type, Union, Any, List, Tuple, Literal
8
+ from typing import Self, Optional, Dict, Iterable, AsyncIterable, Type, Union, Any, List, Tuple, Literal, Callable, Awaitable, Coroutine
7
9
  from datetime import datetime, UTC
8
10
 
9
11
  import pyotp
@@ -14,7 +16,7 @@ import httpx
14
16
 
15
17
  from . import util
16
18
  from .models import Inbound, SingleInboundClient, ClientStats
17
- from .util import JsonType, async_range
19
+ from .util import JsonType, async_range, check_xui_response
18
20
 
19
21
  DataType: Type[str | bytes | Iterable[bytes] | AsyncIterable[bytes]] = Union[str, bytes, Iterable[bytes], AsyncIterable[bytes]]
20
22
  PrimitiveData = Optional[Union[str, int, float, bool]]
@@ -66,7 +68,9 @@ class XUIClient:
66
68
  def __init__(self, base_website: str, base_port: int, base_path: str,
67
69
  *, username: str | None = None, password: str | None = None,
68
70
  two_fac_code: str | None = None, session_duration: int = 3600,
69
- custom_prod_string: str = "testing") -> None:
71
+ custom_prod_string: str = "testing",
72
+ custom_sub_generator: Callable[[int], str]|Callable[[int], Awaitable[str]] = util.default_sub_from_tgid
73
+ ) -> None:
70
74
  """Initialize the XUIClient.
71
75
 
72
76
  Args:
@@ -78,7 +82,7 @@ class XUIClient:
78
82
  two_fac_code: Two-factor authentication code (if enabled).
79
83
  session_duration: Maximum session duration in seconds. Defaults to 3600.
80
84
  """
81
- from . import endpoints # look, I know it's bad, but we need to evade cyclical imports
85
+ from . import endpoints # look, I know it's bad, but we need to evade cyclical imports
82
86
  self.connected: bool = False
83
87
  self.PROD_STRING = re.compile(custom_prod_string)
84
88
  self.session: AsyncClient | None = None
@@ -94,6 +98,7 @@ class XUIClient:
94
98
  self.totp: pyotp.TOTP | None = None
95
99
  self.max_retries: int = 5
96
100
  self.retry_delay: int = 1
101
+ self.sub_gen = custom_sub_generator
97
102
  # endpoints
98
103
  self.server_end = endpoints.Server(self)
99
104
  self.clients_end = endpoints.Clients(self)
@@ -142,19 +147,19 @@ class XUIClient:
142
147
  raise RuntimeError("""Server returned a 404, and the session should still be valid, likely it's a REAL 404""")
143
148
  else:
144
149
  logging.error("Server returned a status code of %s", resp.status_code)
145
- raise RuntimeError(f"Wrong status code: {resp.status_code}")
150
+ resp.raise_for_status()
146
151
 
147
- status = await util.check_xui_response_validity(resp)
152
+ status = await util.check_xui_response(resp)
148
153
  if status == "OK":
149
154
  return resp
150
155
  elif status == "DB_LOCKED":
151
156
  if attempt + 1 >= self.max_retries:
152
- # resp.status_code = 518 # so the error can simply be handled as a "bad request"
153
- # return resp
154
157
  raise RuntimeError("Too many retries")
155
158
  await asyncio.sleep(self.retry_delay)
156
159
  continue
157
160
  else:
161
+ logging.error("A %s request was unsuccessful (code 200, but success=false).\nPayload: %s",
162
+ method, json.dumps(resp.json()))
158
163
  return resp
159
164
  raise RuntimeError(f"For some reason safe_request didn't exit, dump:\nmethod:\n{method}\n{kwargs}")
160
165
 
@@ -252,7 +257,7 @@ class XUIClient:
252
257
  }
253
258
  if self.totp:
254
259
  if self.totp.interval - datetime.now().timestamp() % self.totp.interval < 3:
255
- await asyncio.sleep(3.1) # just to not submit an invalid code
260
+ await asyncio.sleep(3.1) # just to not submit an invalid code
256
261
  payload["twoFactorCode"] = self.totp.now()
257
262
  else:
258
263
  if self.two_fac_secret:
@@ -266,7 +271,7 @@ class XUIClient:
266
271
  self.session_start: float = (datetime.now(UTC).timestamp())
267
272
  return
268
273
  else:
269
- raise ValueError("Error: wrong credentials or failed login")
274
+ raise ValueError("Error: wrong credentials (including status code) or failed login.")
270
275
  else:
271
276
  raise RuntimeError(f"Error: server returned a status code of {resp.status_code}")
272
277
 
@@ -317,7 +322,7 @@ class XUIClient:
317
322
  exc_val: The exception value, if an exception occurred.
318
323
  exc_tb: The exception traceback, if an exception occurred.
319
324
  """
320
- if exc_type is None:
325
+ if exc_type is None or exc_type == asyncio.exceptions.CancelledError:
321
326
  logging.info("Client is disconnecting at time with IP/Domain %s", self.base_host)
322
327
  else:
323
328
  logging.warning("Client is disconnecting due to an error (may be unrelated):"
@@ -328,7 +333,7 @@ class XUIClient:
328
333
  return
329
334
 
330
335
  #========================inbound management========================
331
- @alru_cache
336
+ @alru_cache()
332
337
  async def get_production_inbounds(self) -> Tuple[Inbound, ...]:
333
338
  """Retrieve production inbounds.
334
339
 
@@ -364,8 +369,8 @@ class XUIClient:
364
369
  """
365
370
  while self.connected:
366
371
  self.get_production_inbounds.cache_clear()
367
- await self.get_production_inbounds() #fill the cache
368
- await asyncio.sleep(3600) #update every 1h
372
+ await self.get_production_inbounds() #fill the cache
373
+ await asyncio.sleep(3600) #update every 1h
369
374
 
370
375
  #========================clients management========================
371
376
  async def get_client_with_tgid(self, tgid: int, inbound_id: int | None = None) -> List[ClientStats]:
@@ -394,7 +399,12 @@ class XUIClient:
394
399
  resp = await self.clients_end.get_client_with_uuid(uuid)
395
400
  return resp
396
401
 
397
- async def create_and_add_prod_client(self, telegram_id: int, additional_remark: str = None):
402
+ async def create_and_add_prod_client(self, telegram_id: int, *,
403
+ additional_remark: str | None = None,
404
+ expiry_time: int=0,
405
+ exist_ok: bool = False
406
+ ) -> list[Response]:
407
+ #TODO: add exist_ok flag
398
408
  """Create and add a production client.
399
409
 
400
410
  This method creates a new client with the given Telegram ID and
@@ -405,6 +415,8 @@ class XUIClient:
405
415
  Args:
406
416
  telegram_id: The Telegram ID of the client.
407
417
  additional_remark: An optional additional remark for the client.
418
+ expiry_time: Expiry time in SECONDS as a UNIX timestamp.
419
+ exist_ok: Don't raise any errors if the client is already there (good if you need a refresh job)
408
420
 
409
421
  Returns:
410
422
  List[Response]: A list of responses from the server for each
@@ -412,20 +424,35 @@ class XUIClient:
412
424
  """
413
425
  production_inbounds: List[Inbound] = await self.get_production_inbounds()
414
426
 
415
- responses = []
427
+ tasks = []
428
+ custom_sub: str
429
+ if isawaitable(self.sub_gen(telegram_id)):
430
+ custom_sub = await self.sub_gen(telegram_id)
431
+ else:
432
+ custom_sub = self.sub_gen(telegram_id)
416
433
  for inb in production_inbounds:
434
+ tmp_email = util.generate_email_from_tgid_inbid(telegram_id, inb.id)
417
435
  client = SingleInboundClient.model_construct(
418
436
  uuid=util.get_uuid_from_tgid(telegram_id),
419
437
  flow="",
420
- email=util.generate_email_from_tgid_inbid(telegram_id, inb.id),
438
+ email=tmp_email,
421
439
  limit_gb=0,
422
440
  enable=True,
423
- subscription_id=util.sub_from_tgid(telegram_id),
424
- comment=f"{additional_remark}, created at {datetime.now(UTC)}")
425
- responses.append(await self.clients_end.add_client(client, inb.id))
441
+ subscription_id=custom_sub,
442
+ comment=f"{additional_remark}, created at {datetime.now(UTC)}",
443
+ expiry_time=expiry_time * 1000
444
+ )
445
+ tasks.append(asyncio.create_task(self.clients_end.add_client(client, inb.id)))
446
+ responses: list[Response] = await asyncio.gather(*tasks)
447
+ if exist_ok:
448
+ return responses
449
+ for resp in responses:
450
+ json_resp = resp.json()
451
+ if "duplicate email" in json_resp["msg"].lower():
452
+ logging.error("ERROR: Client already exists and exist_ok not set: %s", json_resp["msg"])
426
453
  return responses
427
454
 
428
- async def update_client_by_tgid(self, telegram_id: int, inbound_id: int, /,
455
+ async def update_client_by_tgid(self, telegram_id: int, inbound_id: int, /, *,
429
456
  security: str | None = None,
430
457
  password: str | None = None,
431
458
  flow: Literal["", "xtls-rprx-vision", "xtls-rprx-vision-udp443"] | None = None,
@@ -434,28 +461,36 @@ class XUIClient:
434
461
  expiry_time: int | None = None,
435
462
  enable: bool | None = None,
436
463
  sub_id: str | None = None,
437
- comment: str | None = None) -> Response:
464
+ comment: str | None = None,
465
+ verbose: bool=True) -> Response:
438
466
  """
439
467
  Update a client in a specific inbound by Telegram ID.
440
468
 
441
469
  Args:
442
470
  telegram_id: The Telegram ID of the client
443
471
  inbound_id: The ID of the inbound where the client exists
444
- security: Client security setting
445
- password: Client password
446
- flow: VLESS flow type
447
- limit_ip: IP connection limit
448
- limit_gb: Data limit in GB
449
- expiry_time: Client expiry time (UNIX timestamp)
450
- enable: Whether the client is enabled
451
- sub_id: Subscription ID
452
- comment: Client comment/note
472
+ security: Client security setting (optional)
473
+ password: Client password (optional)
474
+ flow: VLESS flow type (optional)
475
+ limit_ip: IP connection limit (optional)
476
+ limit_gb: Data limit in GB (optional)
477
+ expiry_time: Client expiry time (UNIX timestamp) (optional)
478
+ enable: Whether the client is enabled (optional)
479
+ sub_id: Subscription ID (optional)
480
+ comment: Client comment/note (optional)
453
481
 
454
482
  Returns:
455
483
  Response from the API
456
484
  """
457
485
  email = util.generate_email_from_tgid_inbid(telegram_id, inbound_id)
458
486
  existing_client = await self.clients_end.get_client_with_email(email)
487
+ if verbose:
488
+ if expiry_time < 1e9:
489
+ logging.warning("Warning: You're trying to update a client with expiry time %s. "
490
+ "You set it to expire before 2001, likely because you provided the DURATION. "
491
+ "You need to provide a TIMESTAMP. "
492
+ "If you want to disable this message, set verbose=false.",
493
+ expiry_time)
459
494
 
460
495
  resp = await self.clients_end.update_single_client(
461
496
  SingleInboundClient.model_validate(existing_client.model_dump()),
@@ -486,7 +521,7 @@ class XUIClient:
486
521
  resp = await self.clients_end.delete_client_by_email(email, inbound_id)
487
522
  return resp
488
523
 
489
- async def delete_client_by_tgid_all_inbounds(self, telegram_id: int) -> List[Response]:
524
+ async def revoke_client_by_tgid_all_inbounds(self, telegram_id: int) -> List[Response]:
490
525
  """Delete a client from all production inbounds by Telegram ID.
491
526
 
492
527
  Args:
@@ -505,4 +540,3 @@ class XUIClient:
505
540
  logging.info("Clients of of tgid %s deleted", telegram_id)
506
541
 
507
542
  return responses
508
-
@@ -83,7 +83,7 @@ class BaseModel(pydantic.BaseModel):
83
83
  inbounds = await Inbound.from_response(response, client, list)
84
84
  """
85
85
  json_resp: util.JsonType = response.json()
86
- valid = util.check_xui_response_validity(json_resp)
86
+ valid = util.check_xui_response(json_resp)
87
87
  if valid == "OK":
88
88
  obj = json_resp["obj"]
89
89
  if expect is list:
@@ -91,4 +91,4 @@ class BaseModel(pydantic.BaseModel):
91
91
  if expect is dict:
92
92
  return cls(**obj, client=client)
93
93
  else:
94
- raise ValueError(f"Invalid 3X-UI response, code {valid}")
94
+ raise ValueError(f"Invalid 3X-UI response, code {valid}. Don't use from_response on failed requests.")
@@ -14,7 +14,7 @@ import logging
14
14
  import random
15
15
  import re
16
16
  from datetime import UTC, datetime, tzinfo
17
- from typing import TypeAlias, Union, Dict, Any, List
17
+ from typing import TypeAlias, Union, Dict, Any, List, dataclass_transform
18
18
 
19
19
  import httpx
20
20
 
@@ -79,7 +79,7 @@ def base64_from_string(string: str, omit_trailing_equals: bool = False) -> str:
79
79
  return base64.b64encode(bytes(str(string).encode("utf-8"))).decode()
80
80
 
81
81
 
82
- def sub_from_tgid(telegram_id: int) -> str:
82
+ def default_sub_from_tgid(telegram_id: int) -> str:
83
83
  """Generate a subscription ID from a Telegram ID.
84
84
 
85
85
  Args:
@@ -180,7 +180,7 @@ def generate_new_subscription(length: int = 16):
180
180
  return s
181
181
 
182
182
 
183
- async def check_xui_response_validity(response: JsonType | httpx.Response) -> str:
183
+ async def check_xui_response(response: JsonType | httpx.Response) -> str:
184
184
  """Validate a 3X-UI API response.
185
185
 
186
186
  Checks if the response follows the expected 3X-UI API format with
@@ -193,17 +193,18 @@ async def check_xui_response_validity(response: JsonType | httpx.Response) -> st
193
193
  str: One of three status strings:
194
194
  - "OK": Response is valid and successful.
195
195
  - "DB_LOCKED": Database is locked, operation should be retried.
196
- - "ERROR": Operation was unsuccessful.
196
+ - "FAIL": Operation was unsuccessful.
197
197
 
198
198
  Raises:
199
199
  RuntimeError: If the response doesn't match the expected 3X-UI format.
200
200
 
201
201
  Examples:
202
- >>> check_xui_response_validity({"success": True, "msg": "", "obj": {}})
202
+ >>> check_xui_response({"success": True, "msg": "", "obj": {}})
203
203
  'OK'
204
- >>> check_xui_response_validity({"success": False, "msg": "database is locked", "obj": None})
204
+ >>> check_xui_response({"success": False, "msg": "database is locked", "obj": None})
205
205
  'DB_LOCKED'
206
206
  """
207
+ #TODO: this is trying to do too much. We'll just check if DB is locked, and then use case-to-case basis to see how they are.
207
208
  if isinstance(response, httpx.Response):
208
209
  json_resp = response.json()
209
210
  else:
@@ -218,11 +219,9 @@ async def check_xui_response_validity(response: JsonType | httpx.Response) -> st
218
219
  if "database" in msg.lower() and "locked" in msg.lower() and not success:
219
220
  logging.log(logging.WARNING, "Database is locked, retrying...")
220
221
  return "DB_LOCKED"
221
- logging.warning("Unsuccessful operation (status code 200, success = false)! Message: %s", json_resp["msg"])
222
- return "ERROR"
222
+ return "FAIL"
223
223
  raise RuntimeError("Validator got something very unexpected (Please don't shove responses with non-20X status codes in here...)")
224
224
 
225
-
226
225
  def get_days_until_expiry(expiry_time: int) -> float:
227
226
  """Calculate the number of days until a client expires.
228
227
 
@@ -231,16 +230,16 @@ def get_days_until_expiry(expiry_time: int) -> float:
231
230
 
232
231
  Returns:
233
232
  Number of days until expiry. Returns negative value if already expired.
234
- Returns a very large number (infinity) if expiry_time is 0 (no expiry).
233
+ Returns a very 0 if expiry_time is 0 (no expiry).
235
234
 
236
235
  Examples:
237
- >>> get_days_until_expiry(int(datetime.now(UTC).timestamp_seconds()) + 86400) # 1 day from now
236
+ >>> get_days_until_expiry(int(datetime.now(UTC).timestamp()) + 86400) # 1 day from now
238
237
  1.0
239
238
  >>> get_days_until_expiry(0) # No expiry
240
239
  inf
241
240
  """
242
241
  if expiry_time == 0:
243
- return float('inf')
242
+ return 0
244
243
 
245
244
  current_timestamp = datetime.now(UTC).timestamp()
246
245
  seconds_remaining = expiry_time - current_timestamp
@@ -8,7 +8,7 @@ from pydantic import ValidationError
8
8
 
9
9
  from python_3xui.api import XUIClient
10
10
  from python_3xui.models import SingleInboundClient, ClientStats
11
- from python_3xui.util import get_uuid_from_tgid, sub_from_tgid, s_to_ms_timestamp, datetime_now_ms, generate_email_from_tgid_inbid, \
11
+ from python_3xui.util import get_uuid_from_tgid, s_to_ms_timestamp, datetime_now_ms, generate_email_from_tgid_inbid, \
12
12
  generate_random_email
13
13
 
14
14
 
@@ -72,7 +72,7 @@ class TestClientsEndpoint:
72
72
  expiryTime=timestamp + 86400*1000, # Using alias 'expiryTime' for 'expiry_time'
73
73
  enable=True,
74
74
  tgId="", # Using alias 'tgId' for 'tg_id'
75
- subId=sub_from_tgid(TestClientsEndpoint.test_telegram_id), # Using alias 'subId' for 'subscription_id'
75
+ subId=xui_client.sub_gen(TestClientsEndpoint.test_telegram_id), # Using alias 'subId' for 'subscription_id'
76
76
  comment=f"Test client created at {timestamp}, TEST SUITE",
77
77
  created_at=timestamp,
78
78
  updated_at=timestamp
@@ -242,7 +242,7 @@ class TestClientsEndpoint:
242
242
  print(f"Added test client with email: {test_email}, UUID: {test_uuid} to {len(production_inbounds)} production inbounds")
243
243
 
244
244
  # Now delete the client from all production inbounds by Telegram ID
245
- responses = await xui_client.delete_client_by_tgid_all_inbounds(TEST_TELEGRAM_ID)
245
+ responses = await xui_client.revoke_client_by_tgid_all_inbounds(TEST_TELEGRAM_ID)
246
246
 
247
247
  # Validate responses
248
248
  assert len(responses) == len(production_inbounds)
File without changes