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.
sendly/__init__.py ADDED
@@ -0,0 +1,165 @@
1
+ """
2
+ Sendly Python SDK
3
+
4
+ Official SDK for the Sendly SMS API.
5
+
6
+ Example:
7
+ >>> from sendly import Sendly
8
+ >>>
9
+ >>> client = Sendly('sk_live_v1_your_api_key')
10
+ >>>
11
+ >>> # Send an SMS
12
+ >>> message = client.messages.send(
13
+ ... to='+15551234567',
14
+ ... text='Hello from Sendly!'
15
+ ... )
16
+ >>> print(f'Message sent: {message.id}')
17
+
18
+ Async Example:
19
+ >>> import asyncio
20
+ >>> from sendly import AsyncSendly
21
+ >>>
22
+ >>> async def main():
23
+ ... async with AsyncSendly('sk_live_v1_xxx') as client:
24
+ ... message = await client.messages.send(
25
+ ... to='+15551234567',
26
+ ... text='Hello!'
27
+ ... )
28
+ >>>
29
+ >>> asyncio.run(main())
30
+ """
31
+
32
+ __version__ = "1.0.5"
33
+
34
+ # Main clients
35
+ from .client import AsyncSendly, Sendly
36
+
37
+ # Errors
38
+ from .errors import (
39
+ AuthenticationError,
40
+ InsufficientCreditsError,
41
+ NetworkError,
42
+ NotFoundError,
43
+ RateLimitError,
44
+ SendlyError,
45
+ TimeoutError,
46
+ ValidationError,
47
+ )
48
+
49
+ # Types
50
+ from .types import (
51
+ ALL_SUPPORTED_COUNTRIES,
52
+ # Constants
53
+ CREDITS_PER_SMS,
54
+ SANDBOX_TEST_NUMBERS,
55
+ SUPPORTED_COUNTRIES,
56
+ # Account types
57
+ Account,
58
+ ApiKey,
59
+ CircuitState,
60
+ CreateWebhookOptions,
61
+ Credits,
62
+ CreditTransaction,
63
+ DeliveryStatus,
64
+ ListMessagesOptions,
65
+ Message,
66
+ MessageListResponse,
67
+ MessageStatus,
68
+ PricingTier,
69
+ RateLimitInfo,
70
+ SandboxTestNumbers,
71
+ SenderType,
72
+ SendlyConfig,
73
+ SendMessageRequest,
74
+ TransactionType,
75
+ UpdateWebhookOptions,
76
+ # Webhook types
77
+ Webhook,
78
+ WebhookCreatedResponse,
79
+ WebhookDelivery,
80
+ WebhookSecretRotation,
81
+ WebhookTestResult,
82
+ )
83
+
84
+ # Utilities (for advanced usage)
85
+ from .utils.validation import (
86
+ calculate_segments,
87
+ get_country_from_phone,
88
+ is_country_supported,
89
+ validate_message_text,
90
+ validate_phone_number,
91
+ validate_sender_id,
92
+ )
93
+
94
+ # Webhooks
95
+ from .webhooks import (
96
+ WebhookEvent,
97
+ WebhookEventType,
98
+ WebhookMessageData,
99
+ WebhookMessageStatus,
100
+ Webhooks,
101
+ WebhookSignatureError,
102
+ )
103
+
104
+ __all__ = [
105
+ # Version
106
+ "__version__",
107
+ # Clients
108
+ "Sendly",
109
+ "AsyncSendly",
110
+ # Types
111
+ "SendlyConfig",
112
+ "SendMessageRequest",
113
+ "Message",
114
+ "MessageStatus",
115
+ "SenderType",
116
+ "ListMessagesOptions",
117
+ "MessageListResponse",
118
+ "RateLimitInfo",
119
+ "PricingTier",
120
+ # Webhook types
121
+ "Webhook",
122
+ "WebhookCreatedResponse",
123
+ "CreateWebhookOptions",
124
+ "UpdateWebhookOptions",
125
+ "WebhookDelivery",
126
+ "WebhookTestResult",
127
+ "WebhookSecretRotation",
128
+ "CircuitState",
129
+ "DeliveryStatus",
130
+ # Account types
131
+ "Account",
132
+ "Credits",
133
+ "CreditTransaction",
134
+ "TransactionType",
135
+ "ApiKey",
136
+ # Constants
137
+ "CREDITS_PER_SMS",
138
+ "SUPPORTED_COUNTRIES",
139
+ "ALL_SUPPORTED_COUNTRIES",
140
+ "SANDBOX_TEST_NUMBERS",
141
+ "SandboxTestNumbers",
142
+ # Errors
143
+ "SendlyError",
144
+ "AuthenticationError",
145
+ "RateLimitError",
146
+ "InsufficientCreditsError",
147
+ "ValidationError",
148
+ "NotFoundError",
149
+ "NetworkError",
150
+ "TimeoutError",
151
+ # Utilities
152
+ "validate_phone_number",
153
+ "validate_message_text",
154
+ "validate_sender_id",
155
+ "get_country_from_phone",
156
+ "is_country_supported",
157
+ "calculate_segments",
158
+ # Webhooks
159
+ "Webhooks",
160
+ "WebhookSignatureError",
161
+ "WebhookEvent",
162
+ "WebhookEventType",
163
+ "WebhookMessageData",
164
+ "WebhookMessageStatus",
165
+ ]
sendly/client.py ADDED
@@ -0,0 +1,248 @@
1
+ """
2
+ Sendly Client
3
+
4
+ Main entry point for the Sendly SDK.
5
+ """
6
+
7
+ from typing import Any, Optional, Union
8
+
9
+ from .resources.account import AccountResource, AsyncAccountResource
10
+ from .resources.messages import AsyncMessagesResource, MessagesResource
11
+ from .resources.webhooks import AsyncWebhooksResource, WebhooksResource
12
+ from .types import RateLimitInfo, SendlyConfig
13
+ from .utils.http import AsyncHttpClient, HttpClient
14
+
15
+ DEFAULT_BASE_URL = "https://sendly.live/api/v1"
16
+ DEFAULT_TIMEOUT = 30.0
17
+ DEFAULT_MAX_RETRIES = 3
18
+
19
+
20
+ class Sendly:
21
+ """
22
+ Sendly API Client (synchronous)
23
+
24
+ The main entry point for interacting with the Sendly SMS API.
25
+
26
+ Example:
27
+ >>> from sendly import Sendly
28
+ >>>
29
+ >>> # Initialize with API key
30
+ >>> client = Sendly('sk_live_v1_your_api_key')
31
+ >>>
32
+ >>> # Send an SMS
33
+ >>> message = client.messages.send(
34
+ ... to='+15551234567',
35
+ ... text='Hello from Sendly!'
36
+ ... )
37
+ >>> print(message.id)
38
+
39
+ Example with configuration:
40
+ >>> client = Sendly(
41
+ ... api_key='sk_live_v1_your_api_key',
42
+ ... timeout=60.0,
43
+ ... max_retries=5
44
+ ... )
45
+
46
+ Example with context manager:
47
+ >>> with Sendly('sk_live_v1_xxx') as client:
48
+ ... message = client.messages.send(to='+1555...', text='Hello!')
49
+ """
50
+
51
+ def __init__(
52
+ self,
53
+ api_key: Optional[str] = None,
54
+ *,
55
+ base_url: str = DEFAULT_BASE_URL,
56
+ timeout: float = DEFAULT_TIMEOUT,
57
+ max_retries: int = DEFAULT_MAX_RETRIES,
58
+ config: Optional[SendlyConfig] = None,
59
+ ):
60
+ """
61
+ Create a new Sendly client
62
+
63
+ Args:
64
+ api_key: Your Sendly API key (sk_test_v1_xxx or sk_live_v1_xxx)
65
+ base_url: Base URL for the API (default: https://sendly.live/api/v1)
66
+ timeout: Request timeout in seconds (default: 30)
67
+ max_retries: Maximum retry attempts (default: 3)
68
+ config: Alternative configuration object
69
+ """
70
+ # Handle configuration
71
+ if config is not None:
72
+ api_key = config.api_key
73
+ base_url = config.base_url
74
+ timeout = config.timeout
75
+ max_retries = config.max_retries
76
+ elif api_key is None:
77
+ raise ValueError("api_key is required")
78
+
79
+ self._api_key = api_key
80
+ self._base_url = base_url
81
+ self._timeout = timeout
82
+ self._max_retries = max_retries
83
+
84
+ # Initialize HTTP client
85
+ self._http = HttpClient(
86
+ api_key=api_key,
87
+ base_url=base_url,
88
+ timeout=timeout,
89
+ max_retries=max_retries,
90
+ )
91
+
92
+ # Initialize resources
93
+ self.messages = MessagesResource(self._http)
94
+ self.webhooks = WebhooksResource(self._http)
95
+ self.account = AccountResource(self._http)
96
+
97
+ def __enter__(self) -> "Sendly":
98
+ return self
99
+
100
+ def __exit__(self, *args: Any) -> None:
101
+ self.close()
102
+
103
+ def close(self) -> None:
104
+ """Close the HTTP client and release resources"""
105
+ self._http.close()
106
+
107
+ def is_test_mode(self) -> bool:
108
+ """
109
+ Check if the client is using a test API key
110
+
111
+ Returns:
112
+ True if using a test key (sk_test_v1_xxx)
113
+
114
+ Example:
115
+ >>> if client.is_test_mode():
116
+ ... print('Running in test mode')
117
+ """
118
+ return self._http.is_test_mode()
119
+
120
+ def get_rate_limit_info(self) -> Optional[RateLimitInfo]:
121
+ """
122
+ Get current rate limit information
123
+
124
+ Returns the rate limit info from the most recent API request.
125
+
126
+ Returns:
127
+ Rate limit info or None if no requests have been made
128
+
129
+ Example:
130
+ >>> client.messages.send(to='+1555...', text='Hello!')
131
+ >>> rate_limit = client.get_rate_limit_info()
132
+ >>> if rate_limit:
133
+ ... print(f'{rate_limit.remaining}/{rate_limit.limit} remaining')
134
+ """
135
+ return self._http.get_rate_limit_info()
136
+
137
+ @property
138
+ def base_url(self) -> str:
139
+ """Get the configured base URL"""
140
+ return self._base_url
141
+
142
+
143
+ class AsyncSendly:
144
+ """
145
+ Sendly API Client (asynchronous)
146
+
147
+ Async version of the Sendly client for use with asyncio.
148
+
149
+ Example:
150
+ >>> import asyncio
151
+ >>> from sendly import AsyncSendly
152
+ >>>
153
+ >>> async def main():
154
+ ... async with AsyncSendly('sk_live_v1_xxx') as client:
155
+ ... message = await client.messages.send(
156
+ ... to='+15551234567',
157
+ ... text='Hello from Sendly!'
158
+ ... )
159
+ ... print(message.id)
160
+ >>>
161
+ >>> asyncio.run(main())
162
+
163
+ Example without context manager:
164
+ >>> client = AsyncSendly('sk_live_v1_xxx')
165
+ >>> try:
166
+ ... message = await client.messages.send(to='+1555...', text='Hello!')
167
+ ... finally:
168
+ ... await client.close()
169
+ """
170
+
171
+ def __init__(
172
+ self,
173
+ api_key: Optional[str] = None,
174
+ *,
175
+ base_url: str = DEFAULT_BASE_URL,
176
+ timeout: float = DEFAULT_TIMEOUT,
177
+ max_retries: int = DEFAULT_MAX_RETRIES,
178
+ config: Optional[SendlyConfig] = None,
179
+ ):
180
+ """
181
+ Create a new async Sendly client
182
+
183
+ Args:
184
+ api_key: Your Sendly API key (sk_test_v1_xxx or sk_live_v1_xxx)
185
+ base_url: Base URL for the API (default: https://sendly.live/api/v1)
186
+ timeout: Request timeout in seconds (default: 30)
187
+ max_retries: Maximum retry attempts (default: 3)
188
+ config: Alternative configuration object
189
+ """
190
+ # Handle configuration
191
+ if config is not None:
192
+ api_key = config.api_key
193
+ base_url = config.base_url
194
+ timeout = config.timeout
195
+ max_retries = config.max_retries
196
+ elif api_key is None:
197
+ raise ValueError("api_key is required")
198
+
199
+ self._api_key = api_key
200
+ self._base_url = base_url
201
+ self._timeout = timeout
202
+ self._max_retries = max_retries
203
+
204
+ # Initialize HTTP client
205
+ self._http = AsyncHttpClient(
206
+ api_key=api_key,
207
+ base_url=base_url,
208
+ timeout=timeout,
209
+ max_retries=max_retries,
210
+ )
211
+
212
+ # Initialize resources
213
+ self.messages = AsyncMessagesResource(self._http)
214
+ self.webhooks = AsyncWebhooksResource(self._http)
215
+ self.account = AsyncAccountResource(self._http)
216
+
217
+ async def __aenter__(self) -> "AsyncSendly":
218
+ return self
219
+
220
+ async def __aexit__(self, *args: Any) -> None:
221
+ await self.close()
222
+
223
+ async def close(self) -> None:
224
+ """Close the HTTP client and release resources"""
225
+ await self._http.close()
226
+
227
+ def is_test_mode(self) -> bool:
228
+ """
229
+ Check if the client is using a test API key
230
+
231
+ Returns:
232
+ True if using a test key (sk_test_v1_xxx)
233
+ """
234
+ return self._http.is_test_mode()
235
+
236
+ def get_rate_limit_info(self) -> Optional[RateLimitInfo]:
237
+ """
238
+ Get current rate limit information
239
+
240
+ Returns:
241
+ Rate limit info or None if no requests have been made
242
+ """
243
+ return self._http.get_rate_limit_info()
244
+
245
+ @property
246
+ def base_url(self) -> str:
247
+ """Get the configured base URL"""
248
+ return self._base_url
sendly/errors.py ADDED
@@ -0,0 +1,169 @@
1
+ """
2
+ Sendly SDK Error Classes
3
+
4
+ Custom exceptions for different error scenarios.
5
+ """
6
+
7
+ from typing import Any, Dict, Optional
8
+
9
+ from .types import ApiErrorResponse
10
+
11
+
12
+ class SendlyError(Exception):
13
+ """Base error class for all Sendly SDK errors"""
14
+
15
+ def __init__(
16
+ self,
17
+ message: str,
18
+ code: str = "internal_error",
19
+ status_code: Optional[int] = None,
20
+ response: Optional[ApiErrorResponse] = None,
21
+ ):
22
+ super().__init__(message)
23
+ self.message = message
24
+ self.code = code
25
+ self.status_code = status_code
26
+ self.response = response
27
+
28
+ def __str__(self) -> str:
29
+ if self.status_code:
30
+ return f"[{self.code}] ({self.status_code}) {self.message}"
31
+ return f"[{self.code}] {self.message}"
32
+
33
+ def __repr__(self) -> str:
34
+ return f"{self.__class__.__name__}(code={self.code!r}, message={self.message!r})"
35
+
36
+ @classmethod
37
+ def from_response(cls, status_code: int, response_data: Dict[str, Any]) -> "SendlyError":
38
+ """Create a SendlyError from an API response"""
39
+ try:
40
+ error_response = ApiErrorResponse(**response_data)
41
+ except Exception:
42
+ error_response = ApiErrorResponse(
43
+ error="internal_error",
44
+ message=str(response_data),
45
+ )
46
+
47
+ code = error_response.error
48
+ message = error_response.message
49
+
50
+ # Return specific error types based on error code
51
+ if code in (
52
+ "unauthorized",
53
+ "invalid_auth_format",
54
+ "invalid_key_format",
55
+ "invalid_api_key",
56
+ "api_key_required",
57
+ "key_revoked",
58
+ "key_expired",
59
+ "insufficient_permissions",
60
+ ):
61
+ return AuthenticationError(message, code, status_code, error_response)
62
+
63
+ if code == "rate_limit_exceeded":
64
+ return RateLimitError(
65
+ message,
66
+ retry_after=error_response.retry_after or 60,
67
+ status_code=status_code,
68
+ response=error_response,
69
+ )
70
+
71
+ if code == "insufficient_credits":
72
+ return InsufficientCreditsError(
73
+ message,
74
+ credits_needed=error_response.credits_needed or 0,
75
+ current_balance=error_response.current_balance or 0,
76
+ status_code=status_code,
77
+ response=error_response,
78
+ )
79
+
80
+ if code in ("invalid_request", "unsupported_destination"):
81
+ return ValidationError(message, code, status_code, error_response)
82
+
83
+ if code == "not_found":
84
+ return NotFoundError(message, status_code, error_response)
85
+
86
+ return cls(message, code, status_code, error_response)
87
+
88
+
89
+ class AuthenticationError(SendlyError):
90
+ """Thrown when authentication fails"""
91
+
92
+ def __init__(
93
+ self,
94
+ message: str,
95
+ code: str = "unauthorized",
96
+ status_code: Optional[int] = None,
97
+ response: Optional[ApiErrorResponse] = None,
98
+ ):
99
+ super().__init__(message, code, status_code, response)
100
+
101
+
102
+ class RateLimitError(SendlyError):
103
+ """Thrown when rate limit is exceeded"""
104
+
105
+ def __init__(
106
+ self,
107
+ message: str,
108
+ retry_after: int,
109
+ status_code: Optional[int] = None,
110
+ response: Optional[ApiErrorResponse] = None,
111
+ ):
112
+ super().__init__(message, "rate_limit_exceeded", status_code, response)
113
+ self.retry_after = retry_after
114
+
115
+
116
+ class InsufficientCreditsError(SendlyError):
117
+ """Thrown when credit balance is insufficient"""
118
+
119
+ def __init__(
120
+ self,
121
+ message: str,
122
+ credits_needed: int,
123
+ current_balance: int,
124
+ status_code: Optional[int] = None,
125
+ response: Optional[ApiErrorResponse] = None,
126
+ ):
127
+ super().__init__(message, "insufficient_credits", status_code, response)
128
+ self.credits_needed = credits_needed
129
+ self.current_balance = current_balance
130
+
131
+
132
+ class ValidationError(SendlyError):
133
+ """Thrown when request validation fails"""
134
+
135
+ def __init__(
136
+ self,
137
+ message: str,
138
+ code: str = "invalid_request",
139
+ status_code: Optional[int] = None,
140
+ response: Optional[ApiErrorResponse] = None,
141
+ ):
142
+ super().__init__(message, code, status_code, response)
143
+
144
+
145
+ class NotFoundError(SendlyError):
146
+ """Thrown when a resource is not found"""
147
+
148
+ def __init__(
149
+ self,
150
+ message: str,
151
+ status_code: Optional[int] = None,
152
+ response: Optional[ApiErrorResponse] = None,
153
+ ):
154
+ super().__init__(message, "not_found", status_code, response)
155
+
156
+
157
+ class NetworkError(SendlyError):
158
+ """Thrown when a network or connection error occurs"""
159
+
160
+ def __init__(self, message: str, cause: Optional[Exception] = None):
161
+ super().__init__(message, "internal_error")
162
+ self.cause = cause
163
+
164
+
165
+ class TimeoutError(SendlyError):
166
+ """Thrown when a request times out"""
167
+
168
+ def __init__(self, message: str = "Request timed out"):
169
+ super().__init__(message, "internal_error")
@@ -0,0 +1,5 @@
1
+ """Sendly SDK Resources"""
2
+
3
+ from .messages import AsyncMessagesResource, MessagesResource
4
+
5
+ __all__ = ["MessagesResource", "AsyncMessagesResource"]