Skip to content

Commit c6175b3

Browse files
authored
Merge pull request #225 from david-lev/dev
Improve bug report template and CLI documentation clarity
2 parents 65646c3 + 6667cd4 commit c6175b3

7 files changed

Lines changed: 173 additions & 47 deletions

File tree

.github/ISSUE_TEMPLATE/bug_report.yml

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ body:
2020
attributes:
2121
label: Steps to reproduce
2222
description: Provide step-by-step instructions to reproduce the bug
23-
placeholder: 1) ...\n2) ...\n3) ...
23+
placeholder: e.g. 1. Create a new pywa client 2. Call `wa.send_message(...)` 3. Observe the error
2424
validations:
2525
required: true
2626
- type: textarea
@@ -37,14 +37,11 @@ body:
3737
id: pywa_version
3838
attributes:
3939
label: pywa version
40-
description: Select the pywa version you are using
40+
description: Select the pywa version you are using (run `pywa --version` to check)
4141
options:
4242
- label: 4.x (current)
43-
value: 4.x
4443
- label: 3.x or older
45-
value: <4.0
4644
- label: other
47-
value: other
4845
- type: input
4946
id: python_version
5047
attributes:

CONTRIBUTING.md

Lines changed: 154 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,10 @@ Now you are ready to start contributing!
5353
uv run ruff check .
5454
uv run ruff format .
5555
```
56+
- The project uses [ty](https://github.com/astral-sh/ty) for static type checking. You can run it manually:
57+
```bash
58+
uv run ty check
59+
```
5660

5761
## Making Changes
5862

@@ -91,11 +95,13 @@ Now you are ready to start contributing!
9195
git commit -m "[listeners] add `.ask(...)` shortcut"
9296
```
9397

94-
4. Push your changes to your fork and submit a pull request targeting the `dev` branch:
98+
4. Push your changes to your fork and submit a pull request:
9599
```bash
96100
git push origin my-new-feature
97101
```
98102

103+
> **Important:** Pull requests must target the `dev` branch, not `master`.
104+
99105
## Communication
100106

101107
If you have questions, need help, or want to discuss changes, feel free to reach out via:
@@ -186,39 +192,145 @@ pywa_async/
186192

187193
### Project Components
188194

195+
Below is where to make changes for common kinds of contributions, and what each layer is and isn't responsible for.
196+
**Every module below has a sync (`pywa/`) and async (`pywa_async/`) counterpart — a change to one almost always
197+
requires the matching change to the other.**
198+
189199
#### API
190200

191-
The `api.py` file contains all the api calls to the WhatsApp Cloud API. It is responsible for sending requests to the
192-
WhatsApp Cloud API and returning their raw responses.
201+
`api.py` (`GraphAPI` sync / `GraphAPIAsync` async) is the thin, low-level HTTP layer over the WhatsApp Cloud API.
202+
203+
- Methods accept **only builtin types** (`str`, `int`, `bool`, `dict`, `pathlib.Path`, file-likes, etc.) — never
204+
`pywa.types` dataclasses or enums as arguments.
205+
- Argument names must match the **real Cloud API parameter names** (e.g. `phone_id`, `message_id`), not renamed for
206+
readability — this file is a direct mirror of the API surface.
207+
- Methods return the **raw, unparsed JSON response** (a `dict`). No parsing into `pywa.types` objects happens here.
208+
- Every method added or changed in `pywa/api.py` must be mirrored **exactly** in `pywa_async/api.py` (same
209+
signature, `async def`, `await self._request(...)`).
210+
211+
Example (`pywa/api.py`):
212+
213+
```python
214+
def mark_message_as_read(self, phone_id: str, message_id: str) -> dict[str, bool]:
215+
...
216+
return self._request(
217+
method="POST",
218+
endpoint=f"/{phone_id}/messages",
219+
json={
220+
"messaging_product": "whatsapp",
221+
"status": "read",
222+
"message_id": message_id,
223+
},
224+
)
225+
```
226+
227+
The async mirror in `pywa_async/api.py` is identical except `async def` + `await`.
193228

194229
#### Client
195230

196-
The `WhatsApp` class in the `client.py` file is a wrapper around the api calls. It is responsible for sending requests
197-
to the WhatsApp Cloud API and returning the parsed responses. It allows to send messages, upload media, manage profiles,
198-
flows, templates, and more.
231+
The `WhatsApp` class in `client.py` is the user-facing layer built on top of `api.py`.
232+
233+
- Methods accept nicer-to-use Python values (enums, dataclasses, `int | str` phone numbers, file paths/bytes, etc.)
234+
instead of raw API params.
235+
- Each method calls the matching `self.api.*` method and parses the raw `dict` it gets back into a `pywa.types`
236+
object (or a small result type like `SuccessResult`), rather than returning the raw dict.
237+
- Same mirroring rule as `api.py`: every method added or changed in `pywa/client.py` must be mirrored in
238+
`pywa_async/client.py` as `async def`.
239+
240+
Example (`pywa/client.py`), wrapping the `api.py` example above:
241+
242+
```python
243+
def mark_message_as_read(self, message_id: str, *, sender: str | int | None = None) -> SuccessResult:
244+
return SuccessResult.from_dict(
245+
self.api.mark_message_as_read(
246+
phone_id=helpers.resolve_arg(wa=self, value=sender, method_arg="sender", ...),
247+
message_id=message_id,
248+
)
249+
)
250+
```
199251

200252
#### Server
201253

202-
The `Server` class in the `server.py` file is responsible for handling, verifying and parsing the incoming updates from
203-
the webhook. It is also responsible for registering the webhook routes and the callback url.
254+
The `Server` mixin in `server.py` owns the incoming side of the pipeline: verifying the webhook signature, parsing
255+
the raw payload into a `RawUpdate`, and **deciding which `Handler` class should handle it**.
204256

205-
#### Handlers
257+
- If you add support for a new webhook field, message type, or interactive/system sub-type, the *routing decision*
258+
belongs here — in the `_handle_*_field` functions and the `_MESSAGE_TYPES` / `_INTERACTIVE_TYPES` /
259+
`_SYSTEM_TYPES` / `_CALL_EVENTS` lookup dicts — not in `handlers.py` or `types/`.
260+
- `server.py` is also responsible for registering the webhook routes (Flask/FastAPI/built-in server) and the
261+
callback URL.
206262

207-
The `handlers.py` file contains the handler decorators and their respective handler objects. The handlers are used to
208-
handle incoming updates from the webhook.
263+
Example — mapping a message type to the handler that should process it:
209264

210-
#### Listeners
265+
```python
266+
_MESSAGE_TYPES: dict[MessageType, type[handlers.Handler]] = {
267+
MessageType.BUTTON: handlers.CallbackButtonHandler,
268+
MessageType.EDIT: handlers.EditedMessageHandler,
269+
MessageType.REVOKE: handlers.DeletedMessageHandler,
270+
}
271+
```
272+
273+
#### Handlers
211274

212-
The `listeners.py` file contains the listener functions and the logic to wait and listen to specific updates.
275+
`handlers.py` contains one `Handler` subclass per update type, plus the `@wa.on_*` decorator machinery that
276+
registers callbacks against them. When you add a new update type, add a matching `Handler` subclass here (and its
277+
`wa.on_x` decorator / entry in `add_handlers`), then point `server.py`'s dispatch dict at it.
278+
279+
```python
280+
class MessageHandler(Handler[Message]):
281+
"""Handler for `Message` updates. Registered via `@wa.on_message`."""
282+
```
213283

214284
#### Filters
215285

216-
The `filters.py` file contains the filters to use in the handlers to filter incoming updates.
286+
`filters.py` holds composable `Filter` objects used to narrow which updates a handler receives. Each update type
287+
gets a base filter for "is this update of this type at all" (`filters.message`, `filters.callback_button`, ...),
288+
plus finer-grained filters for its different kinds (`filters.text`, `filters.image`, `filters.mimetypes(...)`, etc.).
289+
290+
```python
291+
message: Filter[types.Message] = new(
292+
lambda _, m: isinstance(m, types.Message), name="filters.message"
293+
)
294+
text: Filter[types.Message] = new(
295+
lambda _, m: m.type == MessageType.TEXT, name="filters.text"
296+
)
297+
```
217298

218299
#### Types
219300

220-
The `types` package contains the data classes representing the different types of updates, messages, templates, flows,
221-
business profiles, calling settings, etc.
301+
The `types` package contains the dataclasses for every update and API resource (`Message`, `CallbackButton`,
302+
`Template`, `FlowDetails`, business profiles, calling settings, etc.).
303+
304+
- `types/base_update.py` defines the shared base classes: `BaseUpdate` (every incoming update), `BaseUserUpdate`
305+
(updates that originate from an end user — adds reply/typing-indicator machinery), and `_ClientShortcuts` (mixed
306+
into `BaseUserUpdate` to expose convenience methods like `.reply_text(...)`, bound to the update's own `WhatsApp`
307+
client instance).
308+
- Most type files carry no sync/async-specific logic and don't need touching on the async side beyond a plain
309+
re-export. Files whose types expose client-shortcut methods (e.g. `.reply_text`, `.mark_as_read`) follow this
310+
pattern in `pywa_async/types/<file>.py`: star-import the sync module to re-export everything unchanged, import the
311+
specific class under a private alias, then subclass it together with the async base to override only the methods
312+
that need to become `async`:
313+
314+
```python
315+
from pywa.types.message import *
316+
from pywa.types.message import Message as _Message
317+
318+
class Message(BaseUserUpdateAsync, _Message):
319+
"""Async override: same fields as the sync `Message`; shortcut methods are async."""
320+
321+
async def reply_text(self, ...): ...
322+
```
323+
324+
So when adding a new field to a type, edit the `pywa/types/<file>.py` dataclass only (it's shared); when adding a
325+
new *client-shortcut method*, add the sync version to the sync class and the async version to the
326+
`pywa_async/types/<file>.py` override class.
327+
328+
#### Listeners
329+
330+
`listeners.py` implements inline "wait for the next matching update" mechanics (`msg.wait_for_reply(...)`,
331+
`msg.wait_for_click(...)`). Unlike `api.py`/`client.py`, the async version (`pywa_async/listeners.py`) is **not** a thin override —
332+
asyncio-based waiting requires different control flow, so it's independently implemented rather than subclassed.
333+
Keep both in sync by behavior, not by inheritance.
222334

223335
#### Utils
224336

@@ -233,26 +345,35 @@ Contains the custom exceptions used in the library.
233345
The `cli.py` and `__main__.py` files implement the command line interface (run using the `pywa` command) to run the dev
234346
server, send messages etc.
235347

236-
#### Async
237-
238-
The async version of pywa (`pywa_async`) preserves the same structure as the sync version (`pywa`). Most of the code in
239-
the async version is inherited from the sync version, while overriding every api-related method to be async. So when you
240-
make changes to the sync version, make sure to apply the same changes to the async version.
241-
242348
#### Docs
243349

244-
The documentation is written in reStructuredText and is located in the `docs/source/content` directory. The
245-
documentation is built using Sphinx and hosted on ReadTheDocs.
350+
The documentation is written in [reStructuredText](https://www.sphinx-doc.org/en/master/usage/restructuredtext/basics.html) and is located in the `docs/source/content` directory. The
351+
documentation is built using [Sphinx](https://www.sphinx-doc.org/en/master/index.html) and hosted on [ReadTheDocs](https://app.readthedocs.org/projects/pywa/).
246352

247353
#### Tests
248354

249-
The tests are located in the `tests` directory and are written using `pytest`.
355+
The tests live in `tests/` and are written using [pytest](https://docs.pytest.org/en/stable/). The split mirrors
356+
the modules above — put new tests next to the existing ones for the module you touched, not in a new file:
250357

251-
- Run all tests:
252-
```bash
253-
pytest
254-
```
255-
- When adding new features or fixing bugs, please write corresponding tests:
256-
- Add tests for client methods/options in `test_client.py` and `test_async.py`.
257-
- Add tests for new filters in `test_filters.py`.
258-
- Add tests for new types/updates in `test_types.py` or `test_updates.py`.
358+
```bash
359+
uv run pytest # full suite
360+
uv run pytest tests/test_client.py # one file
361+
uv run pytest tests/test_client.py -k test_name # one test
362+
```
363+
364+
- `test_api.py` / `test_api_async.py``api.py` request-building/params (sync and async are separate files here,
365+
since `GraphAPIAsync` methods must each be awaited).
366+
- `test_client.py` / `test_async.py``client.py`; add tests for every new/changed client method or option to
367+
**both** files (`test_async.py` covers `pywa_async`-specific and async-only behavior).
368+
- `test_server.py` — webhook verification, parsing, and handler-routing decisions in `server.py`.
369+
- `test_handlers.py` — handler classes and the `@wa.on_*` decorator machinery.
370+
- `test_listeners.py``.wait_for_reply(...)` / `.ask(...)` mechanics (sync and async).
371+
- `test_filters.py` — add a case here for every new filter in `filters.py`.
372+
- `test_types.py` / `test_updates.py` — new/changed dataclasses go in `test_types.py`; parsing of new update shapes
373+
(raw JSON → typed object) goes in `test_updates.py`.
374+
- `test_templates.py`, `test_flows.py`, `test_callback_data.py`, `test_errors.py`, `test_cli.py`,
375+
`test_helpers.py` — one file per matching module (`templates.py`/`types/templates.py`, `types/flows.py`,
376+
`types/callback.py`, `errors.py`, `cli.py`/`__main__.py`, `_helpers.py`).
377+
- `common.py` is a shared fixture, not a test file: it builds one sync `WhatsApp` and one async `WhatsApp` client
378+
from the same raw JSON fixtures in `tests/data/updates/`, so update-parsing/dispatch logic is exercised
379+
identically for both packages. Add new update fixtures there rather than hand-constructing typed objects.

docs/source/content/cli.rst

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ Both ``pywa dev`` and ``pywa run`` share the following options:
7878
* ``path``: Optional positional argument pointing to the Python file containing the ``WhatsApp`` instance.
7979
* ``--host <str>``: The host to bind the socket to. Default: ``127.0.0.1``.
8080
* ``--port <int>``: The port to bind the socket to. Default: ``8000``.
81-
* ``--app <str>``: Specify the variable name of the ``WhatsApp`` client instance within the script (e.g., if you set ``my_wa_client = WhatsApp(...)``, pass ``--app my_wa_client``). By default, Pywa auto-detects instances named ``wa``, ``bot``, ``client``, ``app``, or ``main``.
81+
* ``--app <str>``: Specify the variable name of the ``WhatsApp`` client instance within the script (e.g., if you set ``my_wa_client = WhatsApp(...)``, pass ``--app my_wa_client``). By default, Pywa auto-detects ``WhatsApp`` instances in the script. If multiple instances exist, you must specify which one to use with this option - otherwise, the first instance found will be used.
8282
* ``--entrypoint <str>``: Explicit entrypoint string (e.g., ``main:wa``). This overrides ``path`` and ``--app``.
8383
* ``--log-level <level>``: Set the logging level (choices: ``critical``, ``error``, ``warning``, ``info``, ``debug``, ``trace``).
8484
* ``--ssl-keyfile <path>``: Path to an SSL key file.
@@ -87,7 +87,7 @@ Both ``pywa dev`` and ``pywa run`` share the following options:
8787
Production-only Options (``pywa run``)
8888
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
8989

90-
* ``--workers <int>``: Number of worker processes to run.
90+
* ``--workers <int>``: Number of worker processes to run (this will disable the listeners feature! e.g. ``msg.wait_for_reply(...)``). Default: ``1``.
9191
* ``--proxy-headers`` / ``--no-proxy-headers``: Enable/Disable proxy headers (``X-Forwarded-Proto``, ``X-Forwarded-For``) to populate the request's URL scheme and client IP address.
9292
* ``--forwarded-allow-ips <str>``: Comma-separated list of IPs to trust with proxy headers. Use ``*`` to trust all IPs.
9393
* ``--timeout-keep-alive <int>``: Close keep-alive connections if no new data is received within this timeout (in seconds).

examples/05-loan-application-flow/main.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@
2929
if not callback_url:
3030
callback_url = start_ngrok_tunnel(auth_token=os.environ["NGROK_AUTH_TOKEN"])
3131

32-
with open(os.environ["BUSINESS_PRIVATE_KEY_PATH"]) as f:
32+
with open(os.environ["BUSINESS_PRIVATE_KEY_PATH"], encoding="utf-8") as f:
3333
business_private_key = f.read()
3434

3535
wa = WhatsApp(

examples/05-loan-application-flow/setup_flow.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,9 @@ async def main():
3131
# Upload your business public key once (required for encrypted flow data exchange).
3232
# See README.md for how to generate the private.pem / public.pem pair.
3333
key_path = pathlib.Path(os.environ["BUSINESS_PUBLIC_KEY_PATH"])
34-
await wa.set_business_public_key(await asyncio.to_thread(key_path.read_text))
34+
await wa.set_business_public_key(
35+
await asyncio.to_thread(key_path.read_text, encoding="utf-8")
36+
)
3537

3638
created = await wa.create_flow(
3739
name="Loan Application",

pyproject.toml

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -119,11 +119,15 @@ ignore = [
119119
"F401", # `pywa_async` re-exports `pywa` names as `from pywa.X import Y as Y` on purpose
120120
"E731", # we use lambdas in some places for brevity (for example in `StrEnum._normalize` overrides)
121121
]
122-
extend-select = ["T20", "E711", "E712", "E731", "E741"]
122+
extend-select = [
123+
"T20", # flake8-print: forbid stray `print()`/`pprint()` calls in library code (use `logging` instead)
124+
"E711", # forbid `== None` / `!= None`; use `is None` / `is not None` instead
125+
"E712", # forbid `== True` / `== False`; use `is`/`is not` or plain truthiness instead; exempted below for `tests/test_flows.py`, which asserts against literal booleans in Flow JSON conditions
126+
"E741", # forbid ambiguous single-character names (`l`, `O`, `I`)
127+
]
123128

124129
[tool.ruff.lint.per-file-ignores]
125-
"pywa/types/__init__.py" = ["I001"]
126-
"pywa_async/types/__init__.py" = ["I001"]
130+
"pywa*/types/__init__.py" = ["I001"]
127131
"pywa/cli.py" = ["T201"]
128132
"tests/test_flows.py" = ["E712"]
129133
"tests/smoke_test.py" = ["T201"]

pywa/cli.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -337,7 +337,7 @@ def generate_code(target: str | None, is_async: bool, out_path: pathlib.Path) ->
337337
f"❌ Error: File '{out_file}' already exists. Aborting to prevent overwrite. Use --out to specify a different output directory or remove the existing file."
338338
)
339339
return
340-
out_file.write_text(code)
340+
out_file.write_text(code, encoding="utf-8")
341341
print(f"✅ Created new Pywa project at {out_file.resolve()}")
342342

343343

@@ -419,7 +419,9 @@ def download_example(
419419
dest_file = dest_dir / path[len(prefix) :]
420420
dest_file.parent.mkdir(parents=True, exist_ok=True)
421421
if path.endswith(".py") and not is_async:
422-
dest_file.write_text(async_code_to_sync(file_response.text))
422+
dest_file.write_text(
423+
async_code_to_sync(file_response.text), encoding="utf-8"
424+
)
423425
else:
424426
dest_file.write_bytes(file_response.content)
425427

0 commit comments

Comments
 (0)