Python-3xui 0.0.9.post2__tar.gz → 0.0.9.post3__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.9.post2 → python_3xui-0.0.9.post3}/PKG-INFO +7 -11
- python_3xui-0.0.9.post3/README.md +12 -0
- {python_3xui-0.0.9.post2 → python_3xui-0.0.9.post3}/pyproject.toml +1 -1
- {python_3xui-0.0.9.post2 → python_3xui-0.0.9.post3}/python_3xui/__init__.py +2 -1
- {python_3xui-0.0.9.post2 → python_3xui-0.0.9.post3}/python_3xui/api.py +23 -11
- {python_3xui-0.0.9.post2 → python_3xui-0.0.9.post3}/python_3xui/base_model.py +3 -3
- {python_3xui-0.0.9.post2 → python_3xui-0.0.9.post3}/python_3xui/models.py +5 -5
- {python_3xui-0.0.9.post2 → python_3xui-0.0.9.post3}/python_3xui/util.py +1 -0
- python_3xui-0.0.9.post2/README.md +0 -16
- {python_3xui-0.0.9.post2 → python_3xui-0.0.9.post3}/.gitignore +0 -0
- {python_3xui-0.0.9.post2 → python_3xui-0.0.9.post3}/LICENSE +0 -0
- {python_3xui-0.0.9.post2 → python_3xui-0.0.9.post3}/python_3xui/custom_exceptions.py +0 -0
- {python_3xui-0.0.9.post2 → python_3xui-0.0.9.post3}/python_3xui/endpoints.py +0 -0
- {python_3xui-0.0.9.post2 → python_3xui-0.0.9.post3}/tests/conftest.py +0 -0
- {python_3xui-0.0.9.post2 → python_3xui-0.0.9.post3}/tests/gather_response_stubs.py +0 -0
- {python_3xui-0.0.9.post2 → python_3xui-0.0.9.post3}/tests/pytest.ini +0 -0
- {python_3xui-0.0.9.post2 → python_3xui-0.0.9.post3}/tests/test_endpoints_clients.py +0 -0
- {python_3xui-0.0.9.post2 → python_3xui-0.0.9.post3}/tests/test_endpoints_inbounds.py +0 -0
- {python_3xui-0.0.9.post2 → python_3xui-0.0.9.post3}/tests/test_non_idempotent_endpoints_clients.py +0 -0
- {python_3xui-0.0.9.post2 → python_3xui-0.0.9.post3}/tests/test_non_idempotent_endpoints_inbounds.py +0 -0
- {python_3xui-0.0.9.post2 → python_3xui-0.0.9.post3}/tests/test_xuiclient_helpers.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: Python-3xui
|
|
3
|
-
Version: 0.0.9.
|
|
3
|
+
Version: 0.0.9.post3
|
|
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,15 +28,11 @@ 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.9 Release Notes</h2>
|
|
31
|
+
<h2>0.0.9-r3 Release Notes</h2>
|
|
32
32
|
<ul>
|
|
33
|
-
<li>
|
|
34
|
-
<li>
|
|
35
|
-
<li>
|
|
36
|
-
<li>
|
|
37
|
-
<li>
|
|
38
|
-
<li>Remove obsolete and useless client fields from models</li>
|
|
39
|
-
<li>Inbound settings actually get parsed properly into ClientsSettings</li>
|
|
40
|
-
<li>New asyncio task management so they won't get destroyed when GCed</li>
|
|
41
|
-
<li>XUIClient async_lru cache now binds to event loop at runtime, not in initialization</li>
|
|
33
|
+
<li>HOTFIX: the importing of util.py fixed with from __future__ import annotations</li>
|
|
34
|
+
<li>Make panel_id for better accounting & logging clarity</li>
|
|
35
|
+
<li>Fix __aenter__ in XUIClient to not log a warning</li>
|
|
36
|
+
<li>Fix total_gb to be int and not float, since that would need refactoring which I don't have time for yet.</li>
|
|
37
|
+
<li>ClientsSettings now has extra=ignore instead of extra=forbid.</li>
|
|
42
38
|
</ul>
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
<h1>Hi! This is my example python 3x-ui wrapper!</h1>
|
|
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
|
+
<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
|
+
|
|
5
|
+
<h2>0.0.9-r3 Release Notes</h2>
|
|
6
|
+
<ul>
|
|
7
|
+
<li>HOTFIX: the importing of util.py fixed with from __future__ import annotations</li>
|
|
8
|
+
<li>Make panel_id for better accounting & logging clarity</li>
|
|
9
|
+
<li>Fix __aenter__ in XUIClient to not log a warning</li>
|
|
10
|
+
<li>Fix total_gb to be int and not float, since that would need refactoring which I don't have time for yet.</li>
|
|
11
|
+
<li>ClientsSettings now has extra=ignore instead of extra=forbid.</li>
|
|
12
|
+
</ul>
|
|
@@ -76,6 +76,7 @@ class XUIClient:
|
|
|
76
76
|
custom_prod_string: str = "testing",
|
|
77
77
|
max_retries: int = 5, retry_delay=1,
|
|
78
78
|
custom_sub_generator: Callable[[int], str] | Callable[[int], Awaitable[str]] = util.default_sub_from_tgid,
|
|
79
|
+
panel_id: Any = None
|
|
79
80
|
) -> None:
|
|
80
81
|
"""Initialize the XUIClient.
|
|
81
82
|
|
|
@@ -93,6 +94,7 @@ class XUIClient:
|
|
|
93
94
|
retry_delay: Seconds to wait between database-lock retries.
|
|
94
95
|
custom_sub_generator: Sync or async callable that receives a
|
|
95
96
|
Telegram ID and returns the subscription ID for new clients.
|
|
97
|
+
panel_id: this is solely for user's purposes to increase logging and accounting clarity. Default is None.
|
|
96
98
|
"""
|
|
97
99
|
self.connected: bool = False
|
|
98
100
|
self.PROD_STRING = re.compile(custom_prod_string)
|
|
@@ -110,6 +112,7 @@ class XUIClient:
|
|
|
110
112
|
self.max_retries: int = max_retries
|
|
111
113
|
self.retry_delay: int = retry_delay
|
|
112
114
|
self.sub_gen = custom_sub_generator
|
|
115
|
+
self.panel_id: int | str | Any = panel_id
|
|
113
116
|
# endpoints
|
|
114
117
|
self.server_end = endpoints.Server(self)
|
|
115
118
|
self.clients_end = endpoints.Clients(self)
|
|
@@ -198,7 +201,7 @@ class XUIClient:
|
|
|
198
201
|
if resp.status_code == 404:
|
|
199
202
|
now: float = datetime.now(UTC).timestamp()
|
|
200
203
|
if self.session_start is None or now - self.session_start > self.session_duration:
|
|
201
|
-
logging.info("Client
|
|
204
|
+
logging.info("Client (panel: %s) is not logged in, logging in...", self.panel_id or self.base_host)
|
|
202
205
|
await self.login()
|
|
203
206
|
continue
|
|
204
207
|
else:
|
|
@@ -322,7 +325,7 @@ class XUIClient:
|
|
|
322
325
|
if self.two_fac_secret:
|
|
323
326
|
payload["twoFactorCode"] = self.two_fac_secret.get_secret_value()
|
|
324
327
|
|
|
325
|
-
logging.info("Client is logging in
|
|
328
|
+
logging.info("Client is logging in (panel: %s)", self.panel_id or self.base_host)
|
|
326
329
|
resp = await self.session.post("/login", data=payload)
|
|
327
330
|
if resp.status_code == 200:
|
|
328
331
|
resp_json = resp.json()
|
|
@@ -343,7 +346,7 @@ class XUIClient:
|
|
|
343
346
|
Returns:
|
|
344
347
|
Self: The XUIClient instance.
|
|
345
348
|
"""
|
|
346
|
-
logging.log(DEBUG, "Client connected
|
|
349
|
+
logging.log(DEBUG, "Client connected (panel: %s)", self.panel_id or self.base_url)
|
|
347
350
|
self.session = AsyncClient(base_url=self.base_url)
|
|
348
351
|
self.connected = True
|
|
349
352
|
return self
|
|
@@ -373,9 +376,10 @@ class XUIClient:
|
|
|
373
376
|
"""
|
|
374
377
|
self.connect()
|
|
375
378
|
await self.login()
|
|
376
|
-
self._cache_cleaner_task
|
|
377
|
-
self.
|
|
378
|
-
|
|
379
|
+
if not self._cache_cleaner_task:
|
|
380
|
+
self._cache_cleaner_task = asyncio.create_task(
|
|
381
|
+
self._clear_prod_inbound_cache_task(create_new=True), name=f"inb_cache_clearer_for_{self.base_url}"
|
|
382
|
+
)
|
|
379
383
|
return self
|
|
380
384
|
|
|
381
385
|
async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:
|
|
@@ -390,12 +394,12 @@ class XUIClient:
|
|
|
390
394
|
exc_tb: The exception traceback, if an exception occurred.
|
|
391
395
|
"""
|
|
392
396
|
if exc_type is None or exc_type is asyncio.exceptions.CancelledError:
|
|
393
|
-
logging.info("Client is disconnecting
|
|
397
|
+
logging.info("Client is disconnecting (panel: %s)", self.panel_id or self.base_host)
|
|
394
398
|
else:
|
|
395
399
|
logging.warning("Client is disconnecting due to an error (may be unrelated):"
|
|
396
400
|
"\n%s, with value %s\nStacktrace:%s",
|
|
397
401
|
exc_type, exc_val, exc_tb, exc_info=exc_tb)
|
|
398
|
-
print(f"Client is disconnecting: {self.base_host}")
|
|
402
|
+
print(f"Client is disconnecting: {self.panel_id or self.base_host}")
|
|
399
403
|
await self.disconnect()
|
|
400
404
|
return
|
|
401
405
|
|
|
@@ -424,16 +428,20 @@ class XUIClient:
|
|
|
424
428
|
|
|
425
429
|
return tuple(usable_inbounds)
|
|
426
430
|
|
|
427
|
-
async def _clear_prod_inbound_cache_task(self):
|
|
431
|
+
async def _clear_prod_inbound_cache_task(self, *, create_new: bool = False):
|
|
428
432
|
"""Refresh the production inbound cache in the background.
|
|
429
433
|
|
|
430
434
|
The async context manager starts this loop after login. Each cycle
|
|
431
435
|
clears the cached production inbound list, repopulates it from the
|
|
432
436
|
panel, and then waits before refreshing again.
|
|
437
|
+
|
|
438
|
+
create_new param is kw-only and for people who know what they're doing, so they won't get the warning.
|
|
433
439
|
"""
|
|
434
|
-
if self._cache_cleaner_task is not None:
|
|
440
|
+
if (self._cache_cleaner_task is not None) and (not create_new):
|
|
435
441
|
logging.warning("You're trying to create another cache cleaner task, which is a FaF (Fire-And-Forget)."
|
|
436
442
|
"Please destroy the previous task and set _cache_cleaner_task to None, if you know what you're doing.")
|
|
443
|
+
return
|
|
444
|
+
logging.info("Initializing cache cleaner task for %s", self.panel_id)
|
|
437
445
|
while self.connected:
|
|
438
446
|
self.get_production_inbounds.cache_clear()
|
|
439
447
|
await self.get_production_inbounds() # fill the cache
|
|
@@ -525,7 +533,8 @@ class XUIClient:
|
|
|
525
533
|
raise custom_exceptions.ClientEmailAlreadyExistsError(json_resp["msg"])
|
|
526
534
|
return responses
|
|
527
535
|
|
|
528
|
-
async def _find_client_in_inbound(self, client_uuid: str, inbound_id: int,
|
|
536
|
+
async def _find_client_in_inbound(self, client_uuid: str, inbound_id: int,
|
|
537
|
+
use_cache=False) -> SingleInboundClient | None:
|
|
529
538
|
"""Note:
|
|
530
539
|
Cached production inbounds can be stale because the panel may be
|
|
531
540
|
changed by another actor. If a cached production inbound misses the
|
|
@@ -642,6 +651,7 @@ class XUIClient:
|
|
|
642
651
|
enable: bool | None = None,
|
|
643
652
|
sub_id: str | None = None,
|
|
644
653
|
comment: str | None = None,
|
|
654
|
+
email: str | None = None,
|
|
645
655
|
verbose: bool = True) -> Response:
|
|
646
656
|
"""
|
|
647
657
|
Update a client in a specific inbound by Telegram ID. NOT optimized for multiple inbounds.
|
|
@@ -658,6 +668,7 @@ class XUIClient:
|
|
|
658
668
|
enable: Whether the client is enabled (optional)
|
|
659
669
|
sub_id: Subscription ID (optional)
|
|
660
670
|
comment: Client comment/note (optional)
|
|
671
|
+
email: New client email (optional). USE WITH CAUTION BECAUSE THE PANEL WILL NOT TRACK THE NEW EMAIL.
|
|
661
672
|
|
|
662
673
|
Returns:
|
|
663
674
|
Response from the API
|
|
@@ -674,6 +685,7 @@ class XUIClient:
|
|
|
674
685
|
inbound_id=inbound_id, client_uuid=util.get_uuid_from_tgid(telegram_id),
|
|
675
686
|
security=security,
|
|
676
687
|
password=password,
|
|
688
|
+
email=email,
|
|
677
689
|
flow=flow,
|
|
678
690
|
limit_ip=limit_ip,
|
|
679
691
|
limit_gb=limit_gb,
|
|
@@ -9,6 +9,7 @@ from . import util
|
|
|
9
9
|
if TYPE_CHECKING:
|
|
10
10
|
from .api import XUIClient
|
|
11
11
|
|
|
12
|
+
|
|
12
13
|
class BaseModel(pydantic.BaseModel):
|
|
13
14
|
"""Base model for all 3X-UI API data models.
|
|
14
15
|
|
|
@@ -22,13 +23,12 @@ class BaseModel(pydantic.BaseModel):
|
|
|
22
23
|
ERROR_RETRIES: ClassVar[int] = 5
|
|
23
24
|
ERROR_RETRY_COOLDOWN: ClassVar[int] = 1
|
|
24
25
|
|
|
25
|
-
model_config = pydantic.ConfigDict(ignored_types=(cached_property,
|
|
26
|
+
model_config = pydantic.ConfigDict(ignored_types=(cached_property,), validate_by_name=True, validate_by_alias=True)
|
|
26
27
|
|
|
27
28
|
# def model_post_init(self, context: Any, /) -> None:
|
|
28
29
|
# #print(f"Model {self.__class__}, {self} initialized")
|
|
29
30
|
# ...
|
|
30
31
|
|
|
31
|
-
|
|
32
32
|
@classmethod
|
|
33
33
|
def from_list(cls, args: List[Dict[str, Any]],
|
|
34
34
|
) -> List[Self]:
|
|
@@ -50,7 +50,7 @@ class BaseModel(pydantic.BaseModel):
|
|
|
50
50
|
cls,
|
|
51
51
|
response: httpx.Response,
|
|
52
52
|
client: "XUIClient",
|
|
53
|
-
expect: list|dict,
|
|
53
|
+
expect: list | dict,
|
|
54
54
|
auto_retry: bool = True
|
|
55
55
|
) -> Union[Self, List[Self]]:
|
|
56
56
|
"""Create model instance(s) from an HTTP response.
|
|
@@ -63,7 +63,7 @@ class SingleInboundClient(base_model.BaseModel):
|
|
|
63
63
|
reset: int = 0
|
|
64
64
|
#Interestingly, the API expects this value to be called GB but it's actually bytes.
|
|
65
65
|
# I want the pythonic side to be in GB (hence why floats, i.e. 2.5GB), but the API expects bytes.
|
|
66
|
-
limit_gb: Annotated[int
|
|
66
|
+
limit_gb: Annotated[int, Field(alias="totalGB")] = 0 # total flow
|
|
67
67
|
expiry_time: Annotated[timestamp_seconds, Field(alias="expiryTime")] = 0
|
|
68
68
|
enable: bool = True
|
|
69
69
|
tg_id: Annotated[Union[int, str], Field(alias="tgId")] = ""
|
|
@@ -88,15 +88,15 @@ class SingleInboundClient(base_model.BaseModel):
|
|
|
88
88
|
|
|
89
89
|
@field_serializer("limit_gb")
|
|
90
90
|
@classmethod
|
|
91
|
-
def serialize_total_gb(cls, value: int
|
|
91
|
+
def serialize_total_gb(cls, value: int) -> int:
|
|
92
92
|
#API expects an integer of bytes.
|
|
93
93
|
return value * (1024 ** 3)
|
|
94
94
|
|
|
95
95
|
@field_validator("limit_gb", mode="after")
|
|
96
96
|
@classmethod
|
|
97
|
-
def parse_total_gb(cls, value: int) -> int
|
|
97
|
+
def parse_total_gb(cls, value: int) -> int:
|
|
98
98
|
#Python wants an int/float of GB.
|
|
99
|
-
return value
|
|
99
|
+
return value // (1024 ** 3)
|
|
100
100
|
|
|
101
101
|
|
|
102
102
|
class ClientsSettings(base_model.BaseModel):
|
|
@@ -295,7 +295,7 @@ class Inbound(base_model.BaseModel):
|
|
|
295
295
|
def parse_settings(cls, value: str) -> ClientsSettings:
|
|
296
296
|
if value == "":
|
|
297
297
|
return ClientsSettings(clients=[])
|
|
298
|
-
return ClientsSettings.model_validate_json(value, by_alias=True, extra="
|
|
298
|
+
return ClientsSettings.model_validate_json(value, by_alias=True, extra="ignore")
|
|
299
299
|
|
|
300
300
|
@field_serializer("settings")
|
|
301
301
|
@classmethod
|
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
<h1>Hi! This is my example python 3x-ui wrapper!</h1>
|
|
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
|
-
<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
|
-
|
|
5
|
-
<h2>0.0.9 Release Notes</h2>
|
|
6
|
-
<ul>
|
|
7
|
-
<li>Fix _request_update_client for it to actually work and NOT create "zombies"</li>
|
|
8
|
-
<li>DTO un-split because fields reset when not provided, so full inbounds must be fetched</li>
|
|
9
|
-
<li>New method: update_client_by_tgid</li>
|
|
10
|
-
<li>Fixed test suite</li>
|
|
11
|
-
<li>Fix from_response and from_list</li>
|
|
12
|
-
<li>Remove obsolete and useless client fields from models</li>
|
|
13
|
-
<li>Inbound settings actually get parsed properly into ClientsSettings</li>
|
|
14
|
-
<li>New asyncio task management so they won't get destroyed when GCed</li>
|
|
15
|
-
<li>XUIClient async_lru cache now binds to event loop at runtime, not in initialization</li>
|
|
16
|
-
</ul>
|
|
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.9.post2 → python_3xui-0.0.9.post3}/tests/test_non_idempotent_endpoints_clients.py
RENAMED
|
File without changes
|
{python_3xui-0.0.9.post2 → python_3xui-0.0.9.post3}/tests/test_non_idempotent_endpoints_inbounds.py
RENAMED
|
File without changes
|
|
File without changes
|