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.
- {python_3xui-0.0.7 → python_3xui-0.0.8.post1}/PKG-INFO +3 -5
- {python_3xui-0.0.7 → python_3xui-0.0.8.post1}/README.md +2 -4
- {python_3xui-0.0.7 → python_3xui-0.0.8.post1}/pyproject.toml +2 -2
- {python_3xui-0.0.7 → python_3xui-0.0.8.post1}/python_3xui/api.py +67 -33
- {python_3xui-0.0.7 → python_3xui-0.0.8.post1}/python_3xui/base_model.py +2 -2
- {python_3xui-0.0.7 → python_3xui-0.0.8.post1}/python_3xui/util.py +11 -12
- {python_3xui-0.0.7 → python_3xui-0.0.8.post1}/tests/test_non_idempotent_endpoints_clients.py +3 -3
- {python_3xui-0.0.7 → python_3xui-0.0.8.post1}/.gitignore +0 -0
- {python_3xui-0.0.7 → python_3xui-0.0.8.post1}/LICENSE +0 -0
- {python_3xui-0.0.7 → python_3xui-0.0.8.post1}/python_3xui/__init__.py +0 -0
- {python_3xui-0.0.7 → python_3xui-0.0.8.post1}/python_3xui/endpoints.py +0 -0
- {python_3xui-0.0.7 → python_3xui-0.0.8.post1}/python_3xui/models.py +0 -0
- {python_3xui-0.0.7 → python_3xui-0.0.8.post1}/tests/conftest.py +0 -0
- {python_3xui-0.0.7 → python_3xui-0.0.8.post1}/tests/gather_response_stubs.py +0 -0
- {python_3xui-0.0.7 → python_3xui-0.0.8.post1}/tests/pytest.ini +0 -0
- {python_3xui-0.0.7 → python_3xui-0.0.8.post1}/tests/test_endpoints_clients.py +0 -0
- {python_3xui-0.0.7 → python_3xui-0.0.8.post1}/tests/test_endpoints_inbounds.py +0 -0
- {python_3xui-0.0.7 → python_3xui-0.0.8.post1}/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.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.
|
|
31
|
+
<h2>0.0.81 Release Notes</h2>
|
|
32
32
|
<ul>
|
|
33
|
-
<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.
|
|
5
|
+
<h2>0.0.81 Release Notes</h2>
|
|
6
6
|
<ul>
|
|
7
|
-
<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.
|
|
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"
|
|
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
|
|
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
|
-
|
|
150
|
+
resp.raise_for_status()
|
|
146
151
|
|
|
147
|
-
status = await util.
|
|
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)
|
|
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()
|
|
368
|
-
await asyncio.sleep(3600)
|
|
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,
|
|
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
|
-
|
|
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=
|
|
438
|
+
email=tmp_email,
|
|
421
439
|
limit_gb=0,
|
|
422
440
|
enable=True,
|
|
423
|
-
subscription_id=
|
|
424
|
-
comment=f"{additional_remark}, created at {datetime.now(UTC)}"
|
|
425
|
-
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
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
|
{python_3xui-0.0.7 → python_3xui-0.0.8.post1}/tests/test_non_idempotent_endpoints_clients.py
RENAMED
|
@@ -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,
|
|
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=
|
|
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.
|
|
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
|
{python_3xui-0.0.7 → python_3xui-0.0.8.post1}/tests/test_non_idempotent_endpoints_inbounds.py
RENAMED
|
File without changes
|