sendly 3.8.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.
@@ -0,0 +1,26 @@
1
+ """Sendly SDK Utilities"""
2
+
3
+ from .http import AsyncHttpClient, HttpClient
4
+ from .validation import (
5
+ calculate_segments,
6
+ get_country_from_phone,
7
+ is_country_supported,
8
+ validate_limit,
9
+ validate_message_id,
10
+ validate_message_text,
11
+ validate_phone_number,
12
+ validate_sender_id,
13
+ )
14
+
15
+ __all__ = [
16
+ "HttpClient",
17
+ "AsyncHttpClient",
18
+ "validate_phone_number",
19
+ "validate_message_text",
20
+ "validate_sender_id",
21
+ "validate_limit",
22
+ "validate_message_id",
23
+ "get_country_from_phone",
24
+ "is_country_supported",
25
+ "calculate_segments",
26
+ ]
sendly/utils/http.py ADDED
@@ -0,0 +1,358 @@
1
+ """
2
+ HTTP Client Utility
3
+
4
+ Handles HTTP requests to the Sendly API with retries and rate limiting.
5
+ """
6
+
7
+ import asyncio
8
+ import random
9
+ import re
10
+ import time
11
+ from typing import Any, Dict, Optional, TypeVar, Union
12
+
13
+ import httpx
14
+
15
+ from ..errors import (
16
+ NetworkError,
17
+ RateLimitError,
18
+ SendlyError,
19
+ TimeoutError,
20
+ )
21
+ from ..types import RateLimitInfo
22
+
23
+ T = TypeVar("T")
24
+
25
+ DEFAULT_BASE_URL = "https://sendly.live/api/v1"
26
+ DEFAULT_TIMEOUT = 30.0
27
+ DEFAULT_MAX_RETRIES = 3
28
+ SDK_VERSION = "1.0.5"
29
+
30
+
31
+ class HttpClient:
32
+ """Synchronous HTTP client for making API requests"""
33
+
34
+ def __init__(
35
+ self,
36
+ api_key: str,
37
+ base_url: str = DEFAULT_BASE_URL,
38
+ timeout: float = DEFAULT_TIMEOUT,
39
+ max_retries: int = DEFAULT_MAX_RETRIES,
40
+ ):
41
+ self.api_key = api_key
42
+ self.base_url = base_url.rstrip("/")
43
+ self.timeout = timeout
44
+ self.max_retries = max_retries
45
+ self._rate_limit_info: Optional[RateLimitInfo] = None
46
+ self._client: Optional[httpx.Client] = None
47
+
48
+ # Validate API key format
49
+ if not self._is_valid_api_key(api_key):
50
+ raise ValueError("Invalid API key format. Expected sk_test_v1_xxx or sk_live_v1_xxx")
51
+
52
+ def _is_valid_api_key(self, key: str) -> bool:
53
+ """Validate API key format"""
54
+ return bool(re.match(r"^sk_(test|live)_v1_[a-zA-Z0-9_-]+$", key))
55
+
56
+ def is_test_mode(self) -> bool:
57
+ """Check if using a test API key"""
58
+ return self.api_key.startswith("sk_test_")
59
+
60
+ def get_rate_limit_info(self) -> Optional[RateLimitInfo]:
61
+ """Get current rate limit info"""
62
+ return self._rate_limit_info
63
+
64
+ @property
65
+ def client(self) -> httpx.Client:
66
+ """Get or create the HTTP client"""
67
+ if self._client is None or self._client.is_closed:
68
+ self._client = httpx.Client(
69
+ base_url=self.base_url,
70
+ timeout=self.timeout,
71
+ headers=self._build_headers(),
72
+ )
73
+ return self._client
74
+
75
+ def close(self) -> None:
76
+ """Close the HTTP client"""
77
+ if self._client is not None:
78
+ self._client.close()
79
+ self._client = None
80
+
81
+ def __enter__(self) -> "HttpClient":
82
+ return self
83
+
84
+ def __exit__(self, *args: Any) -> None:
85
+ self.close()
86
+
87
+ def _build_headers(self) -> Dict[str, str]:
88
+ """Build request headers"""
89
+ return {
90
+ "Authorization": f"Bearer {self.api_key}",
91
+ "Content-Type": "application/json",
92
+ "Accept": "application/json",
93
+ "User-Agent": f"sendly-python/{SDK_VERSION}",
94
+ }
95
+
96
+ def _update_rate_limit_info(self, headers: httpx.Headers) -> None:
97
+ """Update rate limit info from response headers"""
98
+ limit = headers.get("X-RateLimit-Limit")
99
+ remaining = headers.get("X-RateLimit-Remaining")
100
+ reset = headers.get("X-RateLimit-Reset")
101
+
102
+ if limit and remaining and reset:
103
+ self._rate_limit_info = RateLimitInfo(
104
+ limit=int(limit),
105
+ remaining=int(remaining),
106
+ reset=int(reset),
107
+ )
108
+
109
+ def _calculate_backoff(self, attempt: int) -> float:
110
+ """Calculate exponential backoff time"""
111
+ base_delay = 2**attempt
112
+ jitter = random.uniform(0, 0.5)
113
+ return min(base_delay + jitter, 30.0)
114
+
115
+ def request(
116
+ self,
117
+ method: str,
118
+ path: str,
119
+ body: Optional[Dict[str, Any]] = None,
120
+ params: Optional[Dict[str, Any]] = None,
121
+ ) -> Any:
122
+ """Make an HTTP request to the API"""
123
+ last_error: Optional[Exception] = None
124
+
125
+ for attempt in range(self.max_retries + 1):
126
+ try:
127
+ response = self.client.request(
128
+ method=method,
129
+ url=path,
130
+ json=body,
131
+ params=params,
132
+ )
133
+
134
+ # Update rate limit info
135
+ self._update_rate_limit_info(response.headers)
136
+
137
+ # Parse response
138
+ data = self._parse_response(response)
139
+ return data
140
+
141
+ except SendlyError as e:
142
+ last_error = e
143
+
144
+ # Don't retry certain errors
145
+ if e.status_code in (400, 401, 402, 403, 404):
146
+ raise
147
+
148
+ # Handle rate limiting
149
+ if isinstance(e, RateLimitError):
150
+ if attempt < self.max_retries:
151
+ time.sleep(e.retry_after)
152
+ continue
153
+ raise
154
+
155
+ except httpx.TimeoutException as e:
156
+ last_error = TimeoutError(f"Request timed out after {self.timeout}s")
157
+ if attempt < self.max_retries:
158
+ time.sleep(self._calculate_backoff(attempt))
159
+ continue
160
+
161
+ except httpx.RequestError as e:
162
+ last_error = NetworkError(f"Network error: {str(e)}", e)
163
+ if attempt < self.max_retries:
164
+ time.sleep(self._calculate_backoff(attempt))
165
+ continue
166
+
167
+ if last_error:
168
+ raise last_error
169
+ raise NetworkError("Request failed after retries")
170
+
171
+ def _parse_response(self, response: httpx.Response) -> Any:
172
+ """Parse the response body"""
173
+ content_type = response.headers.get("content-type", "")
174
+
175
+ if "application/json" in content_type:
176
+ try:
177
+ data = response.json()
178
+ except Exception:
179
+ data = response.text
180
+ else:
181
+ data = response.text
182
+
183
+ # Handle error responses
184
+ if not response.is_success:
185
+ if isinstance(data, dict):
186
+ raise SendlyError.from_response(response.status_code, data)
187
+ raise SendlyError(
188
+ message=str(data) or f"HTTP {response.status_code}",
189
+ code="internal_error",
190
+ status_code=response.status_code,
191
+ )
192
+
193
+ return data
194
+
195
+
196
+ class AsyncHttpClient:
197
+ """Asynchronous HTTP client for making API requests"""
198
+
199
+ def __init__(
200
+ self,
201
+ api_key: str,
202
+ base_url: str = DEFAULT_BASE_URL,
203
+ timeout: float = DEFAULT_TIMEOUT,
204
+ max_retries: int = DEFAULT_MAX_RETRIES,
205
+ ):
206
+ self.api_key = api_key
207
+ self.base_url = base_url.rstrip("/")
208
+ self.timeout = timeout
209
+ self.max_retries = max_retries
210
+ self._rate_limit_info: Optional[RateLimitInfo] = None
211
+ self._client: Optional[httpx.AsyncClient] = None
212
+
213
+ # Validate API key format
214
+ if not self._is_valid_api_key(api_key):
215
+ raise ValueError("Invalid API key format. Expected sk_test_v1_xxx or sk_live_v1_xxx")
216
+
217
+ def _is_valid_api_key(self, key: str) -> bool:
218
+ """Validate API key format"""
219
+ return bool(re.match(r"^sk_(test|live)_v1_[a-zA-Z0-9_-]+$", key))
220
+
221
+ def is_test_mode(self) -> bool:
222
+ """Check if using a test API key"""
223
+ return self.api_key.startswith("sk_test_")
224
+
225
+ def get_rate_limit_info(self) -> Optional[RateLimitInfo]:
226
+ """Get current rate limit info"""
227
+ return self._rate_limit_info
228
+
229
+ @property
230
+ def client(self) -> httpx.AsyncClient:
231
+ """Get or create the async HTTP client"""
232
+ if self._client is None or self._client.is_closed:
233
+ self._client = httpx.AsyncClient(
234
+ base_url=self.base_url,
235
+ timeout=self.timeout,
236
+ headers=self._build_headers(),
237
+ )
238
+ return self._client
239
+
240
+ async def close(self) -> None:
241
+ """Close the HTTP client"""
242
+ if self._client is not None:
243
+ await self._client.aclose()
244
+ self._client = None
245
+
246
+ async def __aenter__(self) -> "AsyncHttpClient":
247
+ return self
248
+
249
+ async def __aexit__(self, *args: Any) -> None:
250
+ await self.close()
251
+
252
+ def _build_headers(self) -> Dict[str, str]:
253
+ """Build request headers"""
254
+ return {
255
+ "Authorization": f"Bearer {self.api_key}",
256
+ "Content-Type": "application/json",
257
+ "Accept": "application/json",
258
+ "User-Agent": f"sendly-python/{SDK_VERSION}",
259
+ }
260
+
261
+ def _update_rate_limit_info(self, headers: httpx.Headers) -> None:
262
+ """Update rate limit info from response headers"""
263
+ limit = headers.get("X-RateLimit-Limit")
264
+ remaining = headers.get("X-RateLimit-Remaining")
265
+ reset = headers.get("X-RateLimit-Reset")
266
+
267
+ if limit and remaining and reset:
268
+ self._rate_limit_info = RateLimitInfo(
269
+ limit=int(limit),
270
+ remaining=int(remaining),
271
+ reset=int(reset),
272
+ )
273
+
274
+ def _calculate_backoff(self, attempt: int) -> float:
275
+ """Calculate exponential backoff time"""
276
+ base_delay = 2**attempt
277
+ jitter = random.uniform(0, 0.5)
278
+ return min(base_delay + jitter, 30.0)
279
+
280
+ async def request(
281
+ self,
282
+ method: str,
283
+ path: str,
284
+ body: Optional[Dict[str, Any]] = None,
285
+ params: Optional[Dict[str, Any]] = None,
286
+ ) -> Any:
287
+ """Make an async HTTP request to the API"""
288
+ last_error: Optional[Exception] = None
289
+
290
+ for attempt in range(self.max_retries + 1):
291
+ try:
292
+ response = await self.client.request(
293
+ method=method,
294
+ url=path,
295
+ json=body,
296
+ params=params,
297
+ )
298
+
299
+ # Update rate limit info
300
+ self._update_rate_limit_info(response.headers)
301
+
302
+ # Parse response
303
+ data = self._parse_response(response)
304
+ return data
305
+
306
+ except SendlyError as e:
307
+ last_error = e
308
+
309
+ # Don't retry certain errors
310
+ if e.status_code in (400, 401, 402, 403, 404):
311
+ raise
312
+
313
+ # Handle rate limiting
314
+ if isinstance(e, RateLimitError):
315
+ if attempt < self.max_retries:
316
+ await asyncio.sleep(e.retry_after)
317
+ continue
318
+ raise
319
+
320
+ except httpx.TimeoutException as e:
321
+ last_error = TimeoutError(f"Request timed out after {self.timeout}s")
322
+ if attempt < self.max_retries:
323
+ await asyncio.sleep(self._calculate_backoff(attempt))
324
+ continue
325
+
326
+ except httpx.RequestError as e:
327
+ last_error = NetworkError(f"Network error: {str(e)}", e)
328
+ if attempt < self.max_retries:
329
+ await asyncio.sleep(self._calculate_backoff(attempt))
330
+ continue
331
+
332
+ if last_error:
333
+ raise last_error
334
+ raise NetworkError("Request failed after retries")
335
+
336
+ def _parse_response(self, response: httpx.Response) -> Any:
337
+ """Parse the response body"""
338
+ content_type = response.headers.get("content-type", "")
339
+
340
+ if "application/json" in content_type:
341
+ try:
342
+ data = response.json()
343
+ except Exception:
344
+ data = response.text
345
+ else:
346
+ data = response.text
347
+
348
+ # Handle error responses
349
+ if not response.is_success:
350
+ if isinstance(data, dict):
351
+ raise SendlyError.from_response(response.status_code, data)
352
+ raise SendlyError(
353
+ message=str(data) or f"HTTP {response.status_code}",
354
+ code="internal_error",
355
+ status_code=response.status_code,
356
+ )
357
+
358
+ return data
@@ -0,0 +1,248 @@
1
+ """
2
+ Input Validation Utilities
3
+
4
+ Functions for validating API inputs.
5
+ """
6
+
7
+ import re
8
+ from typing import Optional
9
+
10
+ from ..errors import ValidationError
11
+ from ..types import ALL_SUPPORTED_COUNTRIES
12
+
13
+
14
+ def validate_phone_number(phone: str) -> None:
15
+ """
16
+ Validate phone number format (E.164)
17
+
18
+ Args:
19
+ phone: Phone number to validate
20
+
21
+ Raises:
22
+ ValidationError: If phone number is invalid
23
+ """
24
+ if not phone:
25
+ raise ValidationError("Phone number is required")
26
+
27
+ # E.164 format: + followed by 1-15 digits
28
+ e164_pattern = r"^\+[1-9]\d{1,14}$"
29
+
30
+ if not re.match(e164_pattern, phone):
31
+ raise ValidationError(
32
+ f"Invalid phone number format: {phone}. Expected E.164 format (e.g., +15551234567)"
33
+ )
34
+
35
+
36
+ def validate_message_text(text: str) -> None:
37
+ """
38
+ Validate message text
39
+
40
+ Args:
41
+ text: Message text to validate
42
+
43
+ Raises:
44
+ ValidationError: If text is invalid
45
+ """
46
+ if not text:
47
+ raise ValidationError("Message text is required")
48
+
49
+ if not isinstance(text, str):
50
+ raise ValidationError("Message text must be a string")
51
+
52
+ # Warn about very long messages
53
+ if len(text) > 1600:
54
+ import warnings
55
+
56
+ warnings.warn(
57
+ f"Message is {len(text)} characters. "
58
+ f"This will be split into {calculate_segments(text)} segments."
59
+ )
60
+
61
+
62
+ def validate_sender_id(from_: Optional[str]) -> None:
63
+ """
64
+ Validate sender ID
65
+
66
+ Args:
67
+ from_: Sender ID to validate
68
+
69
+ Raises:
70
+ ValidationError: If sender ID is invalid
71
+ """
72
+ if not from_:
73
+ return # Optional field
74
+
75
+ # Phone number format (toll-free)
76
+ if from_.startswith("+"):
77
+ validate_phone_number(from_)
78
+ return
79
+
80
+ # Alphanumeric sender ID (2-11 characters)
81
+ alphanumeric_pattern = r"^[a-zA-Z0-9]{2,11}$"
82
+
83
+ if not re.match(alphanumeric_pattern, from_):
84
+ raise ValidationError(
85
+ f"Invalid sender ID: {from_}. "
86
+ "Must be 2-11 alphanumeric characters or a valid phone number."
87
+ )
88
+
89
+
90
+ def validate_limit(limit: Optional[int]) -> None:
91
+ """
92
+ Validate list limit
93
+
94
+ Args:
95
+ limit: Limit value to validate
96
+
97
+ Raises:
98
+ ValidationError: If limit is invalid
99
+ """
100
+ if limit is None:
101
+ return
102
+
103
+ if not isinstance(limit, int):
104
+ raise ValidationError("Limit must be an integer")
105
+
106
+ if limit < 1 or limit > 100:
107
+ raise ValidationError("Limit must be between 1 and 100")
108
+
109
+
110
+ def validate_message_id(id: str) -> None:
111
+ """
112
+ Validate message ID format
113
+
114
+ Args:
115
+ id: Message ID to validate
116
+
117
+ Raises:
118
+ ValidationError: If ID is invalid
119
+ """
120
+ if not id:
121
+ raise ValidationError("Message ID is required")
122
+
123
+ if not isinstance(id, str):
124
+ raise ValidationError("Message ID must be a string")
125
+
126
+ # UUID format or prefixed format
127
+ uuid_pattern = r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
128
+ prefixed_pattern = r"^msg_[a-zA-Z0-9_]+$"
129
+
130
+ if not (re.match(uuid_pattern, id, re.IGNORECASE) or re.match(prefixed_pattern, id)):
131
+ raise ValidationError(f"Invalid message ID format: {id}")
132
+
133
+
134
+ def get_country_from_phone(phone: str) -> Optional[str]:
135
+ """
136
+ Get country code from phone number
137
+
138
+ Args:
139
+ phone: Phone number in E.164 format
140
+
141
+ Returns:
142
+ ISO country code or None if not found
143
+ """
144
+ # Remove + prefix
145
+ digits = phone.lstrip("+")
146
+
147
+ # Check US/Canada (country code 1)
148
+ if digits.startswith("1") and len(digits) == 11:
149
+ return "US" # Could be CA, but we treat as domestic
150
+
151
+ # Map of country codes to ISO codes
152
+ country_prefixes = {
153
+ "44": "GB",
154
+ "48": "PL",
155
+ "351": "PT",
156
+ "40": "RO",
157
+ "420": "CZ",
158
+ "36": "HU",
159
+ "86": "CN",
160
+ "82": "KR",
161
+ "91": "IN",
162
+ "63": "PH",
163
+ "66": "TH",
164
+ "84": "VN",
165
+ "33": "FR",
166
+ "34": "ES",
167
+ "46": "SE",
168
+ "47": "NO",
169
+ "45": "DK",
170
+ "358": "FI",
171
+ "353": "IE",
172
+ "81": "JP",
173
+ "61": "AU",
174
+ "64": "NZ",
175
+ "65": "SG",
176
+ "852": "HK",
177
+ "60": "MY",
178
+ "62": "ID",
179
+ "55": "BR",
180
+ "54": "AR",
181
+ "56": "CL",
182
+ "57": "CO",
183
+ "27": "ZA",
184
+ "30": "GR",
185
+ "49": "DE",
186
+ "39": "IT",
187
+ "31": "NL",
188
+ "32": "BE",
189
+ "43": "AT",
190
+ "41": "CH",
191
+ "52": "MX",
192
+ "972": "IL",
193
+ "971": "AE",
194
+ "966": "SA",
195
+ "20": "EG",
196
+ "234": "NG",
197
+ "254": "KE",
198
+ "886": "TW",
199
+ "92": "PK",
200
+ "90": "TR",
201
+ }
202
+
203
+ # Try to match country prefixes (longest first)
204
+ sorted_prefixes = sorted(country_prefixes.keys(), key=len, reverse=True)
205
+
206
+ for prefix in sorted_prefixes:
207
+ if digits.startswith(prefix):
208
+ return country_prefixes[prefix]
209
+
210
+ return None
211
+
212
+
213
+ def is_country_supported(country_code: str) -> bool:
214
+ """
215
+ Check if a country is supported
216
+
217
+ Args:
218
+ country_code: ISO country code
219
+
220
+ Returns:
221
+ True if country is supported
222
+ """
223
+ return country_code.upper() in ALL_SUPPORTED_COUNTRIES
224
+
225
+
226
+ def calculate_segments(text: str) -> int:
227
+ """
228
+ Calculate number of SMS segments for a message
229
+
230
+ Args:
231
+ text: Message text
232
+
233
+ Returns:
234
+ Number of SMS segments
235
+ """
236
+ # Check if message contains non-GSM characters (requires UCS-2 encoding)
237
+ # Simple check: any character outside basic ASCII range
238
+ is_unicode = any(ord(c) > 127 for c in text)
239
+
240
+ # GSM: 160 chars single, 153 chars per segment for multi
241
+ # UCS-2: 70 chars single, 67 chars per segment for multi
242
+ single_limit = 70 if is_unicode else 160
243
+ multi_limit = 67 if is_unicode else 153
244
+
245
+ if len(text) <= single_limit:
246
+ return 1
247
+
248
+ return (len(text) + multi_limit - 1) // multi_limit