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/_models.py ADDED
@@ -0,0 +1,194 @@
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ from collections.abc import Iterator, Mapping
5
+ from copy import deepcopy
6
+ from dataclasses import dataclass, field
7
+ from decimal import Decimal
8
+ from typing import Any, Generic, TypeVar
9
+
10
+ _SENSITIVE_FIELDS = frozenset(
11
+ {
12
+ "access_token",
13
+ "authorization",
14
+ "code",
15
+ "cookie",
16
+ "custom_key",
17
+ "key",
18
+ "password",
19
+ "refresh_token",
20
+ "temp_token",
21
+ }
22
+ )
23
+
24
+
25
+ def _redacted(value: Any, field: str | None = None) -> Any:
26
+ if field is not None and field.lower() in _SENSITIVE_FIELDS and value not in (None, ""):
27
+ return "********"
28
+ if isinstance(value, Mapping):
29
+ return {str(key): _redacted(item, str(key)) for key, item in value.items()}
30
+ if isinstance(value, (list, tuple)):
31
+ return [_redacted(item) for item in value]
32
+ return value
33
+
34
+
35
+ def _wrapped(value: Any) -> Any:
36
+ if isinstance(value, Mapping):
37
+ return Resource(value)
38
+ if isinstance(value, list):
39
+ return [_wrapped(item) for item in value]
40
+ return value
41
+
42
+
43
+ class Resource(Mapping[str, Any]):
44
+ """A response object supporting both mapping and attribute access."""
45
+
46
+ __slots__ = ("_data",)
47
+
48
+ def __init__(self, data: Mapping[str, Any]) -> None:
49
+ self._data = dict(data)
50
+
51
+ def __getitem__(self, key: str) -> Any:
52
+ return _wrapped(self._data[key])
53
+
54
+ def __iter__(self) -> Iterator[str]:
55
+ return iter(self._data)
56
+
57
+ def __len__(self) -> int:
58
+ return len(self._data)
59
+
60
+ def __getattr__(self, name: str) -> Any:
61
+ try:
62
+ return self[name]
63
+ except KeyError as error:
64
+ raise AttributeError(name) from error
65
+
66
+ def __repr__(self) -> str:
67
+ return f"{type(self).__name__}({_redacted(self._data)!r})"
68
+
69
+ def to_dict(self) -> dict[str, Any]:
70
+ """Return an independent dictionary containing the full response data."""
71
+ return deepcopy(self._data)
72
+
73
+
74
+ class User(Resource):
75
+ """The authenticated user's profile."""
76
+
77
+
78
+ class APIKey(Resource):
79
+ """A user-owned gateway API key."""
80
+
81
+
82
+ class Group(Resource):
83
+ """A group available to the authenticated user."""
84
+
85
+
86
+ class UsageRecord(Resource):
87
+ """One gateway usage-history record."""
88
+
89
+
90
+ class PlatformQuota(Resource):
91
+ """Usage and limit values for one upstream platform."""
92
+
93
+
94
+ class Subscription(Resource):
95
+ """A user subscription."""
96
+
97
+
98
+ class Announcement(Resource):
99
+ """A user-visible announcement."""
100
+
101
+
102
+ class Redemption(Resource):
103
+ """A redemption result or history item."""
104
+
105
+
106
+ @dataclass(frozen=True)
107
+ class KeyGroupMultiplier:
108
+ """The group multiplier resolved for one API key."""
109
+
110
+ api_key: APIKey
111
+ group: Group | None
112
+ group_id: int | None
113
+ base_multiplier: float | None
114
+ custom_multiplier: float | None
115
+ effective_multiplier: float | None
116
+
117
+ @property
118
+ def is_custom(self) -> bool:
119
+ return self.custom_multiplier is not None
120
+
121
+
122
+ T = TypeVar("T", bound=Resource)
123
+
124
+
125
+ @dataclass(frozen=True)
126
+ class Page(Generic[T]):
127
+ """One page returned by a list endpoint."""
128
+
129
+ items: tuple[T, ...]
130
+ total: int
131
+ page: int
132
+ page_size: int
133
+ pages: int
134
+
135
+ def __iter__(self) -> Iterator[T]:
136
+ return iter(self.items)
137
+
138
+ def __len__(self) -> int:
139
+ return len(self.items)
140
+
141
+ @property
142
+ def has_next(self) -> bool:
143
+ return self.page < self.pages
144
+
145
+ @classmethod
146
+ def from_data(cls, data: Mapping[str, Any], item_type: type[T]) -> Page[T]:
147
+ raw_items = data.get("items", [])
148
+ if not isinstance(raw_items, list):
149
+ raise TypeError("paginated response items must be a list")
150
+ if any(not isinstance(item, Mapping) for item in raw_items):
151
+ raise TypeError("paginated response items must contain objects")
152
+ return cls(
153
+ items=tuple(item_type(item) for item in raw_items),
154
+ total=int(data.get("total", len(raw_items))),
155
+ page=int(data.get("page", 1)),
156
+ page_size=int(data.get("page_size", len(raw_items) or 1)),
157
+ pages=int(data.get("pages", 1)),
158
+ )
159
+
160
+
161
+ @dataclass(frozen=True)
162
+ class Balance:
163
+ """The user's USD-denominated balance values."""
164
+
165
+ balance: Decimal
166
+ frozen_balance: Decimal
167
+ total_recharged: Decimal
168
+
169
+
170
+ @dataclass(frozen=True)
171
+ class SessionTokens:
172
+ """The current in-memory dashboard session tokens."""
173
+
174
+ access_token: str | None = field(default=None, repr=False)
175
+ refresh_token: str | None = field(default=None, repr=False)
176
+ expires_at: float | None = None
177
+ token_type: str = "Bearer"
178
+
179
+ @property
180
+ def authenticated(self) -> bool:
181
+ return bool(self.access_token)
182
+
183
+ @property
184
+ def expires_in(self) -> float | None:
185
+ if self.expires_at is None:
186
+ return None
187
+ return max(0.0, self.expires_at - time.time())
188
+
189
+ def needs_refresh(self, leeway: float = 30.0) -> bool:
190
+ return (
191
+ self.refresh_token is not None
192
+ and self.expires_at is not None
193
+ and self.expires_at <= time.time() + leeway
194
+ )