waba-sdk 1.0.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.
waba_sdk/__init__.py ADDED
@@ -0,0 +1,153 @@
1
+ """Async Python SDK for the WhatsApp Business Cloud API.
2
+
3
+ Quickstart::
4
+
5
+ import asyncio
6
+ from waba_sdk import WhatsApp
7
+
8
+ async def main():
9
+ async with WhatsApp.from_env() as client:
10
+ await client.send_text("+15551234567", "hello")
11
+
12
+ asyncio.run(main())
13
+ """
14
+
15
+ from .client import WhatsApp
16
+ from .errors import (
17
+ AuthenticationError,
18
+ InvalidRequestError,
19
+ MediaError,
20
+ RateLimitError,
21
+ ServerError,
22
+ WebhookVerificationError,
23
+ WhatsAppError,
24
+ )
25
+ from .media import MediaDownload, MediaInfo
26
+ from .messages import (
27
+ AudioMessage,
28
+ BodyComponent,
29
+ Button,
30
+ ButtonComponent,
31
+ ButtonParameter,
32
+ ButtonsMessage,
33
+ CatalogMessage,
34
+ Contact,
35
+ ContactAddress,
36
+ ContactEmail,
37
+ ContactName,
38
+ ContactOrg,
39
+ ContactPhone,
40
+ ContactsMessage,
41
+ ContactURL,
42
+ Context,
43
+ CTAUrlMessage,
44
+ CurrencyParameter,
45
+ CurrencyValue,
46
+ DateTimeParameter,
47
+ DateTimeValue,
48
+ DocumentHeader,
49
+ DocumentMessage,
50
+ DocumentParameter,
51
+ FlowMessage,
52
+ Header,
53
+ HeaderComponent,
54
+ ImageHeader,
55
+ ImageMessage,
56
+ ImageParameter,
57
+ InteractiveMessage,
58
+ ListMessage,
59
+ ListRow,
60
+ ListSection,
61
+ LocationMessage,
62
+ LocationRequestMessage,
63
+ Message,
64
+ MultiProductMessage,
65
+ OutboundMessage,
66
+ Parameter,
67
+ ProductSection,
68
+ ReactionMessage,
69
+ SingleProductMessage,
70
+ StickerMessage,
71
+ TemplateComponent,
72
+ TemplateMessage,
73
+ TextHeader,
74
+ TextMessage,
75
+ TextParameter,
76
+ VideoHeader,
77
+ VideoMessage,
78
+ VideoParameter,
79
+ )
80
+ from .types import normalize_phone
81
+
82
+ __version__ = "1.0.0"
83
+
84
+ __all__ = [
85
+ "__version__",
86
+ # client
87
+ "WhatsApp",
88
+ "normalize_phone",
89
+ # errors
90
+ "WhatsAppError",
91
+ "AuthenticationError",
92
+ "InvalidRequestError",
93
+ "RateLimitError",
94
+ "ServerError",
95
+ "MediaError",
96
+ "WebhookVerificationError",
97
+ # media value objects
98
+ "MediaInfo",
99
+ "MediaDownload",
100
+ # messages
101
+ "OutboundMessage",
102
+ "Context",
103
+ "Message",
104
+ "TextMessage",
105
+ "ImageMessage",
106
+ "VideoMessage",
107
+ "AudioMessage",
108
+ "DocumentMessage",
109
+ "StickerMessage",
110
+ "LocationMessage",
111
+ "ContactsMessage",
112
+ "Contact",
113
+ "ContactName",
114
+ "ContactPhone",
115
+ "ContactEmail",
116
+ "ContactAddress",
117
+ "ContactURL",
118
+ "ContactOrg",
119
+ "ReactionMessage",
120
+ "TemplateMessage",
121
+ "Parameter",
122
+ "TextParameter",
123
+ "CurrencyParameter",
124
+ "CurrencyValue",
125
+ "DateTimeParameter",
126
+ "DateTimeValue",
127
+ "ImageParameter",
128
+ "VideoParameter",
129
+ "DocumentParameter",
130
+ "HeaderComponent",
131
+ "BodyComponent",
132
+ "ButtonComponent",
133
+ "ButtonParameter",
134
+ "TemplateComponent",
135
+ "InteractiveMessage",
136
+ "Header",
137
+ "TextHeader",
138
+ "ImageHeader",
139
+ "VideoHeader",
140
+ "DocumentHeader",
141
+ "ButtonsMessage",
142
+ "Button",
143
+ "ListMessage",
144
+ "ListSection",
145
+ "ListRow",
146
+ "CTAUrlMessage",
147
+ "FlowMessage",
148
+ "SingleProductMessage",
149
+ "MultiProductMessage",
150
+ "CatalogMessage",
151
+ "ProductSection",
152
+ "LocationRequestMessage",
153
+ ]
waba_sdk/_http.py ADDED
@@ -0,0 +1,171 @@
1
+ """Internal HTTP client with lazy session, retries, and Graph error mapping."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import logging
7
+ import random
8
+ from typing import Any, Mapping, Optional
9
+
10
+ import aiohttp
11
+
12
+ from .errors import WhatsAppError, build_error
13
+
14
+ logger = logging.getLogger("waba_sdk.http")
15
+
16
+ _DEFAULT_RETRY_STATUSES = (429, 500, 502, 503, 504)
17
+
18
+
19
+ class HttpClient:
20
+ """Owns the shared :class:`aiohttp.ClientSession` and HTTP retry logic.
21
+
22
+ The session is created lazily on first request and reused for all
23
+ subsequent calls. Use ``await client.close()`` or ``async with`` to release
24
+ it.
25
+ """
26
+
27
+ def __init__(
28
+ self,
29
+ *,
30
+ token: str,
31
+ base_url: str,
32
+ timeout: float = 30.0,
33
+ max_retries: int = 2,
34
+ retry_statuses: tuple[int, ...] = _DEFAULT_RETRY_STATUSES,
35
+ session: Optional[aiohttp.ClientSession] = None,
36
+ ) -> None:
37
+ if not token:
38
+ raise ValueError("token is required")
39
+ self._token = token
40
+ self._base_url = base_url.rstrip("/")
41
+ self._timeout = aiohttp.ClientTimeout(total=timeout)
42
+ self._max_retries = max_retries
43
+ self._retry_statuses = retry_statuses
44
+ self._session = session
45
+ self._owns_session = session is None
46
+ self._lock = asyncio.Lock()
47
+
48
+ @property
49
+ def base_url(self) -> str:
50
+ return self._base_url
51
+
52
+ async def _ensure_session(self) -> aiohttp.ClientSession:
53
+ if self._session is not None and not self._session.closed:
54
+ return self._session
55
+ async with self._lock:
56
+ if self._session is None or self._session.closed:
57
+ connector = aiohttp.TCPConnector(limit=100, ttl_dns_cache=300)
58
+ self._session = aiohttp.ClientSession(
59
+ timeout=self._timeout,
60
+ connector=connector,
61
+ )
62
+ self._owns_session = True
63
+ return self._session
64
+
65
+ async def close(self) -> None:
66
+ if (
67
+ self._session is not None
68
+ and self._owns_session
69
+ and not self._session.closed
70
+ ):
71
+ await self._session.close()
72
+ self._session = None
73
+
74
+ async def __aenter__(self) -> "HttpClient":
75
+ await self._ensure_session()
76
+ return self
77
+
78
+ async def __aexit__(self, *exc: Any) -> None:
79
+ await self.close()
80
+
81
+ @staticmethod
82
+ def _backoff(attempt: int) -> float:
83
+ return min(0.5 * (2**attempt), 30.0) + random.uniform(0, 0.25)
84
+
85
+ async def request(
86
+ self,
87
+ method: str,
88
+ path: str,
89
+ *,
90
+ json: Optional[Mapping[str, Any]] = None,
91
+ data: Any = None,
92
+ params: Optional[Mapping[str, Any]] = None,
93
+ headers: Optional[Mapping[str, str]] = None,
94
+ absolute_url: Optional[str] = None,
95
+ ) -> aiohttp.ClientResponse:
96
+ """Perform a request and return the open response.
97
+
98
+ Caller is responsible for ``await response.read()`` / ``response.json()``
99
+ and releasing it (use ``async with`` on the return value).
100
+ """
101
+ session = await self._ensure_session()
102
+ url = absolute_url or f"{self._base_url}{path}"
103
+ merged_headers = {"Authorization": f"Bearer {self._token}"}
104
+ if headers:
105
+ merged_headers.update(headers)
106
+
107
+ attempt = 0
108
+ while True:
109
+ try:
110
+ resp = await session.request(
111
+ method,
112
+ url,
113
+ json=json,
114
+ data=data,
115
+ params=params,
116
+ headers=merged_headers,
117
+ )
118
+ except aiohttp.ClientError as exc:
119
+ if attempt >= self._max_retries:
120
+ raise WhatsAppError(f"network error: {exc}") from exc
121
+ await asyncio.sleep(self._backoff(attempt))
122
+ attempt += 1
123
+ continue
124
+
125
+ if resp.status in self._retry_statuses and attempt < self._max_retries:
126
+ retry_after_header = resp.headers.get("Retry-After")
127
+ try:
128
+ retry_after = (
129
+ float(retry_after_header) if retry_after_header else 0.0
130
+ )
131
+ except (TypeError, ValueError):
132
+ retry_after = 0.0
133
+ wait = retry_after if retry_after > 0 else self._backoff(attempt)
134
+ resp.release()
135
+ await asyncio.sleep(wait)
136
+ attempt += 1
137
+ continue
138
+
139
+ if resp.status >= 400:
140
+ await self._raise_for_status(resp)
141
+
142
+ return resp
143
+
144
+ @staticmethod
145
+ async def _raise_for_status(resp: aiohttp.ClientResponse) -> None:
146
+ try:
147
+ data: Any = await resp.json()
148
+ except Exception:
149
+ try:
150
+ data = {"raw": await resp.text()}
151
+ except Exception:
152
+ data = None
153
+ raise build_error(resp.status, data, headers=dict(resp.headers))
154
+
155
+ async def post_json(self, path: str, payload: Mapping[str, Any]) -> dict[str, Any]:
156
+ resp = await self.request("POST", path, json=payload)
157
+ async with resp:
158
+ return await resp.json()
159
+
160
+ async def get_json(
161
+ self,
162
+ path: str,
163
+ *,
164
+ params: Optional[Mapping[str, Any]] = None,
165
+ ) -> dict[str, Any]:
166
+ resp = await self.request("GET", path, params=params)
167
+ async with resp:
168
+ return await resp.json()
169
+
170
+
171
+ __all__ = ["HttpClient"]