waapi 0.1.0__tar.gz

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,40 @@
1
+ name: Publish to PyPI
2
+
3
+ # Publishes on a v* tag using PyPI Trusted Publishing (OIDC) — no API token is
4
+ # stored in the repository. Configure the publisher once at
5
+ # https://pypi.org/manage/project/waapi/settings/publishing/
6
+ # owner: WaAPIapp repository: waapi-python-sdk
7
+ # workflow: publish.yml environment: pypi
8
+
9
+ on:
10
+ push:
11
+ tags: ['v*']
12
+
13
+ jobs:
14
+ build:
15
+ runs-on: ubuntu-latest
16
+ steps:
17
+ - uses: actions/checkout@v4
18
+ - uses: actions/setup-python@v5
19
+ with:
20
+ python-version: '3.12'
21
+ - run: pip install build twine
22
+ - run: python -m build
23
+ - run: twine check dist/*
24
+ - uses: actions/upload-artifact@v4
25
+ with:
26
+ name: dist
27
+ path: dist/
28
+
29
+ publish:
30
+ needs: build
31
+ runs-on: ubuntu-latest
32
+ environment: pypi
33
+ permissions:
34
+ id-token: write
35
+ steps:
36
+ - uses: actions/download-artifact@v4
37
+ with:
38
+ name: dist
39
+ path: dist/
40
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,32 @@
1
+ name: Tests
2
+
3
+ on:
4
+ push:
5
+ branches: [master]
6
+ pull_request:
7
+
8
+ jobs:
9
+ test:
10
+ runs-on: ubuntu-latest
11
+ strategy:
12
+ fail-fast: false
13
+ matrix:
14
+ python-version: ['3.9', '3.10', '3.11', '3.12', '3.13']
15
+ steps:
16
+ - uses: actions/checkout@v4
17
+ - uses: actions/setup-python@v5
18
+ with:
19
+ python-version: ${{ matrix.python-version }}
20
+ - run: pip install -e ".[dev]"
21
+ - run: pytest -q
22
+
23
+ lint:
24
+ runs-on: ubuntu-latest
25
+ steps:
26
+ - uses: actions/checkout@v4
27
+ - uses: actions/setup-python@v5
28
+ with:
29
+ python-version: '3.12'
30
+ - run: pip install -e ".[dev]"
31
+ - run: ruff check .
32
+ - run: mypy src/waapi
waapi-0.1.0/.gitignore ADDED
@@ -0,0 +1,14 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ build/
5
+ dist/
6
+ .venv/
7
+ venv/
8
+ .pytest_cache/
9
+ .ruff_cache/
10
+ .mypy_cache/
11
+ .coverage
12
+ htmlcov/
13
+ .DS_Store
14
+ dist_test/
@@ -0,0 +1,53 @@
1
+ # Contributing
2
+
3
+ ## The method surface is generated, not hand-written
4
+
5
+ The API exposes 122 client actions across 130 paths. The n8n node and the MCP
6
+ tools are generated from `storage/swagger.json`, which is why both stay
7
+ current, while a hand-maintained SDK falls behind — the Laravel SDK named 25 of
8
+ 122 actions before its methods were generated.
9
+
10
+ `GenerateSdkMethods` in the proxy repository emits them, with a `python` and a
11
+ `python-async` flavour:
12
+
13
+ ```bash
14
+ python3 scripts/sync_actions.py ../eazewhatsapp-proxy
15
+ # wrote 122 sync and 122 async methods, and 122 tests
16
+ ```
17
+
18
+ That overwrites two files whole, and nothing hand-written lives in either:
19
+
20
+ - `src/waapi/_generated.py` — `GeneratedActions` and `GeneratedAsyncActions`
21
+ - `tests/test_generated_actions.py` — one payload test per action
22
+
23
+ The script refuses to write if the two surfaces come out at different sizes or
24
+ if the generator emitted nothing, and it runs `ruff` over its own output, so a
25
+ file marked DO NOT EDIT never needs a human to fix its formatting.
26
+
27
+ Hand-written methods live in `src/waapi/_actions.py`, which composes the
28
+ generated classes in. Add one there only if it cannot come from the spec — the
29
+ instance endpoints are the current example, because they are ordinary REST
30
+ routes rather than client actions. Anything added by hand is one more place a
31
+ future API change has to reach.
32
+
33
+ If you do add one, mirror it in **both** `ActionsMixin` and
34
+ `AsyncActionsMixin` — `test_sync_and_async_expose_the_same_methods` fails
35
+ otherwise, on purpose.
36
+
37
+ ## Conventions
38
+
39
+ - Public arguments are `snake_case`; the JSON keys they map to stay
40
+ `camelCase` as the API defines them.
41
+ - Optional arguments default to `None` and are dropped from the payload by
42
+ `prune()`. Do not send explicit nulls.
43
+ - Anything that can be checked without a request (a missing instance id, a
44
+ media call with no source) raises `ValueError` before the call goes out.
45
+
46
+ ## Tests
47
+
48
+ ```bash
49
+ pip install -e ".[dev]"
50
+ pytest
51
+ ```
52
+
53
+ No test may touch the network. Use `httpx.MockTransport`.
waapi-0.1.0/LICENSE ADDED
@@ -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.
waapi-0.1.0/PKG-INFO ADDED
@@ -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.
waapi-0.1.0/README.md ADDED
@@ -0,0 +1,131 @@
1
+ # WaAPI Python SDK
2
+
3
+ Official Python client for the [WaAPI](https://waapi.app) REST API — send and
4
+ receive WhatsApp messages, manage chats, groups and channels from Python.
5
+
6
+ [![PyPI](https://img.shields.io/pypi/v/waapi?style=for-the-badge)](https://pypi.org/project/waapi/)
7
+ [![Python](https://img.shields.io/pypi/pyversions/waapi?style=for-the-badge)](https://pypi.org/project/waapi/)
8
+ [![License](https://img.shields.io/badge/license-MIT-blue?style=for-the-badge)](LICENSE)
9
+
10
+ ```bash
11
+ pip install waapi
12
+ ```
13
+
14
+ ## Quick start
15
+
16
+ ```python
17
+ from waapi import WaAPI
18
+
19
+ client = WaAPI(token="YOUR_API_TOKEN", instance_id=123)
20
+
21
+ client.send_message(
22
+ chat_id="4915112345678@c.us",
23
+ message="Deployment finished.",
24
+ )
25
+ ```
26
+
27
+ Get a token at [waapi.app/user/api-tokens](https://waapi.app/user/api-tokens)
28
+ and create an instance connected to your number.
29
+
30
+ ### The chat ID is the one thing to get right
31
+
32
+ Its suffix decides where the message lands, and a wrong suffix is accepted and
33
+ delivers nothing:
34
+
35
+ | Target | Format |
36
+ |---|---|
37
+ | One person | `4915112345678@c.us` |
38
+ | Group | `123456789-123456789@g.us` |
39
+ | Channel | `123456789@newsletter` |
40
+
41
+ ## Async
42
+
43
+ Same method names, awaited:
44
+
45
+ ```python
46
+ from waapi import AsyncWaAPI
47
+
48
+ async with AsyncWaAPI(token="YOUR_API_TOKEN", instance_id=123) as client:
49
+ await client.send_message(chat_id="4915112345678@c.us", message="Hi")
50
+ ```
51
+
52
+ ## Errors
53
+
54
+ A successful HTTP exchange is not proof the message was sent. The API answers
55
+ `200` with `{"status": "error"}` when, for example, the instance is not
56
+ connected — so the SDK raises on that too, rather than handing back a body that
57
+ looks like success.
58
+
59
+ ```python
60
+ from waapi import WaAPI, FailedActionError, AuthenticationError, RateLimitError
61
+
62
+ try:
63
+ client.send_message(chat_id="4915112345678@c.us", message="Hi")
64
+ except AuthenticationError:
65
+ ... # token wrong, expired, or missing scopes
66
+ except RateLimitError as e:
67
+ time.sleep(e.retry_after or 5)
68
+ except FailedActionError as e:
69
+ ... # accepted but not carried out — e.response has the detail
70
+ ```
71
+
72
+ | Exception | Raised on |
73
+ |---|---|
74
+ | `AuthenticationError` | HTTP 401, 403 |
75
+ | `NotFoundError` | HTTP 404 |
76
+ | `ValidationError` | HTTP 422 — `.errors` holds the field errors |
77
+ | `RateLimitError` | HTTP 429 — `.retry_after` in seconds when the API sends it |
78
+ | `FailedActionError` | HTTP 400, **and HTTP 200 with `status: error`** |
79
+ | `ServerError` | HTTP 5xx |
80
+
81
+ All inherit from `WaAPIError`.
82
+
83
+ ## Coverage
84
+
85
+ All **122 client actions** are wrapped, typed, and available on both clients:
86
+
87
+ ```python
88
+ client.create_group(group_name="Ops", group_participants=["4915112345678@c.us"])
89
+ client.send_media(chat_id="4915112345678@c.us", media_url="https://example.com/report.pdf")
90
+ client.get_contacts()
91
+ ```
92
+
93
+ They are generated from the same OpenAPI specification the n8n node and the MCP
94
+ tools come from, so they track the API instead of drifting behind it — see
95
+ [CONTRIBUTING.md](CONTRIBUTING.md).
96
+
97
+ An action added to the API since the last release is still reachable by name:
98
+
99
+ ```python
100
+ client.action("some-new-action", {"chatId": "4915112345678@c.us"})
101
+ ```
102
+
103
+ ## Configuration
104
+
105
+ ```python
106
+ WaAPI(
107
+ token="...", # required
108
+ instance_id=123, # optional; per-call instance_id overrides it
109
+ base_url="https://waapi.app/api/v1",
110
+ timeout=30.0,
111
+ )
112
+ ```
113
+
114
+ Passing `instance_id` to the client keeps single-instance code short. Any call
115
+ can still override it, and a call with neither raises before a request is sent.
116
+
117
+ ## Development
118
+
119
+ ```bash
120
+ python -m venv .venv && source .venv/bin/activate
121
+ pip install -e ".[dev]"
122
+ pytest
123
+ ```
124
+
125
+ The suite runs entirely against `httpx.MockTransport` — no network, no token,
126
+ no connected account.
127
+
128
+ ## License
129
+
130
+ MIT. Not affiliated with, endorsed or sponsored by WhatsApp LLC or Meta.
131
+ WhatsApp is a trademark of WhatsApp LLC.
@@ -0,0 +1,54 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "waapi"
7
+ version = "0.1.0"
8
+ description = "Official Python SDK for the WaAPI REST API"
9
+ readme = "README.md"
10
+ license = { file = "LICENSE" }
11
+ requires-python = ">=3.9"
12
+ authors = [{ name = "WaAPI", email = "info@waapi.app" }]
13
+ keywords = ["waapi", "messaging", "api", "sdk", "automation", "chatbot"]
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "Intended Audience :: Developers",
17
+ "License :: OSI Approved :: MIT License",
18
+ "Programming Language :: Python :: 3",
19
+ "Programming Language :: Python :: 3.9",
20
+ "Programming Language :: Python :: 3.10",
21
+ "Programming Language :: Python :: 3.11",
22
+ "Programming Language :: Python :: 3.12",
23
+ "Programming Language :: Python :: 3.13",
24
+ "Topic :: Communications :: Chat",
25
+ "Topic :: Software Development :: Libraries :: Python Modules",
26
+ "Typing :: Typed",
27
+ ]
28
+ dependencies = ["httpx>=0.24,<1.0"]
29
+
30
+ [project.urls]
31
+ Homepage = "https://waapi.app"
32
+ Documentation = "https://waapi.app/docs"
33
+ Source = "https://github.com/WaAPIapp/waapi-python-sdk"
34
+ Issues = "https://github.com/WaAPIapp/waapi-python-sdk/issues"
35
+
36
+ [project.optional-dependencies]
37
+ dev = ["pytest>=7", "pytest-asyncio>=0.23", "respx>=0.20", "ruff>=0.4", "mypy>=1.8"]
38
+
39
+ [tool.hatch.build.targets.wheel]
40
+ packages = ["src/waapi"]
41
+
42
+ [tool.ruff]
43
+ line-length = 100
44
+ src = ["src", "tests"]
45
+
46
+ [tool.ruff.lint]
47
+ # PYI034 wants __enter__/__aenter__ to return typing.Self, which landed in
48
+ # 3.11. requires-python is 3.9, and pulling in typing_extensions as a runtime
49
+ # dependency for one annotation is a worse trade than the lint.
50
+ ignore = ["PYI034"]
51
+
52
+ [tool.pytest.ini_options]
53
+ testpaths = ["tests"]
54
+ asyncio_mode = "auto"
@@ -0,0 +1,156 @@
1
+ #!/usr/bin/env python3
2
+ """Regenerate src/waapi/_generated.py and tests/test_generated_actions.py.
3
+
4
+ The API has 122 client actions. Writing them by hand would put every future
5
+ API change in four places -- the n8n node, the MCP tools, the PHP SDKs and
6
+ here -- so they are generated from the same OpenAPI specification the others
7
+ use, by `sdk:generate-methods` in the proxy repository.
8
+
9
+ Everything generated lands in its own module, which this script overwrites
10
+ whole. Nothing hand-written lives there, so a regeneration can never lose
11
+ someone's edit; the hand-written core stays in _actions.py and composes the
12
+ generated classes in.
13
+
14
+ python3 scripts/sync_actions.py ../eazewhatsapp-proxy
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import argparse
20
+ import re
21
+ import subprocess
22
+ import sys
23
+ from pathlib import Path
24
+
25
+ ROOT = Path(__file__).resolve().parent.parent
26
+
27
+ HEADER = '''"""Client actions generated from the WaAPI OpenAPI specification.
28
+
29
+ DO NOT EDIT. Regenerate with:
30
+
31
+ python3 scripts/sync_actions.py ../eazewhatsapp-proxy
32
+
33
+ Hand-written methods belong in _actions.py, which composes these classes in.
34
+ """
35
+
36
+ from __future__ import annotations
37
+
38
+ from typing import TYPE_CHECKING, Any
39
+
40
+
41
+ class _Calls:
42
+ """The contract the mixins rely on, declared for the type checker only."""
43
+
44
+ if TYPE_CHECKING:
45
+
46
+ def action(
47
+ self,
48
+ name: str,
49
+ payload: dict[str, Any] | None = None,
50
+ *,
51
+ instance_id: int | str | None = None,
52
+ ) -> Any: ...
53
+ '''
54
+
55
+ TEST_HEADER = '''"""Generated payload tests -- one per client action.
56
+
57
+ DO NOT EDIT. Regenerate with:
58
+
59
+ python3 scripts/sync_actions.py ../eazewhatsapp-proxy
60
+
61
+ These methods hold no logic: they name an action and forward named arguments.
62
+ So their real failure modes are a wrong action string and a parameter that is
63
+ dropped or swapped with its neighbour, and both are visible in the request
64
+ that leaves the SDK. Sample values carry the parameter's own name for exactly
65
+ that reason -- identical values could not tell a swap from a correct call.
66
+ """
67
+
68
+ from __future__ import annotations
69
+ '''
70
+
71
+
72
+ def generate(proxy: Path, *args: str) -> str:
73
+ """Run the generator and return only the emitted code.
74
+
75
+ The command reports how many methods it wrote on stdout as well, which is
76
+ useful in a terminal and a syntax error in a Python file.
77
+ """
78
+ result = subprocess.run(
79
+ [sys.executable and "php", "artisan", "sdk:generate-methods", *args],
80
+ cwd=proxy,
81
+ capture_output=True,
82
+ text=True,
83
+ check=True,
84
+ )
85
+ body = re.sub(r"^\s*INFO\s+\d+ methods generated\.\s*$", "", result.stdout, flags=re.MULTILINE)
86
+ return body.rstrip() + "\n"
87
+
88
+
89
+ def count_methods(source: str) -> int:
90
+ return len(re.findall(r"^\s+(?:async )?def \w+\(", source, flags=re.MULTILINE))
91
+
92
+
93
+ def main() -> int:
94
+ parser = argparse.ArgumentParser(description=__doc__)
95
+ parser.add_argument("proxy", type=Path, help="path to the eazewhatsapp-proxy checkout")
96
+ args = parser.parse_args()
97
+
98
+ proxy = args.proxy.expanduser().resolve()
99
+ if not (proxy / "artisan").is_file():
100
+ raise SystemExit(f"not a Laravel checkout: {proxy}")
101
+
102
+ sync = generate(proxy, "--flavour=python")
103
+ asyncronous = generate(proxy, "--flavour=python-async")
104
+ tests = generate(proxy, "--flavour=python", "--tests")
105
+
106
+ n_sync, n_async = count_methods(sync), count_methods(asyncronous)
107
+ if n_sync != n_async:
108
+ raise SystemExit(f"sync/async surfaces differ: {n_sync} vs {n_async}")
109
+ if n_sync == 0:
110
+ raise SystemExit("the generator emitted nothing -- check the spec path")
111
+
112
+ module = (
113
+ HEADER
114
+ + "\n\nclass GeneratedActions(_Calls):\n"
115
+ + ' """Every client action, blocking."""\n'
116
+ + sync
117
+ + "\n\nclass GeneratedAsyncActions(_Calls):\n"
118
+ + ' """Every client action, awaited."""\n'
119
+ + asyncronous
120
+ )
121
+ (ROOT / "src" / "waapi" / "_generated.py").write_text(module)
122
+
123
+ (ROOT / "tests" / "test_generated_actions.py").write_text(TEST_HEADER + tests)
124
+
125
+ written = [
126
+ ROOT / "src" / "waapi" / "_generated.py",
127
+ ROOT / "tests" / "test_generated_actions.py",
128
+ ]
129
+ tidy(written)
130
+
131
+ print(f"wrote {n_sync} sync and {n_async} async methods, and {count_tests(tests)} tests")
132
+ return 0
133
+
134
+
135
+ def tidy(paths: list[Path]) -> None:
136
+ """Bring the generated files up to the project's lint rules.
137
+
138
+ Emitting blank lines to PEP 8's satisfaction from a PHP string builder is
139
+ possible and pointless: the formatter already knows the rules, and letting
140
+ it run means a lint failure can never be something a human has to fix by
141
+ hand in a file marked DO NOT EDIT.
142
+ """
143
+ for command in (["ruff", "check", "--fix", "--quiet"], ["ruff", "format", "--quiet"]):
144
+ try:
145
+ subprocess.run([*command, *map(str, paths)], cwd=ROOT, check=True)
146
+ except FileNotFoundError:
147
+ print("ruff not on PATH -- generated files left unformatted", file=sys.stderr)
148
+ return
149
+
150
+
151
+ def count_tests(source: str) -> int:
152
+ return len(re.findall(r"^def test_", source, flags=re.MULTILINE))
153
+
154
+
155
+ if __name__ == "__main__":
156
+ raise SystemExit(main())