Python-3xui 0.0.7__py3-none-any.whl → 0.0.8__py3-none-any.whl

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.
python_3xui/api.py CHANGED
@@ -1,3 +1,4 @@
1
+ import json
1
2
  import logging
2
3
  import re
3
4
  import time
@@ -14,7 +15,7 @@ import httpx
14
15
 
15
16
  from . import util
16
17
  from .models import Inbound, SingleInboundClient, ClientStats
17
- from .util import JsonType, async_range
18
+ from .util import JsonType, async_range, check_xui_response
18
19
 
19
20
  DataType: Type[str | bytes | Iterable[bytes] | AsyncIterable[bytes]] = Union[str, bytes, Iterable[bytes], AsyncIterable[bytes]]
20
21
  PrimitiveData = Optional[Union[str, int, float, bool]]
@@ -78,7 +79,7 @@ class XUIClient:
78
79
  two_fac_code: Two-factor authentication code (if enabled).
79
80
  session_duration: Maximum session duration in seconds. Defaults to 3600.
80
81
  """
81
- from . import endpoints # look, I know it's bad, but we need to evade cyclical imports
82
+ from . import endpoints # look, I know it's bad, but we need to evade cyclical imports
82
83
  self.connected: bool = False
83
84
  self.PROD_STRING = re.compile(custom_prod_string)
84
85
  self.session: AsyncClient | None = None
@@ -142,19 +143,19 @@ class XUIClient:
142
143
  raise RuntimeError("""Server returned a 404, and the session should still be valid, likely it's a REAL 404""")
143
144
  else:
144
145
  logging.error("Server returned a status code of %s", resp.status_code)
145
- raise RuntimeError(f"Wrong status code: {resp.status_code}")
146
+ resp.raise_for_status()
146
147
 
147
- status = await util.check_xui_response_validity(resp)
148
+ status = await util.check_xui_response(resp)
148
149
  if status == "OK":
149
150
  return resp
150
151
  elif status == "DB_LOCKED":
151
152
  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
153
  raise RuntimeError("Too many retries")
155
154
  await asyncio.sleep(self.retry_delay)
156
155
  continue
157
156
  else:
157
+ logging.error("A %s request was unsuccessful (code 200, but success=false).\nPayload: %s",
158
+ method, json.dumps(resp.json()))
158
159
  return resp
159
160
  raise RuntimeError(f"For some reason safe_request didn't exit, dump:\nmethod:\n{method}\n{kwargs}")
160
161
 
@@ -252,7 +253,7 @@ class XUIClient:
252
253
  }
253
254
  if self.totp:
254
255
  if self.totp.interval - datetime.now().timestamp() % self.totp.interval < 3:
255
- await asyncio.sleep(3.1) # just to not submit an invalid code
256
+ await asyncio.sleep(3.1) # just to not submit an invalid code
256
257
  payload["twoFactorCode"] = self.totp.now()
257
258
  else:
258
259
  if self.two_fac_secret:
@@ -266,7 +267,7 @@ class XUIClient:
266
267
  self.session_start: float = (datetime.now(UTC).timestamp())
267
268
  return
268
269
  else:
269
- raise ValueError("Error: wrong credentials or failed login")
270
+ raise ValueError("Error: wrong credentials (including status code) or failed login.")
270
271
  else:
271
272
  raise RuntimeError(f"Error: server returned a status code of {resp.status_code}")
272
273
 
@@ -317,7 +318,7 @@ class XUIClient:
317
318
  exc_val: The exception value, if an exception occurred.
318
319
  exc_tb: The exception traceback, if an exception occurred.
319
320
  """
320
- if exc_type is None:
321
+ if exc_type is None or exc_type == asyncio.exceptions.CancelledError:
321
322
  logging.info("Client is disconnecting at time with IP/Domain %s", self.base_host)
322
323
  else:
323
324
  logging.warning("Client is disconnecting due to an error (may be unrelated):"
@@ -328,7 +329,7 @@ class XUIClient:
328
329
  return
329
330
 
330
331
  #========================inbound management========================
331
- @alru_cache
332
+ @alru_cache()
332
333
  async def get_production_inbounds(self) -> Tuple[Inbound, ...]:
333
334
  """Retrieve production inbounds.
334
335
 
@@ -364,8 +365,8 @@ class XUIClient:
364
365
  """
365
366
  while self.connected:
366
367
  self.get_production_inbounds.cache_clear()
367
- await self.get_production_inbounds() #fill the cache
368
- await asyncio.sleep(3600) #update every 1h
368
+ await self.get_production_inbounds() #fill the cache
369
+ await asyncio.sleep(3600) #update every 1h
369
370
 
370
371
  #========================clients management========================
371
372
  async def get_client_with_tgid(self, tgid: int, inbound_id: int | None = None) -> List[ClientStats]:
@@ -394,7 +395,12 @@ class XUIClient:
394
395
  resp = await self.clients_end.get_client_with_uuid(uuid)
395
396
  return resp
396
397
 
397
- async def create_and_add_prod_client(self, telegram_id: int, additional_remark: str = None):
398
+ async def create_and_add_prod_client(self, telegram_id: int, *,
399
+ additional_remark: str | None = None,
400
+ expiry_time: int=0,
401
+ exist_ok: bool = False
402
+ ) -> list[Response]:
403
+ #TODO: add exist_ok flag
398
404
  """Create and add a production client.
399
405
 
400
406
  This method creates a new client with the given Telegram ID and
@@ -405,6 +411,8 @@ class XUIClient:
405
411
  Args:
406
412
  telegram_id: The Telegram ID of the client.
407
413
  additional_remark: An optional additional remark for the client.
414
+ expiry_time: Expiry time in SECONDS as a UNIX timestamp.
415
+ exist_ok: Don't raise any errors if the client is already there (good if you need a refresh job)
408
416
 
409
417
  Returns:
410
418
  List[Response]: A list of responses from the server for each
@@ -412,20 +420,30 @@ class XUIClient:
412
420
  """
413
421
  production_inbounds: List[Inbound] = await self.get_production_inbounds()
414
422
 
415
- responses = []
423
+ tasks = []
416
424
  for inb in production_inbounds:
425
+ tmp_email = util.generate_email_from_tgid_inbid(telegram_id, inb.id)
417
426
  client = SingleInboundClient.model_construct(
418
427
  uuid=util.get_uuid_from_tgid(telegram_id),
419
428
  flow="",
420
- email=util.generate_email_from_tgid_inbid(telegram_id, inb.id),
429
+ email=tmp_email,
421
430
  limit_gb=0,
422
431
  enable=True,
423
432
  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))
433
+ comment=f"{additional_remark}, created at {datetime.now(UTC)}",
434
+ expiry_time=expiry_time * 1000
435
+ )
436
+ tasks.append(asyncio.create_task(self.clients_end.add_client(client, inb.id)))
437
+ responses: list[Response] = await asyncio.gather(*tasks)
438
+ if exist_ok:
439
+ return responses
440
+ for resp in responses:
441
+ json_resp = resp.json()
442
+ if "duplicate email" in json_resp["msg"].lower():
443
+ logging.error("ERROR: Client already exists and exist_ok not set: %s", json_resp["msg"])
426
444
  return responses
427
445
 
428
- async def update_client_by_tgid(self, telegram_id: int, inbound_id: int, /,
446
+ async def update_client_by_tgid(self, telegram_id: int, inbound_id: int, /, *,
429
447
  security: str | None = None,
430
448
  password: str | None = None,
431
449
  flow: Literal["", "xtls-rprx-vision", "xtls-rprx-vision-udp443"] | None = None,
@@ -434,7 +452,8 @@ class XUIClient:
434
452
  expiry_time: int | None = None,
435
453
  enable: bool | None = None,
436
454
  sub_id: str | None = None,
437
- comment: str | None = None) -> Response:
455
+ comment: str | None = None,
456
+ verbose: bool=True) -> Response:
438
457
  """
439
458
  Update a client in a specific inbound by Telegram ID.
440
459
 
@@ -456,6 +475,13 @@ class XUIClient:
456
475
  """
457
476
  email = util.generate_email_from_tgid_inbid(telegram_id, inbound_id)
458
477
  existing_client = await self.clients_end.get_client_with_email(email)
478
+ if verbose:
479
+ if expiry_time < 1e9:
480
+ logging.warning("Warning: You're trying to update a client with expiry time %s. "
481
+ "You set it to expire before 2001, likely because you provided the DURATION. "
482
+ "You need to provide a TIMESTAMP. "
483
+ "If you want to disable this message, set verbose=false.",
484
+ expiry_time)
459
485
 
460
486
  resp = await self.clients_end.update_single_client(
461
487
  SingleInboundClient.model_validate(existing_client.model_dump()),
@@ -486,7 +512,7 @@ class XUIClient:
486
512
  resp = await self.clients_end.delete_client_by_email(email, inbound_id)
487
513
  return resp
488
514
 
489
- async def delete_client_by_tgid_all_inbounds(self, telegram_id: int) -> List[Response]:
515
+ async def revoke_client_by_tgid_all_inbounds(self, telegram_id: int) -> List[Response]:
490
516
  """Delete a client from all production inbounds by Telegram ID.
491
517
 
492
518
  Args:
@@ -505,4 +531,3 @@ class XUIClient:
505
531
  logging.info("Clients of of tgid %s deleted", telegram_id)
506
532
 
507
533
  return responses
508
-
python_3xui/base_model.py CHANGED
@@ -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.")
python_3xui/util.py CHANGED
@@ -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
 
@@ -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
@@ -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
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,9 @@ 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.8 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>Improve create_and_add_prod_client to have an expiry_time</li>
34
+ <li>delete_client_by_tgid_all_inbounds -> revoke_client_by_tgid_all_inbounds</li>
35
+ <li>Change vulnerable requirements</li>
36
36
  </ul>
@@ -0,0 +1,10 @@
1
+ python_3xui/__init__.py,sha256=Zb_-gNgvpe1LS95EKksaLFVM2wgWuXzoFb4pb3DZB_o,94
2
+ python_3xui/api.py,sha256=w7vPgQrvxnymzZtkLOqZb8nYjwdCuMrhklVVxyEC8ts,22347
3
+ python_3xui/base_model.py,sha256=qNQKS_oq5um5dAmpHSB5XhKy_06VJLXYu4eO9Jd50ac,3361
4
+ python_3xui/endpoints.py,sha256=6F7bfMV8i-DnRJx186YLWTjSdTqocdfGHSpZ7NxAt7E,13352
5
+ python_3xui/models.py,sha256=LjbzA9H6Zmrv3qRDQBIaxBiq7NACINiDpl0Uhvh_hK4,11369
6
+ python_3xui/util.py,sha256=PgnlUPQrysThhB5_PpwVdsQb94BF0LTEGC-XuIEgNU8,9277
7
+ python_3xui-0.0.8.dist-info/METADATA,sha256=nGsuWThDwbm4whPk-lBtZQeiJyFaD1MVjHfhPuVI4AY,1520
8
+ python_3xui-0.0.8.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
9
+ python_3xui-0.0.8.dist-info/licenses/LICENSE,sha256=QwcOLU5TJoTeUhuIXzhdCEEDDvorGiC6-3YTOl4TecE,11356
10
+ python_3xui-0.0.8.dist-info/RECORD,,
@@ -1,10 +0,0 @@
1
- python_3xui/__init__.py,sha256=Zb_-gNgvpe1LS95EKksaLFVM2wgWuXzoFb4pb3DZB_o,94
2
- python_3xui/api.py,sha256=3S1XzxRVeo36hV3QsMSKqdEGGgAKnswf-_ynERiPvw4,20822
3
- python_3xui/base_model.py,sha256=5qjxYjZxAcTS0MCNkPJ_jLQ9PB1kZTMpR3unKmLSgNE,3325
4
- python_3xui/endpoints.py,sha256=6F7bfMV8i-DnRJx186YLWTjSdTqocdfGHSpZ7NxAt7E,13352
5
- python_3xui/models.py,sha256=LjbzA9H6Zmrv3qRDQBIaxBiq7NACINiDpl0Uhvh_hK4,11369
6
- python_3xui/util.py,sha256=D8bLK8dWnIlHl0RL-pxpe58S8H5lM1Hs9lT7vzjjiEM,9318
7
- python_3xui-0.0.7.dist-info/METADATA,sha256=AeGst2aay6rKYPZEK8HNiCAVQyDHXxsbFj-PCeStLlE,1478
8
- python_3xui-0.0.7.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
9
- python_3xui-0.0.7.dist-info/licenses/LICENSE,sha256=QwcOLU5TJoTeUhuIXzhdCEEDDvorGiC6-3YTOl4TecE,11356
10
- python_3xui-0.0.7.dist-info/RECORD,,