Python-3xui 0.0.6__tar.gz → 0.0.7__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.6 → python_3xui-0.0.7}/PKG-INFO +5 -6
- {python_3xui-0.0.6 → python_3xui-0.0.7}/README.md +4 -5
- {python_3xui-0.0.6 → python_3xui-0.0.7}/pyproject.toml +1 -1
- {python_3xui-0.0.6 → python_3xui-0.0.7}/python_3xui/api.py +19 -30
- {python_3xui-0.0.6 → python_3xui-0.0.7}/python_3xui/base_model.py +3 -3
- {python_3xui-0.0.6 → python_3xui-0.0.7}/python_3xui/endpoints.py +1 -8
- {python_3xui-0.0.6 → python_3xui-0.0.7}/python_3xui/models.py +3 -4
- {python_3xui-0.0.6 → python_3xui-0.0.7}/python_3xui/util.py +1 -1
- {python_3xui-0.0.6 → python_3xui-0.0.7}/tests/conftest.py +3 -2
- {python_3xui-0.0.6 → python_3xui-0.0.7}/tests/test_non_idempotent_endpoints_clients.py +1 -1
- {python_3xui-0.0.6 → python_3xui-0.0.7}/tests/test_non_idempotent_endpoints_inbounds.py +2 -1
- {python_3xui-0.0.6 → python_3xui-0.0.7}/.gitignore +0 -0
- {python_3xui-0.0.6 → python_3xui-0.0.7}/LICENSE +0 -0
- {python_3xui-0.0.6 → python_3xui-0.0.7}/python_3xui/__init__.py +0 -0
- {python_3xui-0.0.6 → python_3xui-0.0.7}/tests/gather_response_stubs.py +0 -0
- {python_3xui-0.0.6 → python_3xui-0.0.7}/tests/pytest.ini +0 -0
- {python_3xui-0.0.6 → python_3xui-0.0.7}/tests/test_endpoints_clients.py +0 -0
- {python_3xui-0.0.6 → python_3xui-0.0.7}/tests/test_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.7
|
|
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,10 +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.7 Release Notes</h2>
|
|
32
32
|
<ul>
|
|
33
|
-
<li>
|
|
34
|
-
<li>
|
|
35
|
-
<li>
|
|
36
|
-
Note that one-time codes only work one time... To ensure consistent logins you need OTP <b>secrets</b> to form new codes
|
|
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>
|
|
37
36
|
</ul>
|
|
@@ -2,10 +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.7 Release Notes</h2>
|
|
6
6
|
<ul>
|
|
7
|
-
<li>
|
|
8
|
-
<li>
|
|
9
|
-
<li>
|
|
10
|
-
Note that one-time codes only work one time... To ensure consistent logins you need OTP <b>secrets</b> to form new codes
|
|
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>
|
|
11
10
|
</ul>
|
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
import logging
|
|
1
2
|
import re
|
|
2
3
|
import time
|
|
3
4
|
from collections.abc import Sequence, Mapping
|
|
5
|
+
from logging import DEBUG
|
|
4
6
|
from typing import Self, Optional, Dict, Iterable, AsyncIterable, Type, Union, Any, List, Tuple, Literal
|
|
5
7
|
from datetime import datetime, UTC
|
|
6
8
|
|
|
@@ -106,23 +108,6 @@ class XUIClient:
|
|
|
106
108
|
else:
|
|
107
109
|
self.totp = pyotp.TOTP(self.two_fac_secret)
|
|
108
110
|
|
|
109
|
-
#========================singleton pattern========================
|
|
110
|
-
def __new__(cls, *args, **kwargs):
|
|
111
|
-
"""Create or return the singleton instance.
|
|
112
|
-
|
|
113
|
-
Args:
|
|
114
|
-
*args: Positional arguments passed to __init__.
|
|
115
|
-
**kwargs: Keyword arguments passed to __init__.
|
|
116
|
-
|
|
117
|
-
Returns:
|
|
118
|
-
The singleton XUIClient instance.
|
|
119
|
-
"""
|
|
120
|
-
print("initializing client")
|
|
121
|
-
if cls._instance is None:
|
|
122
|
-
print("nu instance")
|
|
123
|
-
cls._instance = super(XUIClient, cls).__new__(cls)
|
|
124
|
-
return cls._instance
|
|
125
|
-
|
|
126
111
|
#========================request stuffs========================
|
|
127
112
|
async def _safe_request(self,
|
|
128
113
|
method: Literal["get", "post", "patch", "delete", "put"],
|
|
@@ -142,20 +127,21 @@ class XUIClient:
|
|
|
142
127
|
Raises:
|
|
143
128
|
RuntimeError: If max retries exceeded or session is invalid.
|
|
144
129
|
"""
|
|
145
|
-
|
|
146
|
-
print(str(self.session.base_url) + str(kwargs["url"]))
|
|
130
|
+
logging.debug("Safe request is running to %s%s", str(self.session.base_url), str(kwargs["url"]))
|
|
147
131
|
async for attempt in async_range(self.max_retries):
|
|
148
132
|
resp = await self.session.request(method=method, **kwargs)
|
|
149
133
|
if resp.status_code // 100 != 2: #because it can return either 201 or 202
|
|
150
134
|
if resp.status_code == 404:
|
|
151
135
|
now: float = datetime.now(UTC).timestamp()
|
|
152
136
|
if self.session_start is None or now - self.session_start > self.session_duration:
|
|
153
|
-
|
|
137
|
+
logging.info("Client with IP/Domain %s is not logged in, logging in...", self.base_host)
|
|
154
138
|
await self.login()
|
|
155
139
|
continue
|
|
156
140
|
else:
|
|
141
|
+
logging.error("Server returned a status code of %s with a valid session", resp.status_code)
|
|
157
142
|
raise RuntimeError("""Server returned a 404, and the session should still be valid, likely it's a REAL 404""")
|
|
158
143
|
else:
|
|
144
|
+
logging.error("Server returned a status code of %s", resp.status_code)
|
|
159
145
|
raise RuntimeError(f"Wrong status code: {resp.status_code}")
|
|
160
146
|
|
|
161
147
|
status = await util.check_xui_response_validity(resp)
|
|
@@ -265,15 +251,14 @@ class XUIClient:
|
|
|
265
251
|
"password": self.xui_password,
|
|
266
252
|
}
|
|
267
253
|
if self.totp:
|
|
268
|
-
if self.totp.interval - datetime.now().timestamp() % self.totp.interval <
|
|
269
|
-
await asyncio.sleep(
|
|
254
|
+
if self.totp.interval - datetime.now().timestamp() % self.totp.interval < 3:
|
|
255
|
+
await asyncio.sleep(3.1) # just to not submit an invalid code
|
|
270
256
|
payload["twoFactorCode"] = self.totp.now()
|
|
271
257
|
else:
|
|
272
258
|
if self.two_fac_secret:
|
|
273
259
|
payload["twoFactorCode"] = self.two_fac_secret
|
|
274
260
|
|
|
275
|
-
|
|
276
|
-
print("WE'RE LOGGING IN")
|
|
261
|
+
logging.info("Client is logging in with IP/Domain: %s", self.base_host)
|
|
277
262
|
resp = await self.session.post("/login", data=payload)
|
|
278
263
|
if resp.status_code == 200:
|
|
279
264
|
resp_json = resp.json()
|
|
@@ -293,6 +278,7 @@ class XUIClient:
|
|
|
293
278
|
Returns:
|
|
294
279
|
Self: The XUIClient instance.
|
|
295
280
|
"""
|
|
281
|
+
logging.log(DEBUG, "Client connected with IP/domain %s", self.base_url)
|
|
296
282
|
self.session = AsyncClient(base_url=self.base_url)
|
|
297
283
|
self.connected = True
|
|
298
284
|
return self
|
|
@@ -331,7 +317,13 @@ class XUIClient:
|
|
|
331
317
|
exc_val: The exception value, if an exception occurred.
|
|
332
318
|
exc_tb: The exception traceback, if an exception occurred.
|
|
333
319
|
"""
|
|
334
|
-
|
|
320
|
+
if exc_type is None:
|
|
321
|
+
logging.info("Client is disconnecting at time with IP/Domain %s", self.base_host)
|
|
322
|
+
else:
|
|
323
|
+
logging.warning("Client is disconnecting due to an error (may be unrelated):"
|
|
324
|
+
"\n%s, with value %s\nStacktrace:%s",
|
|
325
|
+
exc_type, exc_val, exc_tb)
|
|
326
|
+
print(f"Client is disconnecting: {self.base_host}")
|
|
335
327
|
await self.disconnect()
|
|
336
328
|
return
|
|
337
329
|
|
|
@@ -371,12 +363,9 @@ class XUIClient:
|
|
|
371
363
|
timer from 5 to 60*60*24 in the code.
|
|
372
364
|
"""
|
|
373
365
|
while self.connected:
|
|
374
|
-
print("You're seeing this message because I forgot to remove it in api.update_inbounds() !")
|
|
375
|
-
print("Please change the timer from 5 to 60*60*24!")
|
|
376
366
|
self.get_production_inbounds.cache_clear()
|
|
377
367
|
await self.get_production_inbounds() #fill the cache
|
|
378
|
-
await asyncio.sleep(
|
|
379
|
-
#print(stat)
|
|
368
|
+
await asyncio.sleep(3600) #update every 1h
|
|
380
369
|
|
|
381
370
|
#========================clients management========================
|
|
382
371
|
async def get_client_with_tgid(self, tgid: int, inbound_id: int | None = None) -> List[ClientStats]:
|
|
@@ -513,7 +502,7 @@ class XUIClient:
|
|
|
513
502
|
email = util.generate_email_from_tgid_inbid(telegram_id, inbound.id)
|
|
514
503
|
resp = await self.clients_end.delete_client_by_email(email, inbound.id)
|
|
515
504
|
responses.append(resp)
|
|
516
|
-
|
|
505
|
+
logging.info("Clients of of tgid %s deleted", telegram_id)
|
|
517
506
|
|
|
518
507
|
return responses
|
|
519
508
|
|
|
@@ -27,9 +27,9 @@ class BaseModel(pydantic.BaseModel):
|
|
|
27
27
|
|
|
28
28
|
model_config = pydantic.ConfigDict(ignored_types=(cached_property, ))
|
|
29
29
|
|
|
30
|
-
def model_post_init(self, context: Any, /) -> None:
|
|
31
|
-
|
|
32
|
-
|
|
30
|
+
# def model_post_init(self, context: Any, /) -> None:
|
|
31
|
+
# #print(f"Model {self.__class__}, {self} initialized")
|
|
32
|
+
# ...
|
|
33
33
|
|
|
34
34
|
|
|
35
35
|
@classmethod
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import json
|
|
2
|
+
import logging
|
|
2
3
|
from datetime import datetime, UTC
|
|
3
4
|
from typing import Generic, Literal, List, Dict
|
|
4
5
|
|
|
@@ -232,17 +233,9 @@ class Clients(BaseEndpoint):
|
|
|
232
233
|
else:
|
|
233
234
|
raise TypeError
|
|
234
235
|
# send request
|
|
235
|
-
print(type(final))
|
|
236
|
-
print(final)
|
|
237
236
|
data = final.model_dump(by_alias=True)
|
|
238
|
-
print(type(data))
|
|
239
|
-
print(json.dumps(data))
|
|
240
|
-
print(f"{self._url}{endpoint}")
|
|
241
237
|
resp = await self.client.safe_post(f"{self._url}{endpoint}", data=data)
|
|
242
|
-
|
|
243
238
|
#YOU NEED TO PASS SETTINGS AS A STRING, NOT AS A DICT, YOU FUCKING DUMBASS!
|
|
244
|
-
print(resp)
|
|
245
|
-
print(resp.json())
|
|
246
239
|
return resp
|
|
247
240
|
|
|
248
241
|
async def _request_update_client(self, client: models.InboundClients | models.SingleInboundClient,
|
|
@@ -1,13 +1,12 @@
|
|
|
1
1
|
import json
|
|
2
|
-
from types import NoneType
|
|
3
2
|
from datetime import datetime, UTC
|
|
4
|
-
from typing import Union,
|
|
3
|
+
from typing import Union, TypeAlias, Any, Annotated, Literal, List, Dict, ClassVar
|
|
5
4
|
|
|
6
|
-
from pydantic import field_validator, Field, field_serializer, AfterValidator
|
|
7
5
|
import pydantic
|
|
6
|
+
from pydantic import field_validator, Field, field_serializer
|
|
8
7
|
|
|
9
8
|
from . import base_model
|
|
10
|
-
from .util import JsonType, auto_s_to_ms_timestamp,
|
|
9
|
+
from .util import JsonType, auto_s_to_ms_timestamp, auto_ms_to_s_timestamp
|
|
11
10
|
|
|
12
11
|
timestamp_seconds: TypeAlias = int
|
|
13
12
|
ip_address: TypeAlias = str
|
|
@@ -218,7 +218,7 @@ async def check_xui_response_validity(response: JsonType | httpx.Response) -> st
|
|
|
218
218
|
if "database" in msg.lower() and "locked" in msg.lower() and not success:
|
|
219
219
|
logging.log(logging.WARNING, "Database is locked, retrying...")
|
|
220
220
|
return "DB_LOCKED"
|
|
221
|
-
|
|
221
|
+
logging.warning("Unsuccessful operation (status code 200, success = false)! Message: %s", json_resp["msg"])
|
|
222
222
|
return "ERROR"
|
|
223
223
|
raise RuntimeError("Validator got something very unexpected (Please don't shove responses with non-20X status codes in here...)")
|
|
224
224
|
|
|
@@ -36,8 +36,9 @@ async def xui_client() -> XUIClient:
|
|
|
36
36
|
base_path = os.getenv("BASE_PATH")
|
|
37
37
|
username = os.getenv("XUI_USERNAME")
|
|
38
38
|
password = os.getenv("XUI_PASSWORD")
|
|
39
|
+
two_fac = os.getenv("XUI_2FA_SECRET")
|
|
39
40
|
|
|
40
|
-
if not all([base_url, port_str, base_path, username, password]):
|
|
41
|
+
if not all([base_url, port_str, base_path, username, password, two_fac]):
|
|
41
42
|
pytest.skip("Environment variables for XUIClient not configured (.env file required)")
|
|
42
43
|
|
|
43
44
|
try:
|
|
@@ -48,7 +49,7 @@ async def xui_client() -> XUIClient:
|
|
|
48
49
|
# Reset singleton for clean test state
|
|
49
50
|
XUIClient._instance = None
|
|
50
51
|
|
|
51
|
-
client = XUIClient(base_url, port, base_path, username=username, password=password)
|
|
52
|
+
client = XUIClient(base_url, port, base_path, username=username, password=password, two_fac_code=two_fac, custom_prod_string="test3")
|
|
52
53
|
client.connect()
|
|
53
54
|
|
|
54
55
|
# Authenticate
|
|
@@ -36,7 +36,7 @@ class TestClientsEndpoint:
|
|
|
36
36
|
# Try to find a suitable inbound (preferably with PROD_STRING in remark)
|
|
37
37
|
test_inbound = None
|
|
38
38
|
for inbound in all_inbounds:
|
|
39
|
-
if xui_client.PROD_STRING
|
|
39
|
+
if xui_client.PROD_STRING.search(inbound.remark.lower()):
|
|
40
40
|
test_inbound = inbound
|
|
41
41
|
break
|
|
42
42
|
|
|
@@ -61,7 +61,8 @@ class TestInboundsEndpoint:
|
|
|
61
61
|
)
|
|
62
62
|
|
|
63
63
|
# Create the inbound
|
|
64
|
-
|
|
64
|
+
pytest.skip("Not implemented yet")
|
|
65
|
+
response = await xui_client.inbounds_end.add_inbound()
|
|
65
66
|
|
|
66
67
|
# Validate response
|
|
67
68
|
assert response.status_code == 200
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|