Python-3xui 0.0.7__tar.gz → 0.0.8__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.
- {python_3xui-0.0.7 → python_3xui-0.0.8}/PKG-INFO +5 -5
- {python_3xui-0.0.7 → python_3xui-0.0.8}/README.md +4 -4
- {python_3xui-0.0.7 → python_3xui-0.0.8}/pyproject.toml +1 -1
- {python_3xui-0.0.7 → python_3xui-0.0.8}/python_3xui/api.py +46 -21
- {python_3xui-0.0.7 → python_3xui-0.0.8}/python_3xui/base_model.py +2 -2
- {python_3xui-0.0.7 → python_3xui-0.0.8}/python_3xui/util.py +10 -11
- {python_3xui-0.0.7 → python_3xui-0.0.8}/tests/test_non_idempotent_endpoints_clients.py +1 -1
- {python_3xui-0.0.7 → python_3xui-0.0.8}/.gitignore +0 -0
- {python_3xui-0.0.7 → python_3xui-0.0.8}/LICENSE +0 -0
- {python_3xui-0.0.7 → python_3xui-0.0.8}/python_3xui/__init__.py +0 -0
- {python_3xui-0.0.7 → python_3xui-0.0.8}/python_3xui/endpoints.py +0 -0
- {python_3xui-0.0.7 → python_3xui-0.0.8}/python_3xui/models.py +0 -0
- {python_3xui-0.0.7 → python_3xui-0.0.8}/tests/conftest.py +0 -0
- {python_3xui-0.0.7 → python_3xui-0.0.8}/tests/gather_response_stubs.py +0 -0
- {python_3xui-0.0.7 → python_3xui-0.0.8}/tests/pytest.ini +0 -0
- {python_3xui-0.0.7 → python_3xui-0.0.8}/tests/test_endpoints_clients.py +0 -0
- {python_3xui-0.0.7 → python_3xui-0.0.8}/tests/test_endpoints_inbounds.py +0 -0
- {python_3xui-0.0.7 → python_3xui-0.0.8}/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.
|
|
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.
|
|
31
|
+
<h2>0.0.8 Release Notes</h2>
|
|
32
32
|
<ul>
|
|
33
|
-
<li>
|
|
34
|
-
<li>
|
|
35
|
-
<li>Change
|
|
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>
|
|
@@ -2,9 +2,9 @@
|
|
|
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.
|
|
5
|
+
<h2>0.0.8 Release Notes</h2>
|
|
6
6
|
<ul>
|
|
7
|
-
<li>
|
|
8
|
-
<li>
|
|
9
|
-
<li>Change
|
|
7
|
+
<li>Improve create_and_add_prod_client to have an expiry_time</li>
|
|
8
|
+
<li>delete_client_by_tgid_all_inbounds -> revoke_client_by_tgid_all_inbounds</li>
|
|
9
|
+
<li>Change vulnerable requirements</li>
|
|
10
10
|
</ul>
|
|
@@ -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
|
|
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
|
-
|
|
146
|
+
resp.raise_for_status()
|
|
146
147
|
|
|
147
|
-
status = await util.
|
|
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)
|
|
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()
|
|
368
|
-
await asyncio.sleep(3600)
|
|
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,
|
|
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
|
-
|
|
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=
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
@@ -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.
|
|
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
|
|
|
@@ -180,7 +180,7 @@ def generate_new_subscription(length: int = 16):
|
|
|
180
180
|
return s
|
|
181
181
|
|
|
182
182
|
|
|
183
|
-
async def
|
|
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
|
-
- "
|
|
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
|
-
>>>
|
|
202
|
+
>>> check_xui_response({"success": True, "msg": "", "obj": {}})
|
|
203
203
|
'OK'
|
|
204
|
-
>>>
|
|
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
|
-
|
|
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
|
|
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).
|
|
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
|
|
242
|
+
return 0
|
|
244
243
|
|
|
245
244
|
current_timestamp = datetime.now(UTC).timestamp()
|
|
246
245
|
seconds_remaining = expiry_time - current_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.
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|