Python-3xui 0.0.8__py3-none-any.whl → 0.0.9__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/__init__.py CHANGED
@@ -1,5 +1,7 @@
1
1
  from .api import XUIClient
2
2
 
3
3
  __author__ = "JustMe_001"
4
- __version__ = "0.0.1"
5
- __email__ = ""
4
+ __version__ = "0.0.9"
5
+ __email__ = ""
6
+
7
+
python_3xui/api.py CHANGED
@@ -1,21 +1,24 @@
1
+ import asyncio
1
2
  import json
2
3
  import logging
3
4
  import re
4
- import time
5
+ from asyncio import Task
5
6
  from collections.abc import Sequence, Mapping
6
- from logging import DEBUG
7
- from typing import Self, Optional, Dict, Iterable, AsyncIterable, Type, Union, Any, List, Tuple, Literal
8
7
  from datetime import datetime, UTC
8
+ from inspect import iscoroutinefunction
9
+ from logging import DEBUG
10
+ from typing import Self, Optional, Dict, Iterable, AsyncIterable, Type, Union, Any, List, Tuple, Literal, Callable, Awaitable, overload
9
11
 
12
+ import httpx
10
13
  import pyotp
11
- from httpx import Response, AsyncClient
12
14
  from async_lru import alru_cache
13
- import asyncio
14
- import httpx
15
+ from httpx import Response, AsyncClient, Request
16
+ from pydantic import SecretStr
15
17
 
18
+ from . import custom_exceptions
16
19
  from . import util
17
20
  from .models import Inbound, SingleInboundClient, ClientStats
18
- from .util import JsonType, async_range, check_xui_response
21
+ from .util import JsonType, async_range
19
22
 
20
23
  DataType: Type[str | bytes | Iterable[bytes] | AsyncIterable[bytes]] = Union[str, bytes, Iterable[bytes], AsyncIterable[bytes]]
21
24
  PrimitiveData = Optional[Union[str, int, float, bool]]
@@ -41,9 +44,6 @@ class XUIClient:
41
44
  This class provides methods for authenticating with the 3X-UI panel,
42
45
  managing sessions, and performing operations on inbounds and clients.
43
46
 
44
- The client implements a singleton pattern to ensure only one instance
45
- exists at a time.
46
-
47
47
  Attributes:
48
48
  PROD_STRING: String used to identify production inbounds.
49
49
  session: The async HTTP client session.
@@ -62,12 +62,14 @@ class XUIClient:
62
62
  clients_end: Clients endpoint handler.
63
63
  inbounds_end: Inbounds endpoint handler.
64
64
  """
65
- _instance = None
66
65
 
67
66
  def __init__(self, base_website: str, base_port: int, base_path: str,
68
67
  *, username: str | None = None, password: str | None = None,
69
68
  two_fac_code: str | None = None, session_duration: int = 3600,
70
- custom_prod_string: str = "testing") -> None:
69
+ custom_prod_string: str = "testing",
70
+ max_retries: int = 5, retry_delay = 1,
71
+ custom_sub_generator: Callable[[int], str]|Callable[[int], Awaitable[str]] = util.default_sub_from_tgid,
72
+ ) -> None:
71
73
  """Initialize the XUIClient.
72
74
 
73
75
  Args:
@@ -91,27 +93,43 @@ class XUIClient:
91
93
  self.session_duration: int = session_duration
92
94
  self.xui_username: str | None = username
93
95
  self.xui_password: str | None = password
94
- self.two_fac_secret: str | None = two_fac_code
96
+ self.two_fac_secret: SecretStr | None = SecretStr(two_fac_code) if two_fac_code is not None else None
95
97
  self.totp: pyotp.TOTP | None = None
96
- self.max_retries: int = 5
97
- self.retry_delay: int = 1
98
+ self.max_retries: int = max_retries
99
+ self.retry_delay: int = retry_delay
100
+ self.sub_gen = custom_sub_generator
98
101
  # endpoints
99
102
  self.server_end = endpoints.Server(self)
100
103
  self.clients_end = endpoints.Clients(self)
101
104
  self.inbounds_end = endpoints.Inbounds(self)
105
+ # Per-instance cache wrapper. Using a class-level @alru_cache() on the underlying coroutine binds the cache to
106
+ # the first event loop that touches it (see async_lru._check_loop), which breaks any caller that creates
107
+ # a new XUIClient on a fresh loop (e.g. each pytest-asyncio test). Building the wrapper here gives every
108
+ # instance its own cache bound to its own loop.
109
+ self.get_production_inbounds = alru_cache(maxsize=128)(self._get_production_inbounds_impl)
110
+ self._cache_cleaner_task: Task|None = None
102
111
  #init self.totp
103
112
  if self.two_fac_secret:
104
- if self.two_fac_secret.isdigit() and len(self.two_fac_secret) <= 8:
113
+ if len(self.two_fac_secret.get_secret_value()) <= 8:
105
114
  print("WARNING: You seem to have entered a 2FA **code**, not a 2FA secret."
106
115
  "Although entering the secret is dangerous, there is no other way to provide a consistent way"
107
116
  "for continuous login. This code will only work for this specific login.")
108
117
  self.totp = None
109
118
  else:
110
- self.totp = pyotp.TOTP(self.two_fac_secret)
119
+ self.totp = pyotp.TOTP(self.two_fac_secret.get_secret_value())
111
120
 
112
121
  #========================request stuffs========================
122
+ @overload
123
+ async def _safe_request(self, *, request_to_send: httpx.Request) -> Response:
124
+ ...
125
+
126
+ @overload
127
+ async def _safe_request(self, method: Literal["get", "post", "patch", "delete", "put"],
128
+ **kwargs) -> Response:
129
+ ...
130
+
113
131
  async def _safe_request(self,
114
- method: Literal["get", "post", "patch", "delete", "put"],
132
+ method: Literal["get", "post", "patch", "delete", "put"]|None=None,
115
133
  **kwargs) -> Response:
116
134
  """Execute an HTTP request with automatic retry on database lock.
117
135
 
@@ -128,9 +146,23 @@ class XUIClient:
128
146
  Raises:
129
147
  RuntimeError: If max retries exceeded or session is invalid.
130
148
  """
131
- logging.debug("Safe request is running to %s%s", str(self.session.base_url), str(kwargs["url"]))
149
+ if "request_to_send" in kwargs and len(kwargs.keys()) != 1:
150
+ raise ValueError("It's either a predetermined a request or args to build your own.")
151
+ if not "request_to_send" in kwargs:
152
+ if method is None:
153
+ raise ValueError("If there's no prebuilt request, you must provide a method.")
154
+
155
+ #FIXME: make it also extract JSON out of a ready request
156
+ logging.info("Safe %s is running to %s%s\nJSON Payload: %s",
157
+ method, str(self.session.base_url), str(kwargs["url"]) or kwargs["request_to_send"].url,
158
+ json.dumps(kwargs["json"]) if "json" in kwargs.keys() else "(no payload)")
132
159
  async for attempt in async_range(self.max_retries):
133
- resp = await self.session.request(method=method, **kwargs)
160
+ if "request_to_send" in kwargs:
161
+ _request: Request = kwargs["request_to_send"]
162
+ resp = await self.session.send(_request)
163
+ else:
164
+ # noinspection PyTypeChecker
165
+ resp = await self.session.request(method, **kwargs)
134
166
  if resp.status_code // 100 != 2: #because it can return either 201 or 202
135
167
  if resp.status_code == 404:
136
168
  now: float = datetime.now(UTC).timestamp()
@@ -257,7 +289,7 @@ class XUIClient:
257
289
  payload["twoFactorCode"] = self.totp.now()
258
290
  else:
259
291
  if self.two_fac_secret:
260
- payload["twoFactorCode"] = self.two_fac_secret
292
+ payload["twoFactorCode"] = self.two_fac_secret.get_secret_value()
261
293
 
262
294
  logging.info("Client is logging in with IP/Domain: %s", self.base_host)
263
295
  resp = await self.session.post("/login", data=payload)
@@ -289,8 +321,12 @@ class XUIClient:
289
321
 
290
322
  This method closes the async HTTP client session.
291
323
  """
324
+ if self._cache_cleaner_task is not None:
325
+ self._cache_cleaner_task.cancel("Panel is exiting.")
292
326
  self.connected = False
293
- await self.session.aclose()
327
+
328
+ if self.session is not None:
329
+ await self.session.aclose()
294
330
 
295
331
  async def __aenter__(self) -> Self:
296
332
  """Enter the async context manager.
@@ -304,7 +340,9 @@ class XUIClient:
304
340
  """
305
341
  self.connect()
306
342
  await self.login()
307
- asyncio.create_task(self.clear_prod_inbound_cache())
343
+ self._cache_cleaner_task = asyncio.create_task(
344
+ self.clear_prod_inbound_cache(), name=f"inb_cache_clearer_for_{self.base_url}"
345
+ )
308
346
  return self
309
347
 
310
348
  async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:
@@ -323,21 +361,22 @@ class XUIClient:
323
361
  else:
324
362
  logging.warning("Client is disconnecting due to an error (may be unrelated):"
325
363
  "\n%s, with value %s\nStacktrace:%s",
326
- exc_type, exc_val, exc_tb)
364
+ exc_type, exc_val, exc_tb, exc_info=exc_tb)
327
365
  print(f"Client is disconnecting: {self.base_host}")
328
366
  await self.disconnect()
329
367
  return
330
368
 
331
369
  #========================inbound management========================
332
- @alru_cache()
333
- async def get_production_inbounds(self) -> Tuple[Inbound, ...]:
370
+ async def _get_production_inbounds_impl(self) -> tuple[Inbound, ...]:
334
371
  """Retrieve production inbounds.
335
372
 
336
373
  This method fetches all inbounds and filters them based on the
337
- production string. It is cached for efficiency.
374
+ production string. It is wrapped in a per-instance ``alru_cache``
375
+ in ``__init__`` and exposed as ``get_production_inbounds``; do not
376
+ call this method directly outside of that wrapper.
338
377
 
339
378
  Returns:
340
- List[Inbound]: A list of production inbounds.
379
+ tuple[Inbound]: A list of production inbounds.
341
380
 
342
381
  Raises:
343
382
  RuntimeError: If no production inbounds are found.
@@ -400,7 +439,6 @@ class XUIClient:
400
439
  expiry_time: int=0,
401
440
  exist_ok: bool = False
402
441
  ) -> list[Response]:
403
- #TODO: add exist_ok flag
404
442
  """Create and add a production client.
405
443
 
406
444
  This method creates a new client with the given Telegram ID and
@@ -418,9 +456,14 @@ class XUIClient:
418
456
  List[Response]: A list of responses from the server for each
419
457
  inbound the client was added to.
420
458
  """
421
- production_inbounds: List[Inbound] = await self.get_production_inbounds()
459
+ production_inbounds: tuple[Inbound, ...] = await self.get_production_inbounds()
422
460
 
423
461
  tasks = []
462
+ custom_sub: str
463
+ if iscoroutinefunction(self.sub_gen):
464
+ custom_sub = await self.sub_gen(telegram_id)
465
+ else:
466
+ custom_sub = self.sub_gen(telegram_id)
424
467
  for inb in production_inbounds:
425
468
  tmp_email = util.generate_email_from_tgid_inbid(telegram_id, inb.id)
426
469
  client = SingleInboundClient.model_construct(
@@ -429,7 +472,7 @@ class XUIClient:
429
472
  email=tmp_email,
430
473
  limit_gb=0,
431
474
  enable=True,
432
- subscription_id=util.sub_from_tgid(telegram_id),
475
+ subscription_id=custom_sub,
433
476
  comment=f"{additional_remark}, created at {datetime.now(UTC)}",
434
477
  expiry_time=expiry_time * 1000
435
478
  )
@@ -441,8 +484,29 @@ class XUIClient:
441
484
  json_resp = resp.json()
442
485
  if "duplicate email" in json_resp["msg"].lower():
443
486
  logging.error("ERROR: Client already exists and exist_ok not set: %s", json_resp["msg"])
487
+ raise custom_exceptions.ClientEmailAlreadyExistsError(json_resp["msg"])
444
488
  return responses
445
489
 
490
+ async def _find_client_in_inbound(self, client_uuid: str, inbound_id: int) -> SingleInboundClient|None:
491
+ prod_inbs = await self.get_production_inbounds() #check production first since they're all cached
492
+ prod_inb_index = None
493
+ for i, prod_inb in enumerate(prod_inbs): # see if inbound is production
494
+ if inbound_id == prod_inb.id:
495
+ prod_inb_index = i
496
+
497
+ if prod_inb_index is not None:
498
+ needed_inb: Inbound = prod_inbs[prod_inb_index]
499
+ for client in needed_inb.settings.clients:
500
+ if client.uuid == client_uuid:
501
+ return client
502
+ self.get_production_inbounds.cache_clear() # this means client is in a prod inbound but it's not refreshed
503
+
504
+ inb = await self.inbounds_end.get_specific_inbound(inbound_id)
505
+ for client in inb.settings.clients:
506
+ if client.uuid == client_uuid:
507
+ return client
508
+ return None
509
+
446
510
  async def update_client_by_tgid(self, telegram_id: int, inbound_id: int, /, *,
447
511
  security: str | None = None,
448
512
  password: str | None = None,
@@ -460,23 +524,21 @@ class XUIClient:
460
524
  Args:
461
525
  telegram_id: The Telegram ID of the client
462
526
  inbound_id: The ID of the inbound where the client exists
463
- security: Client security setting
464
- password: Client password
465
- flow: VLESS flow type
466
- limit_ip: IP connection limit
467
- limit_gb: Data limit in GB
468
- expiry_time: Client expiry time (UNIX timestamp)
469
- enable: Whether the client is enabled
470
- sub_id: Subscription ID
471
- comment: Client comment/note
527
+ security: Client security setting (optional)
528
+ password: Client password (optional)
529
+ flow: VLESS flow type (optional)
530
+ limit_ip: IP connection limit (optional)
531
+ limit_gb: Data limit in GB (optional)
532
+ expiry_time: Client expiry time (UNIX timestamp) (optional)
533
+ enable: Whether the client is enabled (optional)
534
+ sub_id: Subscription ID (optional)
535
+ comment: Client comment/note (optional)
472
536
 
473
537
  Returns:
474
538
  Response from the API
475
539
  """
476
- email = util.generate_email_from_tgid_inbid(telegram_id, inbound_id)
477
- existing_client = await self.clients_end.get_client_with_email(email)
478
540
  if verbose:
479
- if expiry_time < 1e9:
541
+ if expiry_time and expiry_time < 1e9:
480
542
  logging.warning("Warning: You're trying to update a client with expiry time %s. "
481
543
  "You set it to expire before 2001, likely because you provided the DURATION. "
482
544
  "You need to provide a TIMESTAMP. "
@@ -484,8 +546,7 @@ class XUIClient:
484
546
  expiry_time)
485
547
 
486
548
  resp = await self.clients_end.update_single_client(
487
- SingleInboundClient.model_validate(existing_client.model_dump()),
488
- inbound_id,
549
+ inbound_id=inbound_id, client_uuid=util.get_uuid_from_tgid(telegram_id),
489
550
  security=security,
490
551
  password=password,
491
552
  flow=flow,
python_3xui/base_model.py CHANGED
@@ -1,12 +1,9 @@
1
- import asyncio
2
- import logging
3
- from typing import TYPE_CHECKING, Any, Dict, List, Optional, Type, Union, overload, Self, ClassVar, Annotated, Literal, Callable
1
+ from functools import cached_property
2
+ from typing import TYPE_CHECKING, Any, Dict, List, Union, Self, ClassVar
4
3
 
5
- import pydantic
6
4
  import httpx
7
- from functools import cached_property
5
+ import pydantic
8
6
 
9
- from . import models
10
7
  from . import util
11
8
 
12
9
  if TYPE_CHECKING:
@@ -34,13 +31,11 @@ class BaseModel(pydantic.BaseModel):
34
31
 
35
32
  @classmethod
36
33
  def from_list(cls, args: List[Dict[str, Any]],
37
- client: "XUIClient"
38
34
  ) -> List[Self]:
39
35
  """Create a list of model instances from a list of dictionaries.
40
36
 
41
37
  Args:
42
38
  args: A list of dictionaries containing model data.
43
- client: The XUIClient instance to associate with each model.
44
39
 
45
40
  Returns:
46
41
  A list of model instances initialized with the provided data.
@@ -83,12 +78,14 @@ class BaseModel(pydantic.BaseModel):
83
78
  inbounds = await Inbound.from_response(response, client, list)
84
79
  """
85
80
  json_resp: util.JsonType = response.json()
86
- valid = util.check_xui_response(json_resp)
81
+ valid = await util.check_xui_response(json_resp)
87
82
  if valid == "OK":
88
83
  obj = json_resp["obj"]
89
84
  if expect is list:
90
- return cls.from_list(obj, client=client)
85
+ return cls.from_list(obj)
91
86
  if expect is dict:
92
- return cls(**obj, client=client)
87
+ return cls(**obj)
93
88
  else:
94
- raise ValueError(f"Invalid 3X-UI response, code {valid}. Don't use from_response on failed requests.")
89
+ req = response.request
90
+ new_resp = await client._safe_request(request_to_send=req)
91
+ return await cls.from_response(new_resp, client=client, expect=expect, auto_retry=False)
@@ -0,0 +1,12 @@
1
+
2
+ class ClientEmailAlreadyExistsError(Exception):
3
+ def __init__(self, *args):
4
+ super().__init__(args[0] if len(args) == 0 else args)
5
+
6
+ class EmailNotExistsError(Exception):
7
+ def __init__(self, *args):
8
+ super().__init__(args[0] if len(args) == 0 else args)
9
+
10
+ class ClientDoesNotExistError(Exception):
11
+ def __init__(self, *args):
12
+ super().__init__(args[0] if len(args) == 0 else args)
python_3xui/endpoints.py CHANGED
@@ -7,9 +7,9 @@ from httpx import Response
7
7
  from pydantic import ValidationError
8
8
  from pydantic.main import ModelT
9
9
 
10
- from . import models
11
10
  from .api import XUIClient
12
- from .models import Inbound, SingleInboundClient
11
+ from .custom_exceptions import ClientDoesNotExistError
12
+ from .models import Inbound, SingleInboundClient, ClientStats, InboundClients, timestamp_seconds, ClientsSettings
13
13
  from .util import JsonType
14
14
 
15
15
 
@@ -127,22 +127,22 @@ class Inbounds(BaseEndpoint):
127
127
  A list of Inbound model instances.
128
128
  """
129
129
  endpoint = "/list"
130
- json = await self._simple_get(f"{endpoint}")
131
- inbounds = Inbound.from_list(json, client=self.client)
130
+ json_resp = await self._simple_get(f"{endpoint}")
131
+ inbounds = Inbound.from_list(json_resp)
132
132
  return inbounds
133
133
 
134
- async def get_specific_inbound(self, id) -> Inbound:
134
+ async def get_specific_inbound(self, inbound_id) -> Inbound:
135
135
  """Retrieve a specific inbound by ID.
136
136
 
137
137
  Args:
138
- id: The ID of the inbound to retrieve.
138
+ inbound_id: The ID of the inbound to retrieve.
139
139
 
140
140
  Returns:
141
141
  An Inbound model instance for the specified ID.
142
142
  """
143
- endpoint = f"/get/{id}"
143
+ endpoint = f"/get/{inbound_id}"
144
144
  json = await self._simple_get(f"{endpoint}")
145
- inbound = Inbound(client=self.client, **json)
145
+ inbound = Inbound(**json)
146
146
  return inbound
147
147
 
148
148
 
@@ -163,7 +163,7 @@ class Clients(BaseEndpoint):
163
163
 
164
164
  #although it's the same url, they should be differentiated
165
165
 
166
- async def get_client_with_email(self, email: str) -> models.ClientStats:
166
+ async def get_client_with_email(self, email: str) -> ClientStats:
167
167
  """Retrieve client statistics by email.
168
168
 
169
169
  Args:
@@ -174,9 +174,9 @@ class Clients(BaseEndpoint):
174
174
  """
175
175
  endpoint = f"getClientTraffics/{email}"
176
176
  resp = await self._simple_get(endpoint)
177
- return models.ClientStats.model_validate(resp)
177
+ return ClientStats.model_validate(resp)
178
178
 
179
- async def get_client_with_uuid(self, uuid: str) -> List[models.ClientStats]:
179
+ async def get_client_with_uuid(self, uuid: str) -> List[ClientStats]:
180
180
  """Retrieve client statistics by UUID.
181
181
 
182
182
  Args:
@@ -187,11 +187,10 @@ class Clients(BaseEndpoint):
187
187
  """
188
188
  endpoint = f"getClientTrafficsById/{uuid}"
189
189
  resp = await self._simple_get(endpoint)
190
- client_stats = models.ClientStats.from_list(resp, client=self.client)
190
+ client_stats = ClientStats.from_list(resp)
191
191
  return client_stats
192
192
 
193
-
194
- async def add_client(self, client: models.InboundClients | models.SingleInboundClient | Dict,
193
+ async def add_client(self, client: InboundClients | SingleInboundClient | Dict,
195
194
  inbound_id: int | None = None) -> Response:
196
195
  """Add a new client to an inbound.
197
196
 
@@ -213,78 +212,73 @@ class Clients(BaseEndpoint):
213
212
  endpoint = f"addClient"
214
213
  if isinstance(client, Dict):
215
214
  try:
216
- client = str(client)
217
- final = models.InboundClients.model_validate_json(client)
215
+ final = InboundClients.model_validate(client)
218
216
  except ValidationError:
219
- # if there is in fact an error, I want it to raise
220
- tmp = models.SingleInboundClient.model_validate_json(client)
217
+ # Check the SingleInboundClient now...
218
+ tmp = SingleInboundClient.model_validate(client)
221
219
  if inbound_id:
222
- final = models.InboundClients(id=inbound_id,
223
- settings=models.InboundClients.Settings(clients=[tmp]))
220
+ final = InboundClients(id=inbound_id,
221
+ settings=ClientsSettings(clients=[tmp]))
224
222
  else:
225
223
  raise ValueError("A single client was provided to be added but no parent inbound id")
226
- elif isinstance(client, models.SingleInboundClient):
227
- final = models.InboundClients(id=inbound_id,
228
- settings=models.InboundClients.Settings(clients=[client]))
229
- elif isinstance(client, models.InboundClients):
224
+ elif isinstance(client, SingleInboundClient):
225
+ final = InboundClients(id=inbound_id,
226
+ settings=ClientsSettings(clients=[client]))
227
+ elif isinstance(client, InboundClients):
230
228
  final = client
231
229
  if inbound_id:
232
230
  final.parent_id = inbound_id
233
231
  else:
234
232
  raise TypeError
235
233
  # send request
236
- data = final.model_dump(by_alias=True)
237
- resp = await self.client.safe_post(f"{self._url}{endpoint}", data=data)
238
- #YOU NEED TO PASS SETTINGS AS A STRING, NOT AS A DICT, YOU FUCKING DUMBASS!
234
+ data = json.loads(final.model_dump_json(by_alias=True))
235
+ resp = await self.client.safe_post(f"{self._url}{endpoint}", json=data)
236
+ #YOU NEED TO PASS SETTINGS AS A STRING, NOT AS A DICT, YOU IDIOT!
239
237
  return resp
240
238
 
241
- async def _request_update_client(self, client: models.InboundClients | models.SingleInboundClient,
239
+ async def _request_update_client(self, client: InboundClients | SingleInboundClient,
242
240
  inbound_id: int | None = None,
243
241
  *, original_uuid: str | None = None) -> Response:
244
242
  """Request to update an existing client.
245
243
 
246
244
  Args:
247
245
  client: The client data to update. Can be:
246
+ - A ClientUpdatePayload - Recommended (requires inbound_id)
248
247
  - A SingleInboundClient (requires inbound_id)
249
248
  - An InboundClients object (with one client)
250
249
  inbound_id: The ID of the inbound the client belongs to.
251
- Required if client is a SingleInboundClient.
250
+ Required if client is a SingleInboundClient or ClientUpdatePayload.
252
251
  original_uuid: The original UUID of the client to update.
253
- Required if client is a SingleInboundClient.
252
+ Required if client is a SingleInboundClient or ClientUpdatePayload.
254
253
 
255
254
  Returns:
256
255
  The HTTP response from the API.
257
256
  """
258
- if isinstance(client, models.SingleInboundClient):
259
- if inbound_id is None:
260
- raise ValueError("Provide a parent inbound ID or pass models.InboundClients")
261
- client = models.InboundClients(parent_id=inbound_id,
262
- settings=models.InboundClients.Settings(clients=[client]))
263
- else:
264
- if len(client.settings.clients) != 1:
265
- raise ValueError(f"You can only update 1 client at a time, instead got {len(client.settings.clients)}")
266
-
257
+ if isinstance(client, SingleInboundClient):
258
+ client = InboundClients(id=inbound_id, settings=ClientsSettings(clients=[client]))
267
259
  _endpoint = f"updateClient/{original_uuid if original_uuid else client.settings.clients[0].uuid}"
268
- resp = await self.client.safe_post(f"{self._url}{_endpoint}", json=client.model_dump_json())
269
-
260
+ #we have to do this because if we do model.dump() it will return a Settings **OBJECT** which we DON'T want.
261
+ resp = await self.client.safe_post(f"{self._url}{_endpoint}",
262
+ json=json.loads(client.model_dump_json(exclude_none=True, by_alias=True)))
270
263
  return resp
271
264
 
272
- async def update_single_client(self, existing_client: SingleInboundClient, inbound_id: int, /, *,
265
+
266
+ async def update_single_client(self, inbound_id: int, client_uuid: str, *,
273
267
  security: str | None = None,
274
268
  password: str | None = None,
275
269
  flow: Literal["", "xtls-rprx-vision", "xtls-rprx-vision-udp443"] | None = None,
276
270
  email: str | None = None,
277
271
  limit_ip: int | None = None,
278
272
  limit_gb: int | None = None,
279
- expiry_time: models.timestamp_seconds | None = None,
273
+ expiry_time: timestamp_seconds | None = None,
280
274
  enable: bool | None = None,
281
275
  sub_id: str | None = None,
282
276
  comment: str | None = None,
283
- ):
277
+ ) -> Response:
284
278
  """Update an existing client's details.
285
279
 
286
280
  Args:
287
- existing_client: The existing client object to update.
281
+ client_uuid: The UUID of the original client.
288
282
  inbound_id: The ID of the inbound the client belongs to.
289
283
  security: New security settings (optional).
290
284
  password: New password (optional).
@@ -302,13 +296,17 @@ class Clients(BaseEndpoint):
302
296
  """
303
297
  # Collect only the arguments that were explicitly provided (not None)
304
298
  changes = {k: v for k, v in locals().items()
305
- if k != 'self' and k != 'existing_client' and v is not None}
299
+ if k not in ("self", "inbound_id", "client_uuid", "changes") and v is not None}
306
300
  # Rename sub_id to subscription_id if needed
307
301
  if 'sub_id' in changes:
308
302
  changes['subscription_id'] = changes.pop('sub_id')
309
- changes["updated_at"] = int(datetime.now(UTC).timestamp())
310
- updated = existing_client.model_copy(update=changes)
311
303
 
304
+ found_inbound = await self.client._find_client_in_inbound(client_uuid, inbound_id)
305
+ if not found_inbound:
306
+ raise ClientDoesNotExistError(f"The target inbound was checked but client {client_uuid} was not found.")
307
+
308
+ changes["updated_at"] = int(datetime.now(UTC).timestamp())
309
+ updated = found_inbound.model_copy(update=changes)
312
310
  resp = await self._request_update_client(updated, inbound_id)
313
311
  return resp
314
312
 
python_3xui/models.py CHANGED
@@ -4,6 +4,7 @@ from typing import Union, TypeAlias, Any, Annotated, Literal, List, Dict, ClassV
4
4
 
5
5
  import pydantic
6
6
  from pydantic import field_validator, Field, field_serializer
7
+ from typing_extensions import TypeVar
7
8
 
8
9
  from . import base_model
9
10
  from .util import JsonType, auto_s_to_ms_timestamp, auto_ms_to_s_timestamp
@@ -12,6 +13,7 @@ timestamp_seconds: TypeAlias = int
12
13
  ip_address: TypeAlias = str
13
14
  json_string: TypeAlias = str
14
15
 
16
+
15
17
  def exclude_if_none(field) -> bool:
16
18
  """Check if a field value is None for exclusion purposes.
17
19
 
@@ -25,6 +27,10 @@ def exclude_if_none(field) -> bool:
25
27
  return True
26
28
  return False
27
29
 
30
+
31
+ _IntNone = TypeVar("_IntNone", bound=int | None)
32
+
33
+
28
34
  class SingleInboundClient(pydantic.BaseModel):
29
35
  """Represents a single client within a VLESS/VMess inbound.
30
36
 
@@ -48,14 +54,15 @@ class SingleInboundClient(pydantic.BaseModel):
48
54
  updated_at: Timestamp of last client update.
49
55
  """
50
56
  TIME_FIELDS: ClassVar[List[str]] = ["expiry_time", "created_at", "updated_at"]
51
- uuid: Annotated[str, Field(alias="id")] #yes they really did that...
57
+ uuid: Annotated[str, Field(alias="id")] #yes they really did that...
52
58
  security: str = ""
53
59
  password: str = ""
54
60
  flow: Literal["", "xtls-rprx-vision", "xtls-rprx-vision-udp443"]
55
61
  email: Annotated[str, Field(alias="email")]
56
62
  limit_ip: Annotated[int, Field(alias="limitIp")] = 20
63
+ reset: int = 0
57
64
  #Interestingly, the API expects this value to be called GB but it's actually bytes.
58
- limit_gb: Annotated[int, Field(alias="totalGB")] # total flow
65
+ limit_gb: Annotated[int, Field(alias="totalGB")] = 0 # total flow
59
66
  expiry_time: Annotated[timestamp_seconds, Field(alias="expiryTime")] = 0
60
67
  enable: bool = True
61
68
  tg_id: Annotated[Union[int, str], Field(alias="tgId")] = ""
@@ -68,19 +75,35 @@ class SingleInboundClient(pydantic.BaseModel):
68
75
  default_factory=(lambda: int(datetime.now(UTC).timestamp())))
69
76
  ]
70
77
 
71
- @classmethod
78
+ # noinspection PyNestedDecorators
72
79
  @field_validator(TIME_FIELDS[0], *TIME_FIELDS[1:], mode="after")
73
- def ensure_s_timestamp(cls, value: int) -> int:
80
+ @classmethod
81
+ def ensure_s_timestamp(cls, value: _IntNone) -> _IntNone:
74
82
  return auto_ms_to_s_timestamp(value)
75
83
 
76
- @classmethod
84
+ # noinspection PyNestedDecorators
77
85
  @field_serializer(TIME_FIELDS[0], *TIME_FIELDS[1:])
78
- def serialize_ms_timestamp(cls, value: int) -> int:
79
- return auto_s_to_ms_timestamp(value)
86
+ @classmethod
87
+ def serialize_ms_timestamp(cls, value: _IntNone) -> _IntNone:
88
+ return auto_s_to_ms_timestamp(value) if value is not None else None
80
89
 
90
+ # noinspection PyNestedDecorators
81
91
  @field_serializer("limit_gb")
82
- def serialize_total_gb(self, value: int) -> int:
83
- return value * (1024 ** 2) # Convert GB to bytes for API
92
+ @classmethod
93
+ def serialize_total_gb(cls, value: _IntNone) -> _IntNone:
94
+ return value * (1024 ** 3) if value is not None else None
95
+
96
+
97
+ class ClientsSettings(pydantic.BaseModel):
98
+ """Settings container for inbound clients.
99
+
100
+ Attributes:
101
+ clients: List of SingleInboundClient objects.
102
+ """
103
+ clients: list[SingleInboundClient]
104
+ decryption: Annotated[str, Field(exclude_if=lambda x: x == "none")] = "none"
105
+ encryption: Annotated[str, Field(exclude_if=lambda x: x == "none")] = "none"
106
+ fallbacks: Annotated[list|None, Field(exclude_if=exclude_if_none)] = None
84
107
 
85
108
 
86
109
  class InboundClients(pydantic.BaseModel):
@@ -94,24 +117,16 @@ class InboundClients(pydantic.BaseModel):
94
117
  settings: The settings object containing the client list.
95
118
  """
96
119
 
97
- class Settings(pydantic.BaseModel):
98
- """Settings container for inbound clients.
99
-
100
- Attributes:
101
- clients: List of SingleInboundClient objects.
102
- """
103
- clients: list[SingleInboundClient]
104
-
105
- parent_id: Annotated[int|None, Field(exclude_if=exclude_if_none, alias="id")] = None
106
- settings: Settings
120
+ parent_id: Annotated[int | None, Field(exclude_if=exclude_if_none, alias="id")] = None
121
+ settings: ClientsSettings
107
122
 
108
123
  @field_serializer("settings")
109
- def stringify_settings(self, value: Settings) -> str:
124
+ def stringify_settings(self, value: ClientsSettings) -> str:
110
125
  """Serialize the settings object to a JSON string.
111
126
 
112
127
  The 3X-UI API expects settings as a JSON string, not an object.
113
128
  """
114
- return json.dumps(value.model_dump(by_alias=True), ensure_ascii=False)
129
+ return json.dumps(value.model_dump(by_alias=True, exclude_none=True), ensure_ascii=False)
115
130
 
116
131
 
117
132
  #class InboundSettings(base_model.BaseModel):
@@ -256,19 +271,30 @@ class Inbound(base_model.BaseModel):
256
271
  expiryTime: timestamp_seconds # UNIX timestamp
257
272
  trafficReset: str # "Never", "Weekly", "Monthly", "Daily"
258
273
  lastTrafficResetTime: timestamp_seconds # UNIX timestamp
259
- clientStats: Union[list[ClientStats], None]
274
+ clientStats: list[ClientStats] | None
260
275
  listen: str
261
276
  port: int
262
277
  protocol: Literal["vless", "vmess", "trojan", "shadowsocks", "wireguard"] # note: there are some "deprecated" like wireguard
263
- settings: Union[json_string, Dict[Any, Any]] # JSON packed value, stringified
278
+ settings: ClientsSettings # JSON packed value, stringified
264
279
  streamSettings: Union[json_string, Dict[Any, Any]] # JSON packed value, stringified
265
280
  tag: str
266
281
  sniffing: Union[json_string, Dict[Any, Any]] # JSON packed value, stringified
267
282
 
268
- # noinspection PyNestedDecorators
269
- @field_validator('settings', 'streamSettings', 'sniffing', mode='after')
283
+ @field_validator("settings", mode="before")
270
284
  @classmethod
271
- def parse_json_fields(cls, value: str) -> JsonType|Literal[""]:
285
+ def parse_settings(cls, value: str) -> ClientsSettings:
286
+ if value == "":
287
+ return ClientsSettings(clients=[])
288
+ return ClientsSettings.model_validate_json(value, by_alias=True, extra="forbid")
289
+
290
+ @field_serializer("settings")
291
+ @classmethod
292
+ def dump_settings(cls, value: ClientsSettings) -> str:
293
+ return value.model_dump_json(by_alias=True)
294
+
295
+ @field_validator('streamSettings', 'sniffing', mode='after')
296
+ @classmethod
297
+ def parse_json_fields(cls, value: str) -> JsonType | Literal[""]:
272
298
  """Parse JSON string fields into dictionaries.
273
299
 
274
300
  The 3X-UI API returns settings, streamSettings, and sniffing as
@@ -278,10 +304,9 @@ class Inbound(base_model.BaseModel):
278
304
  return ""
279
305
  return json.loads(value)
280
306
 
281
- # noinspection PyNestedDecorators
282
- @field_serializer("settings", "streamSettings", "sniffing")
307
+ @field_serializer("streamSettings", "sniffing")
283
308
  @classmethod
284
- def stringify_json_fields(cls, value: Dict|Literal[""]) -> str:
309
+ def stringify_json_fields(cls, value: Dict | Literal[""]) -> str:
285
310
  """Serialize dictionary fields back to JSON strings.
286
311
 
287
312
  When sending data back to the API, these fields must be JSON strings.
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, dataclass_transform
17
+ from typing import TypeAlias, Union, Dict, Any, List
18
18
 
19
19
  import httpx
20
20
 
@@ -43,7 +43,7 @@ def camel_to_snake(name: str) -> str:
43
43
  return re.sub(_RE_CAMEL_TO_SNAKE2, r"\1_\2", name).lower()
44
44
 
45
45
 
46
- async def async_range(start, stop=None, step=1):
46
+ async def async_range(start: int, stop: int|None=None, step: int=1):
47
47
  """Async generator that yields values from a range.
48
48
 
49
49
  This is an async version of the built-in range() function that yields
@@ -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 sub_from_tgid(telegram_id: int) -> str:
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:
@@ -124,6 +124,12 @@ def get_uuid_from_tgid(telegram_id: int, fixed: bool = True) -> str:
124
124
  return f"{now.year}{mon}{day}-{hr}{mn}-1111-1111-{resid}"
125
125
 
126
126
 
127
+ def random_string(length: int):
128
+ s = "".join([random.choice(
129
+ "1234567890abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ") for _ in range(length)
130
+ ])
131
+ return s
132
+
127
133
  def generate_random_email(length: int = 8) -> str:
128
134
  """Generate a random alphanumeric email identifier.
129
135
 
@@ -136,10 +142,7 @@ def generate_random_email(length: int = 8) -> str:
136
142
  Examples:
137
143
  >>> generate_random_email(8) # Random output like 'aB3xY9zQ'
138
144
  """
139
- s = ""
140
- for i in range(length):
141
- s += random.choice("1234567890abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ")
142
- return s
145
+ return random_string(length)
143
146
 
144
147
 
145
148
  def generate_email_from_tgid_inbid(telegram_id: int, /, inbound_id: int) -> str:
@@ -174,10 +177,7 @@ def generate_new_subscription(length: int = 16):
174
177
  Examples:
175
178
  >>> generate_new_subscription(16) # Random output like 'aB3xY9zQmNpL2kJh'
176
179
  """
177
- s = ""
178
- for i in range(length):
179
- s += random.choice("1234567890abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ")
180
- return s
180
+ return random_string(length)
181
181
 
182
182
 
183
183
  async def check_xui_response(response: JsonType | httpx.Response) -> str:
@@ -230,13 +230,13 @@ def get_days_until_expiry(expiry_time: int) -> float:
230
230
 
231
231
  Returns:
232
232
  Number of days until expiry. Returns negative value if already expired.
233
- Returns a very 0 if expiry_time is 0 (no expiry).
233
+ Returns a 0 if expiry_time is 0 (no expiry).
234
234
 
235
235
  Examples:
236
236
  >>> get_days_until_expiry(int(datetime.now(UTC).timestamp()) + 86400) # 1 day from now
237
237
  1.0
238
238
  >>> get_days_until_expiry(0) # No expiry
239
- inf
239
+ 0
240
240
  """
241
241
  if expiry_time == 0:
242
242
  return 0
@@ -283,6 +283,6 @@ def auto_ms_to_s_timestamp(ms_or_s: int) -> int:
283
283
  return ms_to_s_timestamp(ms_or_s)
284
284
  return ms_or_s
285
285
 
286
- def datetime_now_ms(tzinfo: tzinfo|None) -> int:
286
+ def datetime_now_ms(tzinfo: tzinfo|None=UTC) -> int:
287
287
  """Get the current time as a UNIX timestamp in milliseconds."""
288
288
  return int(datetime.now(tzinfo).timestamp()) * 1000
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: Python-3xui
3
- Version: 0.0.8
3
+ Version: 0.0.9
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
@@ -12,11 +12,12 @@ Classifier: Intended Audience :: Developers
12
12
  Classifier: Operating System :: OS Independent
13
13
  Classifier: Programming Language :: Python :: 3
14
14
  Requires-Python: >=3.11
15
- Requires-Dist: async-lru~=2.2.0
15
+ Requires-Dist: async-lru~=2.3.0
16
16
  Requires-Dist: dotenv~=0.9.9
17
17
  Requires-Dist: httpx~=0.28.1
18
18
  Requires-Dist: pydantic<3,~=2.12.5
19
19
  Requires-Dist: pyotp~=2.9.0
20
+ Requires-Dist: python-dotenv
20
21
  Provides-Extra: testing
21
22
  Requires-Dist: pytest; extra == 'testing'
22
23
  Requires-Dist: pytest-asyncio; extra == 'testing'
@@ -28,9 +29,15 @@ Description-Content-Type: text/markdown
28
29
  <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
30
  <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
31
 
31
- <h2>0.0.8 Release Notes</h2>
32
+ <h2>0.0.9 Release Notes</h2>
32
33
  <ul>
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>
34
+ <li>Fix _request_update_client for it to actually work and NOT create "zombies"</li>
35
+ <li>DTO un-split because fields reset when not provided, so full inbounds must be fetched</li>
36
+ <li>New method: update_client_by_tgid</li>
37
+ <li>Fixed test suite</li>
38
+ <li>Fix from_response and from_list</li>
39
+ <li>Remove obsolete and useless client fields from models</li>
40
+ <li>Inbound settings actually get parsed properly into ClientsSettings</li>
41
+ <li>New asyncio task management so they won't get destroyed when GCed</li>
42
+ <li>XUIClient async_lru cache now binds to event loop at runtime, not in initialization</li>
36
43
  </ul>
@@ -0,0 +1,11 @@
1
+ python_3xui/__init__.py,sha256=tYFn1NixqukOf4c4JVsTFSXGvW-f_ErxqUbVJxYiJAU,100
2
+ python_3xui/api.py,sha256=D-3tA0i09Rhhv1wHfTbdefGN81BT4ESxMvM699R-SRA,25892
3
+ python_3xui/base_model.py,sha256=3jFVwPgMyVIck2W8vqzIz9cLookdleENUCCSIpdKISo,3211
4
+ python_3xui/custom_exceptions.py,sha256=hf25_vHhhYaAThhuVW8z-pNznlzU0j6yayVixP9d3XY,410
5
+ python_3xui/endpoints.py,sha256=HhO7z5IYp0gS0upKlfi3hcYwPPZWU17zWuElMwWOxnw,13391
6
+ python_3xui/models.py,sha256=B0BxOYFffsFn61YWHPj27VIS-N0J9uL5rUsRzV1pW1c,12262
7
+ python_3xui/util.py,sha256=SrOziaXxRj_YAHiUBjzmfRxWOKKLO80UgUUTnJ3tp-A,9239
8
+ python_3xui-0.0.9.dist-info/METADATA,sha256=evF8LSqVGIrbO9z9el67KIC5NvHv6AJvNyp_Hyb9C_8,1957
9
+ python_3xui-0.0.9.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
10
+ python_3xui-0.0.9.dist-info/licenses/LICENSE,sha256=QwcOLU5TJoTeUhuIXzhdCEEDDvorGiC6-3YTOl4TecE,11356
11
+ python_3xui-0.0.9.dist-info/RECORD,,
@@ -1,10 +0,0 @@
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,,