Skip to content

Commit 02f2316

Browse files
committed
Generate all 122 client actions from the OpenAPI spec
Adds python and python-async flavours to GenerateSdkMethods in the proxy and wires them up here, taking the method surface from 9 hand-written methods to 122 typed ones on each client. Generated code lands in its own module, overwritten whole. Nothing hand-written lives there, so a regeneration cannot lose an edit; _actions.py keeps the hand-written core (the instance REST routes and the action() call every generated method is built on) and composes the generated classes in. Python needs no equivalent of the PHP flavours' instanceId ordering workaround: required parameters stay positional, everything optional sits behind a bare star, and instance_id comes last. The star is emitted even for actions with no optional fields, so a trailing positional argument never means something different on one method than on another. The emitters build their output line by line rather than from a heredoc. PHP strips the closing marker's indentation uniformly, which is fine for a language that ignores whitespace and wrong for one where it is syntax -- the first version produced 122 methods with unusable indentation. Tests go from 25 to 147: one per action, asserting the action name and the exact payload, with sample values carrying each parameter's own name so a swap with an adjacent parameter cannot pass.
1 parent 4039f6e commit 02f2316

8 files changed

Lines changed: 5655 additions & 171 deletions

File tree

CONTRIBUTING.md

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,30 @@ tools are generated from `storage/swagger.json`, which is why both stay
77
current, while a hand-maintained SDK falls behind — the Laravel SDK named 25 of
88
122 actions before its methods were generated.
99

10-
`GenerateSdkMethods` in the proxy repository emits the PHP and Laravel
11-
flavours today. `src/waapi/_actions.py` is the target for its Python flavour.
12-
Until that lands, prefer widening `client.action(...)` usage in the README over
13-
adding hand-written wrappers: every hand-written method is one more place a
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
1431
future API change has to reach.
1532

16-
If you do add one by hand, mirror it in **both** `ActionsMixin` and
33+
If you do add one, mirror it in **both** `ActionsMixin` and
1734
`AsyncActionsMixin``test_sync_and_async_expose_the_same_methods` fails
1835
otherwise, on purpose.
1936

README.md

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -80,18 +80,25 @@ except FailedActionError as e:
8080

8181
All inherit from `WaAPIError`.
8282

83-
## Endpoints not wrapped yet
83+
## Coverage
8484

85-
The API exposes 122 client actions. This release wraps the common ones; every
86-
other action is reachable by name:
85+
All **122 client actions** are wrapped, typed, and available on both clients:
8786

8887
```python
89-
client.action("send-seen", {"chatId": "4915112345678@c.us"})
90-
client.action("create-group", {"title": "Ops", "participants": ["4915112345678@c.us"]})
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()
9191
```
9292

93-
The remaining wrappers are generated from the OpenAPI specification rather than
94-
written by hand — see [CONTRIBUTING.md](CONTRIBUTING.md).
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+
```
95102

96103
## Configuration
97104

scripts/sync_actions.py

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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())

0 commit comments

Comments
 (0)