waapi 0.1.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.
waapi/_http.py ADDED
@@ -0,0 +1,97 @@
1
+ """HTTP transport and error mapping.
2
+
3
+ Both the sync and the async client route every call through :func:`interpret`,
4
+ so the two cannot drift apart on what counts as a failure.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json as _json
10
+ from collections.abc import Mapping
11
+ from typing import Any
12
+
13
+ import httpx
14
+
15
+ from .exceptions import (
16
+ AuthenticationError,
17
+ FailedActionError,
18
+ NotFoundError,
19
+ RateLimitError,
20
+ ServerError,
21
+ ValidationError,
22
+ WaAPIError,
23
+ )
24
+
25
+ DEFAULT_BASE_URL = "https://waapi.app/api/v1"
26
+ DEFAULT_TIMEOUT = 30.0
27
+
28
+
29
+ def build_headers(token: str, user_agent: str) -> dict[str, str]:
30
+ return {
31
+ "authorization": f"Bearer {token}",
32
+ "accept": "application/json",
33
+ "content-type": "application/json",
34
+ "user-agent": user_agent,
35
+ }
36
+
37
+
38
+ def _decode(response: httpx.Response) -> Any:
39
+ try:
40
+ return response.json()
41
+ except (ValueError, _json.JSONDecodeError):
42
+ return response.text
43
+
44
+
45
+ def interpret(response: httpx.Response, *, check_body_status: bool = True) -> Any:
46
+ """Turn a response into a payload, or raise the matching exception.
47
+
48
+ ``check_body_status`` exists because a few endpoints answer without the
49
+ envelope. Leave it on unless you know the endpoint is one of them: turning
50
+ it off is what makes a failed send look like a successful one.
51
+ """
52
+ body = _decode(response)
53
+ status = response.status_code
54
+
55
+ if status == 401 or status == 403:
56
+ raise AuthenticationError(
57
+ f"WaAPI rejected the API token (HTTP {status}): {_brief(body)}"
58
+ )
59
+ if status == 404:
60
+ raise NotFoundError()
61
+ if status == 422:
62
+ raise ValidationError(body)
63
+ if status == 429:
64
+ retry_after = response.headers.get("retry-after")
65
+ raise RateLimitError(
66
+ f"Rate limited by WaAPI (HTTP 429): {_brief(body)}",
67
+ retry_after=float(retry_after) if retry_after else None,
68
+ )
69
+ if status == 400:
70
+ raise FailedActionError(f"WaAPI rejected the request: {_brief(body)}", body)
71
+ if 500 <= status < 600:
72
+ raise ServerError(f"WaAPI returned HTTP {status}: {_brief(body)}")
73
+ if not 200 <= status < 300:
74
+ raise WaAPIError(f"WaAPI returned HTTP {status}: {_brief(body)}")
75
+
76
+ # A 2xx is not proof the action happened. The API answers 200 with
77
+ # {"status": "error"} when, for example, the instance is not connected.
78
+ if check_body_status and isinstance(body, Mapping):
79
+ envelope = body.get("status")
80
+ if envelope is not None and envelope != "success":
81
+ raise FailedActionError(
82
+ "WaAPI accepted the request but did not carry the action out: "
83
+ + _brief(body),
84
+ body,
85
+ )
86
+
87
+ return body
88
+
89
+
90
+ def _brief(body: Any, limit: int = 400) -> str:
91
+ text = body if isinstance(body, str) else _json.dumps(body, ensure_ascii=False)
92
+ return text if len(text) <= limit else text[:limit] + "..."
93
+
94
+
95
+ def prune(payload: Mapping[str, Any]) -> dict[str, Any]:
96
+ """Drop keys whose value is None so optional parameters stay optional."""
97
+ return {k: v for k, v in payload.items() if v is not None}
waapi/client.py ADDED
@@ -0,0 +1,118 @@
1
+ """Synchronous and asynchronous WaAPI clients."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ import httpx
8
+
9
+ from . import __version__
10
+ from ._actions import ActionsMixin, AsyncActionsMixin
11
+ from ._http import DEFAULT_BASE_URL, DEFAULT_TIMEOUT, build_headers, interpret
12
+
13
+ _USER_AGENT = f"waapi-python-sdk/{__version__}"
14
+
15
+
16
+ class _Base:
17
+ def __init__(
18
+ self,
19
+ token: str,
20
+ *,
21
+ instance_id: int | str | None = None,
22
+ base_url: str = DEFAULT_BASE_URL,
23
+ timeout: float = DEFAULT_TIMEOUT,
24
+ ) -> None:
25
+ if not token:
26
+ raise ValueError("an API token is required — create one at https://waapi.app/user/api-tokens")
27
+ self.token = token
28
+ self.instance_id = instance_id
29
+ self.base_url = base_url.rstrip("/")
30
+ self.timeout = timeout
31
+
32
+ def _resolve_instance(self, instance_id: int | str | None) -> int | str:
33
+ """Fall back to the client-wide instance so single-instance code stays short."""
34
+ resolved = instance_id if instance_id is not None else self.instance_id
35
+ if resolved is None:
36
+ raise ValueError(
37
+ "no instance id given and none configured on the client — "
38
+ "pass instance_id=... to the call or to WaAPI(...)"
39
+ )
40
+ return resolved
41
+
42
+
43
+ class WaAPI(_Base, ActionsMixin):
44
+ """Blocking client.
45
+
46
+ >>> client = WaAPI(token="...", instance_id=123)
47
+ >>> client.send_message(chat_id="4915112345678@c.us", message="Hello")
48
+ """
49
+
50
+ def __init__(self, token: str, **kwargs: Any) -> None:
51
+ transport = kwargs.pop("transport", None)
52
+ super().__init__(token, **kwargs)
53
+ self._client = httpx.Client(
54
+ base_url=self.base_url,
55
+ headers=build_headers(self.token, _USER_AGENT),
56
+ timeout=self.timeout,
57
+ transport=transport,
58
+ )
59
+
60
+ def request(
61
+ self,
62
+ method: str,
63
+ path: str,
64
+ *,
65
+ json: Any = None,
66
+ params: Any = None,
67
+ check_body_status: bool = True,
68
+ ) -> Any:
69
+ response = self._client.request(method, path, json=json, params=params)
70
+ return interpret(response, check_body_status=check_body_status)
71
+
72
+ def close(self) -> None:
73
+ self._client.close()
74
+
75
+ def __enter__(self) -> WaAPI:
76
+ return self
77
+
78
+ def __exit__(self, *exc: object) -> None:
79
+ self.close()
80
+
81
+
82
+ class AsyncWaAPI(_Base, AsyncActionsMixin):
83
+ """Non-blocking client with the same method names as :class:`WaAPI`.
84
+
85
+ >>> async with AsyncWaAPI(token="...", instance_id=123) as client:
86
+ ... await client.send_message(chat_id="4915112345678@c.us", message="Hi")
87
+ """
88
+
89
+ def __init__(self, token: str, **kwargs: Any) -> None:
90
+ transport = kwargs.pop("transport", None)
91
+ super().__init__(token, **kwargs)
92
+ self._client = httpx.AsyncClient(
93
+ base_url=self.base_url,
94
+ headers=build_headers(self.token, _USER_AGENT),
95
+ timeout=self.timeout,
96
+ transport=transport,
97
+ )
98
+
99
+ async def request(
100
+ self,
101
+ method: str,
102
+ path: str,
103
+ *,
104
+ json: Any = None,
105
+ params: Any = None,
106
+ check_body_status: bool = True,
107
+ ) -> Any:
108
+ response = await self._client.request(method, path, json=json, params=params)
109
+ return interpret(response, check_body_status=check_body_status)
110
+
111
+ async def aclose(self) -> None:
112
+ await self._client.aclose()
113
+
114
+ async def __aenter__(self) -> AsyncWaAPI:
115
+ return self
116
+
117
+ async def __aexit__(self, *exc: object) -> None:
118
+ await self.aclose()
waapi/exceptions.py ADDED
@@ -0,0 +1,60 @@
1
+ """Exceptions raised by the SDK.
2
+
3
+ The hierarchy mirrors the PHP SDK so that the same API condition carries the
4
+ same name in both: a 422 is a validation problem, a 404 is a missing resource,
5
+ a 400 is a rejected action.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any
11
+
12
+
13
+ class WaAPIError(Exception):
14
+ """Base class for every error this SDK raises."""
15
+
16
+
17
+ class ValidationError(WaAPIError):
18
+ """The request body failed the API's validation (HTTP 422)."""
19
+
20
+ def __init__(self, errors: Any) -> None:
21
+ super().__init__("The given data failed to pass validation.")
22
+ self.errors = errors
23
+
24
+
25
+ class NotFoundError(WaAPIError):
26
+ """The addressed resource does not exist (HTTP 404)."""
27
+
28
+ def __init__(self, message: str = "The resource you are looking for could not be found.") -> None:
29
+ super().__init__(message)
30
+
31
+
32
+ class AuthenticationError(WaAPIError):
33
+ """The API token is missing, wrong or lacks the required scope (HTTP 401/403)."""
34
+
35
+
36
+ class RateLimitError(WaAPIError):
37
+ """Too many requests (HTTP 429)."""
38
+
39
+ def __init__(self, message: str, retry_after: float | None = None) -> None:
40
+ super().__init__(message)
41
+ self.retry_after = retry_after
42
+
43
+
44
+ class FailedActionError(WaAPIError):
45
+ """The API accepted the request but did not carry the action out.
46
+
47
+ This covers two cases that look different on the wire but mean the same
48
+ thing to a caller: an HTTP 400, and an HTTP 200 whose body says
49
+ ``{"status": "error"}``. The second is the dangerous one — it is a
50
+ successful HTTP exchange, so code that only checks the status code treats
51
+ a message that was never sent as delivered.
52
+ """
53
+
54
+ def __init__(self, message: str, response: Any = None) -> None:
55
+ super().__init__(message)
56
+ self.response = response
57
+
58
+
59
+ class ServerError(WaAPIError):
60
+ """The API returned a 5xx."""
waapi/py.typed ADDED
File without changes
@@ -0,0 +1,185 @@
1
+ Metadata-Version: 2.5
2
+ Name: waapi
3
+ Version: 0.1.0
4
+ Summary: Official Python SDK for the WaAPI REST API
5
+ Project-URL: Homepage, https://waapi.app
6
+ Project-URL: Documentation, https://waapi.app/docs
7
+ Project-URL: Source, https://github.com/WaAPIapp/waapi-python-sdk
8
+ Project-URL: Issues, https://github.com/WaAPIapp/waapi-python-sdk/issues
9
+ Author-email: WaAPI <info@waapi.app>
10
+ License: The MIT License (MIT)
11
+
12
+ Copyright (c) WaAPI <info@waapi.app>
13
+
14
+ Permission is hereby granted, free of charge, to any person obtaining a copy
15
+ of this software and associated documentation files (the "Software"), to deal
16
+ in the Software without restriction, including without limitation the rights
17
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
18
+ copies of the Software, and to permit persons to whom the Software is
19
+ furnished to do so, subject to the following conditions:
20
+
21
+ The above copyright notice and this permission notice shall be included in all
22
+ copies or substantial portions of the Software.
23
+
24
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
25
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
26
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
27
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
28
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
29
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
30
+ SOFTWARE.
31
+ License-File: LICENSE
32
+ Keywords: api,automation,chatbot,messaging,sdk,waapi
33
+ Classifier: Development Status :: 4 - Beta
34
+ Classifier: Intended Audience :: Developers
35
+ Classifier: License :: OSI Approved :: MIT License
36
+ Classifier: Programming Language :: Python :: 3
37
+ Classifier: Programming Language :: Python :: 3.9
38
+ Classifier: Programming Language :: Python :: 3.10
39
+ Classifier: Programming Language :: Python :: 3.11
40
+ Classifier: Programming Language :: Python :: 3.12
41
+ Classifier: Programming Language :: Python :: 3.13
42
+ Classifier: Topic :: Communications :: Chat
43
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
44
+ Classifier: Typing :: Typed
45
+ Requires-Python: >=3.9
46
+ Requires-Dist: httpx<1.0,>=0.24
47
+ Provides-Extra: dev
48
+ Requires-Dist: mypy>=1.8; extra == 'dev'
49
+ Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
50
+ Requires-Dist: pytest>=7; extra == 'dev'
51
+ Requires-Dist: respx>=0.20; extra == 'dev'
52
+ Requires-Dist: ruff>=0.4; extra == 'dev'
53
+ Description-Content-Type: text/markdown
54
+
55
+ # WaAPI Python SDK
56
+
57
+ Official Python client for the [WaAPI](https://waapi.app) REST API — send and
58
+ receive WhatsApp messages, manage chats, groups and channels from Python.
59
+
60
+ [![PyPI](https://img.shields.io/pypi/v/waapi?style=for-the-badge)](https://pypi.org/project/waapi/)
61
+ [![Python](https://img.shields.io/pypi/pyversions/waapi?style=for-the-badge)](https://pypi.org/project/waapi/)
62
+ [![License](https://img.shields.io/badge/license-MIT-blue?style=for-the-badge)](LICENSE)
63
+
64
+ ```bash
65
+ pip install waapi
66
+ ```
67
+
68
+ ## Quick start
69
+
70
+ ```python
71
+ from waapi import WaAPI
72
+
73
+ client = WaAPI(token="YOUR_API_TOKEN", instance_id=123)
74
+
75
+ client.send_message(
76
+ chat_id="4915112345678@c.us",
77
+ message="Deployment finished.",
78
+ )
79
+ ```
80
+
81
+ Get a token at [waapi.app/user/api-tokens](https://waapi.app/user/api-tokens)
82
+ and create an instance connected to your number.
83
+
84
+ ### The chat ID is the one thing to get right
85
+
86
+ Its suffix decides where the message lands, and a wrong suffix is accepted and
87
+ delivers nothing:
88
+
89
+ | Target | Format |
90
+ |---|---|
91
+ | One person | `4915112345678@c.us` |
92
+ | Group | `123456789-123456789@g.us` |
93
+ | Channel | `123456789@newsletter` |
94
+
95
+ ## Async
96
+
97
+ Same method names, awaited:
98
+
99
+ ```python
100
+ from waapi import AsyncWaAPI
101
+
102
+ async with AsyncWaAPI(token="YOUR_API_TOKEN", instance_id=123) as client:
103
+ await client.send_message(chat_id="4915112345678@c.us", message="Hi")
104
+ ```
105
+
106
+ ## Errors
107
+
108
+ A successful HTTP exchange is not proof the message was sent. The API answers
109
+ `200` with `{"status": "error"}` when, for example, the instance is not
110
+ connected — so the SDK raises on that too, rather than handing back a body that
111
+ looks like success.
112
+
113
+ ```python
114
+ from waapi import WaAPI, FailedActionError, AuthenticationError, RateLimitError
115
+
116
+ try:
117
+ client.send_message(chat_id="4915112345678@c.us", message="Hi")
118
+ except AuthenticationError:
119
+ ... # token wrong, expired, or missing scopes
120
+ except RateLimitError as e:
121
+ time.sleep(e.retry_after or 5)
122
+ except FailedActionError as e:
123
+ ... # accepted but not carried out — e.response has the detail
124
+ ```
125
+
126
+ | Exception | Raised on |
127
+ |---|---|
128
+ | `AuthenticationError` | HTTP 401, 403 |
129
+ | `NotFoundError` | HTTP 404 |
130
+ | `ValidationError` | HTTP 422 — `.errors` holds the field errors |
131
+ | `RateLimitError` | HTTP 429 — `.retry_after` in seconds when the API sends it |
132
+ | `FailedActionError` | HTTP 400, **and HTTP 200 with `status: error`** |
133
+ | `ServerError` | HTTP 5xx |
134
+
135
+ All inherit from `WaAPIError`.
136
+
137
+ ## Coverage
138
+
139
+ All **122 client actions** are wrapped, typed, and available on both clients:
140
+
141
+ ```python
142
+ client.create_group(group_name="Ops", group_participants=["4915112345678@c.us"])
143
+ client.send_media(chat_id="4915112345678@c.us", media_url="https://example.com/report.pdf")
144
+ client.get_contacts()
145
+ ```
146
+
147
+ They are generated from the same OpenAPI specification the n8n node and the MCP
148
+ tools come from, so they track the API instead of drifting behind it — see
149
+ [CONTRIBUTING.md](CONTRIBUTING.md).
150
+
151
+ An action added to the API since the last release is still reachable by name:
152
+
153
+ ```python
154
+ client.action("some-new-action", {"chatId": "4915112345678@c.us"})
155
+ ```
156
+
157
+ ## Configuration
158
+
159
+ ```python
160
+ WaAPI(
161
+ token="...", # required
162
+ instance_id=123, # optional; per-call instance_id overrides it
163
+ base_url="https://waapi.app/api/v1",
164
+ timeout=30.0,
165
+ )
166
+ ```
167
+
168
+ Passing `instance_id` to the client keeps single-instance code short. Any call
169
+ can still override it, and a call with neither raises before a request is sent.
170
+
171
+ ## Development
172
+
173
+ ```bash
174
+ python -m venv .venv && source .venv/bin/activate
175
+ pip install -e ".[dev]"
176
+ pytest
177
+ ```
178
+
179
+ The suite runs entirely against `httpx.MockTransport` — no network, no token,
180
+ no connected account.
181
+
182
+ ## License
183
+
184
+ MIT. Not affiliated with, endorsed or sponsored by WhatsApp LLC or Meta.
185
+ WhatsApp is a trademark of WhatsApp LLC.
@@ -0,0 +1,11 @@
1
+ waapi/__init__.py,sha256=ZwQguHWIZ4yj5vRQF3Nl8djc8Dtf7Eg8wymJ4Q-jrwE,659
2
+ waapi/_actions.py,sha256=0WnfpKGaK1mPrQ_ldlhTR90ypccITpKCmNqd7z1qRpI,5446
3
+ waapi/_generated.py,sha256=Ge0dFy50kSw9y6aiPjrd698kVQu80nPwn1P4XoZqEzs,105033
4
+ waapi/_http.py,sha256=LHTmpV7Imv7LbtqQ0phMKp0R0q01tct4_qbE6N7SSEI,3156
5
+ waapi/client.py,sha256=U9YsxmMNCwzHuOXcwgkLSlhA7hxDNKaP9EUyg2jsnrM,3628
6
+ waapi/exceptions.py,sha256=H979M-QskB-FAQISVAj1HeaETDOkcjxPkAv7Jyy62ws,1870
7
+ waapi/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
+ waapi-0.1.0.dist-info/METADATA,sha256=ogxwxcM-hVfMhRlZxVpEBRtg9SQX3jrrvkGv_wnzaHo,6468
9
+ waapi-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
10
+ waapi-0.1.0.dist-info/licenses/LICENSE,sha256=I_-EKdTBwwmElru8k9Gje8LTfCO4lG6OUJqk5zvne-c,1084
11
+ waapi-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) WaAPI <info@waapi.app>
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.