tsf-sh 1.0.1__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.
- tests/__init__.py +4 -0
- tests/test_client.py +745 -0
- tsf_sh/__init__.py +35 -0
- tsf_sh/client.py +342 -0
- tsf_sh/exceptions.py +62 -0
- tsf_sh/models.py +96 -0
- tsf_sh-1.0.1.dist-info/METADATA +241 -0
- tsf_sh-1.0.1.dist-info/RECORD +10 -0
- tsf_sh-1.0.1.dist-info/WHEEL +5 -0
- tsf_sh-1.0.1.dist-info/top_level.txt +2 -0
tsf_sh/__init__.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""
|
|
2
|
+
tsf-sh - Асинхронная Python библиотека для работы с tsf.sh API
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from .client import Client
|
|
6
|
+
from .exceptions import (
|
|
7
|
+
Error,
|
|
8
|
+
APIError,
|
|
9
|
+
ValidationError,
|
|
10
|
+
UnauthorizedError,
|
|
11
|
+
ForbiddenError,
|
|
12
|
+
NotFoundError,
|
|
13
|
+
RateLimitError,
|
|
14
|
+
ConflictError,
|
|
15
|
+
InternalServerError
|
|
16
|
+
)
|
|
17
|
+
from .models import Link, LinkStats, HealthStatus
|
|
18
|
+
|
|
19
|
+
__version__ = "1.0.0"
|
|
20
|
+
__all__ = [
|
|
21
|
+
"Client",
|
|
22
|
+
"Error",
|
|
23
|
+
"APIError",
|
|
24
|
+
"ValidationError",
|
|
25
|
+
"UnauthorizedError",
|
|
26
|
+
"ForbiddenError",
|
|
27
|
+
"NotFoundError",
|
|
28
|
+
"RateLimitError",
|
|
29
|
+
"ConflictError",
|
|
30
|
+
"InternalServerError",
|
|
31
|
+
"Link",
|
|
32
|
+
"LinkStats",
|
|
33
|
+
"HealthStatus",
|
|
34
|
+
]
|
|
35
|
+
|
tsf_sh/client.py
ADDED
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Асинхронный клиент для работы с tsf.sh API
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import Optional, List
|
|
7
|
+
from urllib.parse import urljoin
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
|
|
11
|
+
from .exceptions import (
|
|
12
|
+
APIError,
|
|
13
|
+
ValidationError,
|
|
14
|
+
UnauthorizedError,
|
|
15
|
+
ForbiddenError,
|
|
16
|
+
NotFoundError,
|
|
17
|
+
RateLimitError,
|
|
18
|
+
ConflictError,
|
|
19
|
+
InternalServerError
|
|
20
|
+
)
|
|
21
|
+
from .models import Link, LinkStats, HealthStatus
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Client:
|
|
25
|
+
"""Асинхронный клиент для работы с tsf.sh API"""
|
|
26
|
+
|
|
27
|
+
BASE_URL = "https://tsf.sh"
|
|
28
|
+
|
|
29
|
+
def __init__(self, api_key: str, base_url: str = None, timeout: float = 30.0):
|
|
30
|
+
"""
|
|
31
|
+
Инициализация клиента
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
api_key: API ключ для аутентификации
|
|
35
|
+
base_url: Базовый URL API (по умолчанию https://tsf.sh)
|
|
36
|
+
timeout: Таймаут запросов в секундах
|
|
37
|
+
"""
|
|
38
|
+
self.api_key = api_key
|
|
39
|
+
self.base_url = base_url or self.BASE_URL
|
|
40
|
+
self.timeout = timeout
|
|
41
|
+
self._client: Optional[httpx.AsyncClient] = None
|
|
42
|
+
|
|
43
|
+
async def __aenter__(self):
|
|
44
|
+
"""Асинхронный контекстный менеджер - вход"""
|
|
45
|
+
await self._ensure_client()
|
|
46
|
+
return self
|
|
47
|
+
|
|
48
|
+
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
|
49
|
+
"""Асинхронный контекстный менеджер - выход"""
|
|
50
|
+
await self.close()
|
|
51
|
+
|
|
52
|
+
async def _ensure_client(self):
|
|
53
|
+
"""Создает HTTP клиент если его нет"""
|
|
54
|
+
if self._client is None:
|
|
55
|
+
self._client = httpx.AsyncClient(
|
|
56
|
+
base_url=self.base_url,
|
|
57
|
+
timeout=self.timeout,
|
|
58
|
+
headers={
|
|
59
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
60
|
+
"Content-Type": "application/json"
|
|
61
|
+
}
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
async def close(self):
|
|
65
|
+
"""Закрывает HTTP клиент"""
|
|
66
|
+
if self._client is not None:
|
|
67
|
+
await self._client.aclose()
|
|
68
|
+
self._client = None
|
|
69
|
+
|
|
70
|
+
def _handle_response(self, response: httpx.Response) -> dict:
|
|
71
|
+
"""Обрабатывает ответ API и выбрасывает исключения при ошибках"""
|
|
72
|
+
try:
|
|
73
|
+
data = response.json()
|
|
74
|
+
except json.JSONDecodeError:
|
|
75
|
+
raise APIError(
|
|
76
|
+
f"Неверный формат ответа: {response.text}",
|
|
77
|
+
status_code=response.status_code
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
if not data.get("success", False):
|
|
81
|
+
error = data.get("error", {})
|
|
82
|
+
error_code = error.get("code", "UNKNOWN_ERROR")
|
|
83
|
+
error_message = error.get("message", "Неизвестная ошибка")
|
|
84
|
+
|
|
85
|
+
if response.status_code == 400:
|
|
86
|
+
raise ValidationError(error_message)
|
|
87
|
+
elif response.status_code == 401:
|
|
88
|
+
raise UnauthorizedError(error_message)
|
|
89
|
+
elif response.status_code == 403:
|
|
90
|
+
raise ForbiddenError(error_message)
|
|
91
|
+
elif response.status_code == 404:
|
|
92
|
+
raise NotFoundError(error_message)
|
|
93
|
+
elif response.status_code == 409:
|
|
94
|
+
existing_code = error.get("existing_code")
|
|
95
|
+
raise ConflictError(error_message, existing_code=existing_code)
|
|
96
|
+
elif response.status_code == 429:
|
|
97
|
+
reset_time = None
|
|
98
|
+
if "X-RateLimit-Reset" in response.headers:
|
|
99
|
+
try:
|
|
100
|
+
reset_time = int(response.headers["X-RateLimit-Reset"])
|
|
101
|
+
except ValueError:
|
|
102
|
+
pass
|
|
103
|
+
raise RateLimitError(error_message, reset_time=reset_time)
|
|
104
|
+
elif response.status_code == 500:
|
|
105
|
+
raise InternalServerError(error_message)
|
|
106
|
+
else:
|
|
107
|
+
raise APIError(error_message, code=error_code, status_code=response.status_code)
|
|
108
|
+
|
|
109
|
+
return data
|
|
110
|
+
|
|
111
|
+
async def create_link(
|
|
112
|
+
self,
|
|
113
|
+
url: str,
|
|
114
|
+
ttl_hours: int = 24,
|
|
115
|
+
password: Optional[str] = None
|
|
116
|
+
) -> Link:
|
|
117
|
+
"""
|
|
118
|
+
Создает короткую ссылку
|
|
119
|
+
|
|
120
|
+
Args:
|
|
121
|
+
url: URL для сокращения (должен начинаться с http:// или https://)
|
|
122
|
+
ttl_hours: Время жизни ссылки в часах (от 1 до 24). По умолчанию: 24
|
|
123
|
+
password: Пароль для защиты ссылки (1-32 символа). По умолчанию: None
|
|
124
|
+
|
|
125
|
+
Returns:
|
|
126
|
+
Link: Объект созданной ссылки
|
|
127
|
+
|
|
128
|
+
Raises:
|
|
129
|
+
ValidationError: Ошибка валидации
|
|
130
|
+
UnauthorizedError: Неверный API ключ
|
|
131
|
+
ForbiddenError: Отсутствует премиум
|
|
132
|
+
ConflictError: Ссылка уже существует
|
|
133
|
+
RateLimitError: Превышен лимит запросов или кулдаун
|
|
134
|
+
"""
|
|
135
|
+
await self._ensure_client()
|
|
136
|
+
|
|
137
|
+
payload = {
|
|
138
|
+
"url": url,
|
|
139
|
+
"ttl_hours": ttl_hours
|
|
140
|
+
}
|
|
141
|
+
if password:
|
|
142
|
+
payload["password"] = password
|
|
143
|
+
|
|
144
|
+
response = await self._client.post("/api/links", json=payload)
|
|
145
|
+
data = self._handle_response(response)
|
|
146
|
+
|
|
147
|
+
return Link.from_dict(data["data"])
|
|
148
|
+
|
|
149
|
+
async def get_links(self) -> List[Link]:
|
|
150
|
+
"""
|
|
151
|
+
Получает список всех ссылок пользователя
|
|
152
|
+
|
|
153
|
+
Returns:
|
|
154
|
+
List[Link]: Список ссылок, отсортированный по дате создания (новые первые)
|
|
155
|
+
|
|
156
|
+
Raises:
|
|
157
|
+
UnauthorizedError: Неверный API ключ
|
|
158
|
+
ForbiddenError: Отсутствует премиум
|
|
159
|
+
"""
|
|
160
|
+
await self._ensure_client()
|
|
161
|
+
|
|
162
|
+
response = await self._client.get("/api/links")
|
|
163
|
+
data = self._handle_response(response)
|
|
164
|
+
|
|
165
|
+
return [Link.from_dict(link_data) for link_data in data["data"]["links"]]
|
|
166
|
+
|
|
167
|
+
async def get_link(self, code: str) -> Link:
|
|
168
|
+
"""
|
|
169
|
+
Получает информацию о ссылке
|
|
170
|
+
|
|
171
|
+
Args:
|
|
172
|
+
code: Код короткой ссылки
|
|
173
|
+
|
|
174
|
+
Returns:
|
|
175
|
+
Link: Объект ссылки
|
|
176
|
+
|
|
177
|
+
Raises:
|
|
178
|
+
UnauthorizedError: Неверный API ключ
|
|
179
|
+
ForbiddenError: Отсутствует премиум
|
|
180
|
+
NotFoundError: Ссылка не найдена
|
|
181
|
+
"""
|
|
182
|
+
await self._ensure_client()
|
|
183
|
+
|
|
184
|
+
response = await self._client.get(f"/api/links/{code}")
|
|
185
|
+
data = self._handle_response(response)
|
|
186
|
+
|
|
187
|
+
return Link.from_dict(data["data"])
|
|
188
|
+
|
|
189
|
+
async def delete_link(self, code: str) -> bool:
|
|
190
|
+
"""
|
|
191
|
+
Удаляет ссылку
|
|
192
|
+
|
|
193
|
+
Args:
|
|
194
|
+
code: Код короткой ссылки
|
|
195
|
+
|
|
196
|
+
Returns:
|
|
197
|
+
bool: True если ссылка успешно удалена
|
|
198
|
+
|
|
199
|
+
Raises:
|
|
200
|
+
UnauthorizedError: Неверный API ключ
|
|
201
|
+
ForbiddenError: Отсутствует премиум
|
|
202
|
+
NotFoundError: Ссылка не найдена
|
|
203
|
+
"""
|
|
204
|
+
await self._ensure_client()
|
|
205
|
+
|
|
206
|
+
response = await self._client.delete(f"/api/links/{code}")
|
|
207
|
+
self._handle_response(response)
|
|
208
|
+
|
|
209
|
+
return True
|
|
210
|
+
|
|
211
|
+
async def extend_link(self, code: str, ttl_hours: int) -> Link:
|
|
212
|
+
"""
|
|
213
|
+
Продлевает время жизни ссылки
|
|
214
|
+
|
|
215
|
+
Args:
|
|
216
|
+
code: Код короткой ссылки
|
|
217
|
+
ttl_hours: Новое время жизни в часах (от 1 до 24)
|
|
218
|
+
|
|
219
|
+
Returns:
|
|
220
|
+
Link: Обновленный объект ссылки
|
|
221
|
+
|
|
222
|
+
Raises:
|
|
223
|
+
ValidationError: Ошибка валидации
|
|
224
|
+
UnauthorizedError: Неверный API ключ
|
|
225
|
+
ForbiddenError: Отсутствует премиум
|
|
226
|
+
NotFoundError: Ссылка не найдена
|
|
227
|
+
RateLimitError: Превышен кулдаун
|
|
228
|
+
"""
|
|
229
|
+
await self._ensure_client()
|
|
230
|
+
|
|
231
|
+
payload = {"ttl_hours": ttl_hours}
|
|
232
|
+
response = await self._client.patch(f"/api/links/{code}/extend", json=payload)
|
|
233
|
+
data = self._handle_response(response)
|
|
234
|
+
|
|
235
|
+
return await self.get_link(code)
|
|
236
|
+
|
|
237
|
+
async def set_password(self, code: str, password: str) -> bool:
|
|
238
|
+
"""
|
|
239
|
+
Устанавливает или изменяет пароль для ссылки
|
|
240
|
+
|
|
241
|
+
Args:
|
|
242
|
+
code: Код короткой ссылки
|
|
243
|
+
password: Пароль (1-32 символа)
|
|
244
|
+
|
|
245
|
+
Returns:
|
|
246
|
+
bool: True если пароль успешно установлен
|
|
247
|
+
|
|
248
|
+
Raises:
|
|
249
|
+
ValidationError: Ошибка валидации
|
|
250
|
+
UnauthorizedError: Неверный API ключ
|
|
251
|
+
ForbiddenError: Отсутствует премиум
|
|
252
|
+
NotFoundError: Ссылка не найдена
|
|
253
|
+
"""
|
|
254
|
+
await self._ensure_client()
|
|
255
|
+
|
|
256
|
+
payload = {"password": password}
|
|
257
|
+
response = await self._client.post(f"/api/links/{code}/password", json=payload)
|
|
258
|
+
self._handle_response(response)
|
|
259
|
+
|
|
260
|
+
return True
|
|
261
|
+
|
|
262
|
+
async def remove_password(self, code: str) -> bool:
|
|
263
|
+
"""
|
|
264
|
+
Удаляет пароль у ссылки
|
|
265
|
+
|
|
266
|
+
Args:
|
|
267
|
+
code: Код короткой ссылки
|
|
268
|
+
|
|
269
|
+
Returns:
|
|
270
|
+
bool: True если пароль успешно удален
|
|
271
|
+
|
|
272
|
+
Raises:
|
|
273
|
+
UnauthorizedError: Неверный API ключ
|
|
274
|
+
ForbiddenError: Отсутствует премиум
|
|
275
|
+
NotFoundError: Ссылка не найдена
|
|
276
|
+
"""
|
|
277
|
+
await self._ensure_client()
|
|
278
|
+
|
|
279
|
+
response = await self._client.delete(f"/api/links/{code}/password")
|
|
280
|
+
self._handle_response(response)
|
|
281
|
+
|
|
282
|
+
return True
|
|
283
|
+
|
|
284
|
+
async def reroll_code(self, code: str) -> str:
|
|
285
|
+
"""
|
|
286
|
+
Перегенерирует код ссылки
|
|
287
|
+
|
|
288
|
+
Args:
|
|
289
|
+
code: Старый код ссылки
|
|
290
|
+
|
|
291
|
+
Returns:
|
|
292
|
+
str: Новый код ссылки
|
|
293
|
+
|
|
294
|
+
Raises:
|
|
295
|
+
UnauthorizedError: Неверный API ключ
|
|
296
|
+
ForbiddenError: Отсутствует премиум
|
|
297
|
+
NotFoundError: Ссылка не найдена
|
|
298
|
+
RateLimitError: Превышен кулдаун
|
|
299
|
+
"""
|
|
300
|
+
await self._ensure_client()
|
|
301
|
+
|
|
302
|
+
response = await self._client.post(f"/api/links/{code}/reroll")
|
|
303
|
+
data = self._handle_response(response)
|
|
304
|
+
|
|
305
|
+
return data["data"]["new_code"]
|
|
306
|
+
|
|
307
|
+
async def get_stats(self) -> LinkStats:
|
|
308
|
+
"""
|
|
309
|
+
Получает статистику пользователя
|
|
310
|
+
|
|
311
|
+
Returns:
|
|
312
|
+
LinkStats: Статистика пользователя
|
|
313
|
+
|
|
314
|
+
Raises:
|
|
315
|
+
UnauthorizedError: Неверный API ключ
|
|
316
|
+
ForbiddenError: Отсутствует премиум
|
|
317
|
+
"""
|
|
318
|
+
await self._ensure_client()
|
|
319
|
+
|
|
320
|
+
response = await self._client.get("/api/stats")
|
|
321
|
+
data = self._handle_response(response)
|
|
322
|
+
|
|
323
|
+
stats_data = data["data"]
|
|
324
|
+
return LinkStats(
|
|
325
|
+
links_count=stats_data["links_count"],
|
|
326
|
+
total_clicks=stats_data["total_clicks"]
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
async def health_check(self) -> HealthStatus:
|
|
330
|
+
"""
|
|
331
|
+
Проверяет работоспособность API
|
|
332
|
+
|
|
333
|
+
Returns:
|
|
334
|
+
HealthStatus: Статус здоровья API
|
|
335
|
+
"""
|
|
336
|
+
await self._ensure_client()
|
|
337
|
+
|
|
338
|
+
response = await self._client.get("/api/health")
|
|
339
|
+
data = self._handle_response(response)
|
|
340
|
+
|
|
341
|
+
return HealthStatus.from_dict(data)
|
|
342
|
+
|
tsf_sh/exceptions.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Исключения для tsf-sh библиотеки
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Error(Exception):
|
|
7
|
+
"""Базовое исключение для всех ошибок tsf-sh"""
|
|
8
|
+
pass
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class APIError(Error):
|
|
12
|
+
"""Базовое исключение для ошибок API"""
|
|
13
|
+
def __init__(self, message: str, code: str = None, status_code: int = None):
|
|
14
|
+
self.message = message
|
|
15
|
+
self.code = code
|
|
16
|
+
self.status_code = status_code
|
|
17
|
+
super().__init__(self.message)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ValidationError(APIError):
|
|
21
|
+
"""Ошибка валидации (400)"""
|
|
22
|
+
def __init__(self, message: str):
|
|
23
|
+
super().__init__(message, code="VALIDATION_ERROR", status_code=400)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class UnauthorizedError(APIError):
|
|
27
|
+
"""Ошибка авторизации (401)"""
|
|
28
|
+
def __init__(self, message: str = "Неверный API ключ"):
|
|
29
|
+
super().__init__(message, code="INVALID_API_KEY", status_code=401)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ForbiddenError(APIError):
|
|
33
|
+
"""Ошибка доступа (403)"""
|
|
34
|
+
def __init__(self, message: str = "Требуется премиум"):
|
|
35
|
+
super().__init__(message, code="PREMIUM_REQUIRED", status_code=403)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class NotFoundError(APIError):
|
|
39
|
+
"""Ресурс не найден (404)"""
|
|
40
|
+
def __init__(self, message: str = "Ссылка не найдена"):
|
|
41
|
+
super().__init__(message, code="LINK_NOT_FOUND", status_code=404)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class ConflictError(APIError):
|
|
45
|
+
"""Конфликт (409)"""
|
|
46
|
+
def __init__(self, message: str, existing_code: str = None):
|
|
47
|
+
self.existing_code = existing_code
|
|
48
|
+
super().__init__(message, code="LINK_ALREADY_EXISTS", status_code=409)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class RateLimitError(APIError):
|
|
52
|
+
"""Превышен лимит запросов (429)"""
|
|
53
|
+
def __init__(self, message: str, reset_time: int = None):
|
|
54
|
+
self.reset_time = reset_time
|
|
55
|
+
super().__init__(message, code="RATE_LIMIT_EXCEEDED", status_code=429)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class InternalServerError(APIError):
|
|
59
|
+
"""Внутренняя ошибка сервера (500)"""
|
|
60
|
+
def __init__(self, message: str = "Внутренняя ошибка сервера"):
|
|
61
|
+
super().__init__(message, code="INTERNAL_SERVER_ERROR", status_code=500)
|
|
62
|
+
|
tsf_sh/models.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Модели данных для tsf-sh библиотеки
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Optional, List
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class Link:
|
|
12
|
+
"""Модель короткой ссылки"""
|
|
13
|
+
code: str
|
|
14
|
+
short_url: str
|
|
15
|
+
original_url: str
|
|
16
|
+
clicks: int
|
|
17
|
+
ttl_seconds: int
|
|
18
|
+
created_at: int
|
|
19
|
+
expires_at: int
|
|
20
|
+
has_password: bool
|
|
21
|
+
|
|
22
|
+
@property
|
|
23
|
+
def created_datetime(self) -> datetime:
|
|
24
|
+
"""Возвращает datetime объект для created_at"""
|
|
25
|
+
return datetime.fromtimestamp(self.created_at)
|
|
26
|
+
|
|
27
|
+
@property
|
|
28
|
+
def expires_datetime(self) -> datetime:
|
|
29
|
+
"""Возвращает datetime объект для expires_at"""
|
|
30
|
+
return datetime.fromtimestamp(self.expires_at)
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def is_expired(self) -> bool:
|
|
34
|
+
"""Проверяет, истекла ли ссылка"""
|
|
35
|
+
return datetime.now().timestamp() > self.expires_at
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def remaining_seconds(self) -> int:
|
|
39
|
+
"""Возвращает оставшееся время жизни в секундах"""
|
|
40
|
+
remaining = self.expires_at - int(datetime.now().timestamp())
|
|
41
|
+
return max(0, remaining)
|
|
42
|
+
|
|
43
|
+
@classmethod
|
|
44
|
+
def from_dict(cls, data: dict) -> "Link":
|
|
45
|
+
"""Создает объект Link из словаря"""
|
|
46
|
+
clicks = data.get("clicks", 0)
|
|
47
|
+
ttl_seconds = data["ttl_seconds"]
|
|
48
|
+
expires_at = data["expires_at"]
|
|
49
|
+
|
|
50
|
+
if "created_at" in data:
|
|
51
|
+
created_at = data["created_at"]
|
|
52
|
+
else:
|
|
53
|
+
created_at = expires_at - ttl_seconds
|
|
54
|
+
|
|
55
|
+
return cls(
|
|
56
|
+
code=data["code"],
|
|
57
|
+
short_url=data["short_url"],
|
|
58
|
+
original_url=data["original_url"],
|
|
59
|
+
clicks=clicks,
|
|
60
|
+
ttl_seconds=ttl_seconds,
|
|
61
|
+
created_at=created_at,
|
|
62
|
+
expires_at=expires_at,
|
|
63
|
+
has_password=data.get("has_password", False)
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass
|
|
68
|
+
class LinkStats:
|
|
69
|
+
"""Статистика пользователя"""
|
|
70
|
+
links_count: int
|
|
71
|
+
total_clicks: int
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass
|
|
75
|
+
class HealthStatus:
|
|
76
|
+
"""Статус здоровья API"""
|
|
77
|
+
success: bool
|
|
78
|
+
status: str
|
|
79
|
+
services: dict
|
|
80
|
+
timestamp: int
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def is_healthy(self) -> bool:
|
|
84
|
+
"""Проверяет, здоров ли API"""
|
|
85
|
+
return self.status == "healthy"
|
|
86
|
+
|
|
87
|
+
@classmethod
|
|
88
|
+
def from_dict(cls, data: dict) -> "HealthStatus":
|
|
89
|
+
"""Создает объект HealthStatus из словаря"""
|
|
90
|
+
return cls(
|
|
91
|
+
success=data["success"],
|
|
92
|
+
status=data["status"],
|
|
93
|
+
services=data["services"],
|
|
94
|
+
timestamp=data["timestamp"]
|
|
95
|
+
)
|
|
96
|
+
|