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.
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
+