sub2api 0.1.0__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.
sub2api/__init__.py ADDED
@@ -0,0 +1,61 @@
1
+ from ._client import Sub2API
2
+ from ._exceptions import (
3
+ APIError,
4
+ AuthenticationError,
5
+ ConfigurationError,
6
+ ConflictError,
7
+ NotFoundError,
8
+ PermissionDeniedError,
9
+ ProtocolError,
10
+ RateLimitError,
11
+ Sub2APIError,
12
+ TransportError,
13
+ TwoFactorRequired,
14
+ ValidationError,
15
+ )
16
+ from ._models import (
17
+ Announcement,
18
+ APIKey,
19
+ Balance,
20
+ Group,
21
+ KeyGroupMultiplier,
22
+ Page,
23
+ PlatformQuota,
24
+ Redemption,
25
+ Resource,
26
+ SessionTokens,
27
+ Subscription,
28
+ UsageRecord,
29
+ User,
30
+ )
31
+
32
+ __all__ = [
33
+ "APIError",
34
+ "APIKey",
35
+ "Announcement",
36
+ "AuthenticationError",
37
+ "Balance",
38
+ "ConfigurationError",
39
+ "ConflictError",
40
+ "Group",
41
+ "KeyGroupMultiplier",
42
+ "NotFoundError",
43
+ "Page",
44
+ "PermissionDeniedError",
45
+ "PlatformQuota",
46
+ "ProtocolError",
47
+ "RateLimitError",
48
+ "Redemption",
49
+ "Resource",
50
+ "SessionTokens",
51
+ "Sub2API",
52
+ "Sub2APIError",
53
+ "Subscription",
54
+ "TransportError",
55
+ "TwoFactorRequired",
56
+ "UsageRecord",
57
+ "User",
58
+ "ValidationError",
59
+ ]
60
+
61
+ __version__ = "0.1.0"
sub2api/_client.py ADDED
@@ -0,0 +1,486 @@
1
+ from __future__ import annotations
2
+
3
+ import threading
4
+ import time
5
+ from collections.abc import Mapping
6
+ from datetime import date, datetime
7
+ from typing import Any, cast
8
+ from urllib.parse import unquote, urlsplit, urlunsplit
9
+
10
+ from curl_cffi import requests as curl_requests
11
+
12
+ from ._exceptions import (
13
+ APIError,
14
+ AuthenticationError,
15
+ ConfigurationError,
16
+ ConflictError,
17
+ NotFoundError,
18
+ PermissionDeniedError,
19
+ ProtocolError,
20
+ RateLimitError,
21
+ TransportError,
22
+ TwoFactorRequired,
23
+ ValidationError,
24
+ )
25
+ from ._models import Balance, Resource, SessionTokens, User
26
+ from ._resources import (
27
+ AccountResource,
28
+ AnnouncementsResource,
29
+ GroupsResource,
30
+ KeysResource,
31
+ RedeemResource,
32
+ SubscriptionsResource,
33
+ UsageResource,
34
+ )
35
+
36
+ _ERROR_TYPES: dict[int, type[APIError]] = {
37
+ 400: ValidationError,
38
+ 401: AuthenticationError,
39
+ 403: PermissionDeniedError,
40
+ 404: NotFoundError,
41
+ 409: ConflictError,
42
+ 422: ValidationError,
43
+ 429: RateLimitError,
44
+ }
45
+ _LOCAL_HOSTS = frozenset({"localhost", "127.0.0.1", "::1"})
46
+
47
+
48
+ def _api_base_url(value: str, allow_insecure: bool) -> str:
49
+ candidate = value.strip()
50
+ if not candidate:
51
+ raise ConfigurationError("base_url must not be empty")
52
+ if "://" not in candidate:
53
+ candidate = f"https://{candidate}"
54
+ parsed = urlsplit(candidate)
55
+ if parsed.scheme not in {"http", "https"} or not parsed.hostname:
56
+ raise ConfigurationError("base_url must be an HTTP or HTTPS instance URL")
57
+ if parsed.username or parsed.password:
58
+ raise ConfigurationError("base_url must not contain credentials")
59
+ if parsed.query or parsed.fragment:
60
+ raise ConfigurationError("base_url must not contain a query string or fragment")
61
+ local = parsed.hostname in _LOCAL_HOSTS or parsed.hostname.endswith(".localhost")
62
+ if parsed.scheme == "http" and not local and not allow_insecure:
63
+ raise ConfigurationError(
64
+ "remote HTTP would expose credentials; use HTTPS or set allow_insecure=True"
65
+ )
66
+ path = parsed.path.rstrip("/")
67
+ if not path.endswith("/api/v1"):
68
+ path = f"{path}/api/v1"
69
+ return urlunsplit((parsed.scheme, parsed.netloc, path, "", ""))
70
+
71
+
72
+ def _request_path(value: str) -> str:
73
+ parsed = urlsplit(value)
74
+ if parsed.scheme or parsed.netloc or parsed.fragment:
75
+ raise ConfigurationError("request path must be relative to the configured instance")
76
+ if parsed.query:
77
+ raise ConfigurationError("request path must not contain a query string; use params instead")
78
+ path = unquote(parsed.path).lstrip("/")
79
+ if path == "api/v1":
80
+ path = ""
81
+ elif path.startswith("api/v1/"):
82
+ path = path[len("api/v1/") :]
83
+ if any(segment == ".." for segment in path.split("/")):
84
+ raise ConfigurationError("request path must not contain parent traversal")
85
+ if not path:
86
+ raise ConfigurationError("request path must identify an endpoint")
87
+ return path
88
+
89
+
90
+ def _query_value(value: Any) -> Any:
91
+ if isinstance(value, bool):
92
+ return "true" if value else "false"
93
+ if isinstance(value, (date, datetime)):
94
+ return value.isoformat()
95
+ return value
96
+
97
+
98
+ def _query(params: Mapping[str, Any] | None, timezone: str, method: str) -> dict[str, Any]:
99
+ output = {
100
+ key: _query_value(value) for key, value in (params or {}).items() if value is not None
101
+ }
102
+ if method == "GET" and "timezone" not in output:
103
+ output["timezone"] = timezone
104
+ return output
105
+
106
+
107
+ def _success(response: Any) -> bool:
108
+ return 200 <= int(response.status_code) < 300
109
+
110
+
111
+ def _retry_after(response: Any) -> float | None:
112
+ value = response.headers.get("Retry-After")
113
+ if value is None:
114
+ return None
115
+ try:
116
+ return max(0.0, float(value))
117
+ except ValueError:
118
+ return None
119
+
120
+
121
+ class Sub2API:
122
+ """An authenticated user session for one Sub2API instance."""
123
+
124
+ def __init__(
125
+ self,
126
+ base_url: str,
127
+ *,
128
+ access_token: str | None = None,
129
+ refresh_token: str | None = None,
130
+ expires_at: float | None = None,
131
+ timeout: float | tuple[float, float] = 30.0,
132
+ timezone: str = "UTC",
133
+ language: str = "en",
134
+ allow_insecure: bool = False,
135
+ impersonate: str | None = "chrome",
136
+ session: Any | None = None,
137
+ ) -> None:
138
+ self.base_url = _api_base_url(base_url, allow_insecure)
139
+ self.timezone = timezone
140
+ self.language = language
141
+ self._tokens = SessionTokens(access_token, refresh_token, expires_at)
142
+ self._refresh_lock = threading.RLock()
143
+ self._pending_2fa_token: str | None = None
144
+ self.timeout = timeout
145
+ self.impersonate = impersonate
146
+ self._owns_http_client = session is None
147
+ if session is None:
148
+ if impersonate:
149
+ self._http: Any = cast(
150
+ Any,
151
+ curl_requests.Session(impersonate=impersonate),
152
+ )
153
+ else:
154
+ self._http = cast(Any, curl_requests.Session())
155
+ else:
156
+ self._http = session
157
+ self.account = AccountResource(self)
158
+ self.keys = KeysResource(self)
159
+ self.groups = GroupsResource(self)
160
+ self.usage = UsageResource(self)
161
+ self.history = self.usage
162
+ self.subscriptions = SubscriptionsResource(self)
163
+ self.announcements = AnnouncementsResource(self)
164
+ self.redeem = RedeemResource(self)
165
+
166
+ @property
167
+ def tokens(self) -> SessionTokens:
168
+ return self._tokens
169
+
170
+ @property
171
+ def is_authenticated(self) -> bool:
172
+ return self._tokens.authenticated
173
+
174
+ def login(
175
+ self,
176
+ email: str,
177
+ password: str,
178
+ *,
179
+ turnstile_token: str | None = None,
180
+ tencent_captcha_ticket: str | None = None,
181
+ tencent_captcha_randstr: str | None = None,
182
+ ) -> User:
183
+ if not email.strip() or not password:
184
+ raise ValueError("email and password are required")
185
+ payload = {
186
+ "email": email,
187
+ "password": password,
188
+ "turnstile_token": turnstile_token,
189
+ "tencent_captcha_ticket": tencent_captcha_ticket,
190
+ "tencent_captcha_randstr": tencent_captcha_randstr,
191
+ }
192
+ data = self._request(
193
+ "POST",
194
+ "auth/login",
195
+ json={key: value for key, value in payload.items() if value is not None},
196
+ authenticated=False,
197
+ allow_refresh=False,
198
+ )
199
+ auth = self._auth_mapping(data, "login")
200
+ if auth.get("requires_2fa") is True:
201
+ temp_token = auth.get("temp_token")
202
+ if not isinstance(temp_token, str) or not temp_token:
203
+ raise ProtocolError("2FA login response did not contain a temporary token")
204
+ self._pending_2fa_token = temp_token
205
+ masked = auth.get("user_email_masked")
206
+ raise TwoFactorRequired(
207
+ temp_token,
208
+ masked if isinstance(masked, str) else None,
209
+ )
210
+ return self._accept_auth(auth)
211
+
212
+ def complete_2fa(self, totp_code: str, *, temp_token: str | None = None) -> User:
213
+ token = temp_token or self._pending_2fa_token
214
+ if not token:
215
+ raise ValueError("temp_token is required when no 2FA login is pending")
216
+ if len(totp_code) != 6 or not totp_code.isdigit():
217
+ raise ValueError("totp_code must contain exactly six digits")
218
+ data = self._request(
219
+ "POST",
220
+ "auth/login/2fa",
221
+ json={"temp_token": token, "totp_code": totp_code},
222
+ authenticated=False,
223
+ allow_refresh=False,
224
+ )
225
+ user = self._accept_auth(self._auth_mapping(data, "2FA login"))
226
+ self._pending_2fa_token = None
227
+ return user
228
+
229
+ def refresh(self) -> SessionTokens:
230
+ with self._refresh_lock:
231
+ refresh_token = self._tokens.refresh_token
232
+ if not refresh_token:
233
+ raise AuthenticationError(
234
+ "No refresh token is available",
235
+ status_code=401,
236
+ code="NO_REFRESH_TOKEN",
237
+ )
238
+ try:
239
+ data = self._request(
240
+ "POST",
241
+ "auth/refresh",
242
+ json={"refresh_token": refresh_token},
243
+ authenticated=False,
244
+ allow_refresh=False,
245
+ )
246
+ auth = self._auth_mapping(data, "token refresh")
247
+ access_token = auth.get("access_token")
248
+ rotated_token = auth.get("refresh_token")
249
+ if not isinstance(access_token, str) or not access_token:
250
+ raise ProtocolError("token refresh did not return an access token")
251
+ if not isinstance(rotated_token, str) or not rotated_token:
252
+ raise ProtocolError("token refresh did not return a refresh token")
253
+ self._tokens = self._tokens_from_auth(auth)
254
+ return self._tokens
255
+ except APIError as error:
256
+ if 400 <= error.status_code < 500:
257
+ self.clear_auth()
258
+ raise
259
+
260
+ def logout(self) -> None:
261
+ refresh_token = self._tokens.refresh_token
262
+ try:
263
+ if refresh_token:
264
+ self._request(
265
+ "POST",
266
+ "auth/logout",
267
+ json={"refresh_token": refresh_token},
268
+ authenticated=False,
269
+ allow_refresh=False,
270
+ )
271
+ finally:
272
+ self.clear_auth()
273
+
274
+ def clear_auth(self) -> None:
275
+ self._tokens = SessionTokens()
276
+ self._pending_2fa_token = None
277
+
278
+ def me(self) -> User:
279
+ return self.account.profile()
280
+
281
+ def balance(self) -> Balance:
282
+ return self.account.balance()
283
+
284
+ def public_settings(self) -> Resource:
285
+ data = self._request(
286
+ "GET",
287
+ "settings/public",
288
+ authenticated=False,
289
+ allow_refresh=False,
290
+ )
291
+ if not isinstance(data, Mapping):
292
+ raise ProtocolError("public settings returned a non-object response")
293
+ return Resource(data)
294
+
295
+ def request(
296
+ self,
297
+ method: str,
298
+ path: str,
299
+ *,
300
+ params: Mapping[str, Any] | None = None,
301
+ json: Any = None,
302
+ headers: Mapping[str, str] | None = None,
303
+ authenticated: bool = True,
304
+ ) -> Any:
305
+ return self._request(
306
+ method,
307
+ path,
308
+ params=params,
309
+ json=json,
310
+ headers=headers,
311
+ authenticated=authenticated,
312
+ allow_refresh=True,
313
+ )
314
+
315
+ def close(self) -> None:
316
+ if self._owns_http_client:
317
+ self._http.close()
318
+
319
+ def __enter__(self) -> Sub2API:
320
+ return self
321
+
322
+ def __exit__(self, exc_type: Any, exc_value: Any, traceback: Any) -> None:
323
+ self.close()
324
+
325
+ def _request(
326
+ self,
327
+ method: str,
328
+ path: str,
329
+ *,
330
+ params: Mapping[str, Any] | None = None,
331
+ json: Any = None,
332
+ headers: Mapping[str, str] | None = None,
333
+ authenticated: bool,
334
+ allow_refresh: bool,
335
+ ) -> Any:
336
+ normalized_method = method.upper()
337
+ if authenticated and allow_refresh:
338
+ self._refresh_if_expiring()
339
+ failed_token = self._tokens.access_token
340
+ response = self._send(
341
+ normalized_method,
342
+ path,
343
+ params=params,
344
+ json=json,
345
+ headers=headers,
346
+ authenticated=authenticated,
347
+ )
348
+ if (
349
+ response.status_code == 401
350
+ and authenticated
351
+ and allow_refresh
352
+ and self._tokens.refresh_token
353
+ ):
354
+ self._refresh_after_unauthorized(failed_token)
355
+ response = self._send(
356
+ normalized_method,
357
+ path,
358
+ params=params,
359
+ json=json,
360
+ headers=headers,
361
+ authenticated=authenticated,
362
+ )
363
+ return self._decode(response)
364
+
365
+ def _send(
366
+ self,
367
+ method: str,
368
+ path: str,
369
+ *,
370
+ params: Mapping[str, Any] | None,
371
+ json: Any,
372
+ headers: Mapping[str, str] | None,
373
+ authenticated: bool,
374
+ ) -> Any:
375
+ relative_path = _request_path(path)
376
+ request_headers = {
377
+ "Accept": "application/json",
378
+ "Accept-Language": self.language,
379
+ "User-Agent": "sub2api-python/0.1.0",
380
+ "X-User-UI-Request": "1",
381
+ }
382
+ if authenticated and self._tokens.access_token:
383
+ request_headers["Authorization"] = (
384
+ f"{self._tokens.token_type} {self._tokens.access_token}"
385
+ )
386
+ if headers:
387
+ request_headers.update(headers)
388
+ try:
389
+ return cast(Any, self._http).request(
390
+ method,
391
+ f"{self.base_url}/{relative_path}",
392
+ params=_query(params, self.timezone, method),
393
+ json=json,
394
+ headers=request_headers,
395
+ timeout=self.timeout,
396
+ allow_redirects=False,
397
+ )
398
+ except curl_requests.RequestsError as error:
399
+ raise TransportError(f"{method} request to the Sub2API instance failed") from error
400
+
401
+ def _decode(self, response: Any) -> Any:
402
+ if not response.content:
403
+ if _success(response):
404
+ return None
405
+ self._raise_api_error(response, {})
406
+ try:
407
+ payload = response.json()
408
+ except ValueError as error:
409
+ if not _success(response):
410
+ raise ProtocolError(
411
+ f"Sub2API returned non-JSON HTTP {response.status_code}"
412
+ ) from error
413
+ raise ProtocolError("Sub2API returned a non-JSON success response") from error
414
+ if not _success(response):
415
+ self._raise_api_error(response, payload)
416
+ if isinstance(payload, Mapping) and "code" in payload:
417
+ code = payload.get("code")
418
+ if code != 0:
419
+ self._raise_api_error(response, payload)
420
+ return payload.get("data")
421
+ return payload
422
+
423
+ def _raise_api_error(self, response: Any, payload: Any) -> None:
424
+ data = payload if isinstance(payload, Mapping) else {}
425
+ code = data.get("code")
426
+ reason = data.get("reason")
427
+ fallback_message = (
428
+ getattr(response, "reason_phrase", None)
429
+ or getattr(response, "reason", None)
430
+ or f"HTTP {response.status_code}"
431
+ )
432
+ message = data.get("message") or data.get("detail") or fallback_message
433
+ metadata = data.get("metadata")
434
+ status_code = response.status_code
435
+ if _success(response) and isinstance(code, int) and 400 <= code <= 599:
436
+ status_code = code
437
+ error_type = _ERROR_TYPES.get(status_code, APIError)
438
+ raise error_type(
439
+ str(message),
440
+ status_code=status_code,
441
+ code=code if isinstance(code, (int, str)) else None,
442
+ reason=reason if isinstance(reason, str) else None,
443
+ metadata=metadata if isinstance(metadata, Mapping) else None,
444
+ retry_after=_retry_after(response),
445
+ )
446
+
447
+ def _refresh_if_expiring(self) -> None:
448
+ with self._refresh_lock:
449
+ if self._tokens.needs_refresh():
450
+ self.refresh()
451
+
452
+ def _refresh_after_unauthorized(self, failed_token: str | None) -> None:
453
+ with self._refresh_lock:
454
+ if self._tokens.access_token and self._tokens.access_token != failed_token:
455
+ return
456
+ self.refresh()
457
+
458
+ def _auth_mapping(self, data: Any, operation: str) -> Mapping[str, Any]:
459
+ if not isinstance(data, Mapping):
460
+ raise ProtocolError(f"{operation} returned a non-object response")
461
+ return data
462
+
463
+ def _accept_auth(self, auth: Mapping[str, Any]) -> User:
464
+ access_token = auth.get("access_token")
465
+ if not isinstance(access_token, str) or not access_token:
466
+ raise ProtocolError("login did not return an access token")
467
+ user_data = auth.get("user")
468
+ if not isinstance(user_data, Mapping):
469
+ raise ProtocolError("login did not return a user profile")
470
+ self._tokens = self._tokens_from_auth(auth)
471
+ return User(user_data)
472
+
473
+ def _tokens_from_auth(self, auth: Mapping[str, Any]) -> SessionTokens:
474
+ expires_in = auth.get("expires_in")
475
+ expires_at = None
476
+ if isinstance(expires_in, (int, float)) and not isinstance(expires_in, bool):
477
+ expires_at = time.time() + max(0.0, float(expires_in))
478
+ token_type = auth.get("token_type")
479
+ return SessionTokens(
480
+ access_token=str(auth["access_token"]),
481
+ refresh_token=(
482
+ str(auth["refresh_token"]) if auth.get("refresh_token") not in (None, "") else None
483
+ ),
484
+ expires_at=expires_at,
485
+ token_type=str(token_type) if token_type else "Bearer",
486
+ )
sub2api/_exceptions.py ADDED
@@ -0,0 +1,83 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Mapping
4
+ from typing import Any
5
+
6
+
7
+ class Sub2APIError(Exception):
8
+ """Base exception for the package."""
9
+
10
+
11
+ class ConfigurationError(Sub2APIError, ValueError):
12
+ """Raised when client configuration is invalid or unsafe."""
13
+
14
+
15
+ class TransportError(Sub2APIError):
16
+ """Raised when the instance cannot be reached or times out."""
17
+
18
+
19
+ class ProtocolError(Sub2APIError):
20
+ """Raised when an instance returns an unsupported response shape."""
21
+
22
+
23
+ class APIError(Sub2APIError):
24
+ """An error returned by a Sub2API instance."""
25
+
26
+ def __init__(
27
+ self,
28
+ message: str,
29
+ *,
30
+ status_code: int,
31
+ code: int | str | None = None,
32
+ reason: str | None = None,
33
+ metadata: Mapping[str, Any] | None = None,
34
+ retry_after: float | None = None,
35
+ ) -> None:
36
+ super().__init__(message)
37
+ self.message = message
38
+ self.status_code = status_code
39
+ self.code = code
40
+ self.reason = reason
41
+ self.metadata = dict(metadata or {})
42
+ self.retry_after = retry_after
43
+
44
+ def __str__(self) -> str:
45
+ label = str(self.code or self.reason or self.status_code)
46
+ return f"Sub2API request failed ({label}): {self.message}"
47
+
48
+
49
+ class AuthenticationError(APIError):
50
+ """Raised when login or session authentication fails."""
51
+
52
+
53
+ class PermissionDeniedError(APIError):
54
+ """Raised when the user cannot perform an operation."""
55
+
56
+
57
+ class NotFoundError(APIError):
58
+ """Raised when a requested resource does not exist."""
59
+
60
+
61
+ class ValidationError(APIError):
62
+ """Raised when the instance rejects request input."""
63
+
64
+
65
+ class ConflictError(APIError):
66
+ """Raised when an operation conflicts with current server state."""
67
+
68
+
69
+ class RateLimitError(APIError):
70
+ """Raised when the panel API rate limit is exceeded."""
71
+
72
+
73
+ class TwoFactorRequired(AuthenticationError):
74
+ """Raised when login must be completed with a TOTP code."""
75
+
76
+ def __init__(self, temp_token: str, user_email_masked: str | None = None) -> None:
77
+ super().__init__(
78
+ "Two-factor authentication is required",
79
+ status_code=401,
80
+ code="TWO_FACTOR_REQUIRED",
81
+ )
82
+ self.temp_token = temp_token
83
+ self.user_email_masked = user_email_masked