Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 2 additions & 5 deletions .github/ISSUE_TEMPLATE/bug_report.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ body:
attributes:
label: Steps to reproduce
description: Provide step-by-step instructions to reproduce the bug
placeholder: 1) ...\n2) ...\n3) ...
placeholder: e.g. 1. Create a new pywa client 2. Call `wa.send_message(...)` 3. Observe the error
validations:
required: true
- type: textarea
Expand All @@ -37,14 +37,11 @@ body:
id: pywa_version
attributes:
label: pywa version
description: Select the pywa version you are using
description: Select the pywa version you are using (run `pywa --version` to check)
options:
- label: 4.x (current)
value: 4.x
- label: 3.x or older
value: <4.0
- label: other
value: other
- type: input
id: python_version
attributes:
Expand Down
187 changes: 154 additions & 33 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,10 @@ Now you are ready to start contributing!
uv run ruff check .
uv run ruff format .
```
- The project uses [ty](https://github.com/astral-sh/ty) for static type checking. You can run it manually:
```bash
uv run ty check
```

## Making Changes

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

4. Push your changes to your fork and submit a pull request targeting the `dev` branch:
4. Push your changes to your fork and submit a pull request:
```bash
git push origin my-new-feature
```

> **Important:** Pull requests must target the `dev` branch, not `master`.

## Communication

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

### Project Components

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

#### API

The `api.py` file contains all the api calls to the WhatsApp Cloud API. It is responsible for sending requests to the
WhatsApp Cloud API and returning their raw responses.
`api.py` (`GraphAPI` sync / `GraphAPIAsync` async) is the thin, low-level HTTP layer over the WhatsApp Cloud API.

- Methods accept **only builtin types** (`str`, `int`, `bool`, `dict`, `pathlib.Path`, file-likes, etc.) — never
`pywa.types` dataclasses or enums as arguments.
- Argument names must match the **real Cloud API parameter names** (e.g. `phone_id`, `message_id`), not renamed for
readability — this file is a direct mirror of the API surface.
- Methods return the **raw, unparsed JSON response** (a `dict`). No parsing into `pywa.types` objects happens here.
- Every method added or changed in `pywa/api.py` must be mirrored **exactly** in `pywa_async/api.py` (same
signature, `async def`, `await self._request(...)`).

Example (`pywa/api.py`):

```python
def mark_message_as_read(self, phone_id: str, message_id: str) -> dict[str, bool]:
...
return self._request(
method="POST",
endpoint=f"/{phone_id}/messages",
json={
"messaging_product": "whatsapp",
"status": "read",
"message_id": message_id,
},
)
```

The async mirror in `pywa_async/api.py` is identical except `async def` + `await`.

#### Client

The `WhatsApp` class in the `client.py` file is a wrapper around the api calls. It is responsible for sending requests
to the WhatsApp Cloud API and returning the parsed responses. It allows to send messages, upload media, manage profiles,
flows, templates, and more.
The `WhatsApp` class in `client.py` is the user-facing layer built on top of `api.py`.

- Methods accept nicer-to-use Python values (enums, dataclasses, `int | str` phone numbers, file paths/bytes, etc.)
instead of raw API params.
- Each method calls the matching `self.api.*` method and parses the raw `dict` it gets back into a `pywa.types`
object (or a small result type like `SuccessResult`), rather than returning the raw dict.
- Same mirroring rule as `api.py`: every method added or changed in `pywa/client.py` must be mirrored in
`pywa_async/client.py` as `async def`.

Example (`pywa/client.py`), wrapping the `api.py` example above:

```python
def mark_message_as_read(self, message_id: str, *, sender: str | int | None = None) -> SuccessResult:
return SuccessResult.from_dict(
self.api.mark_message_as_read(
phone_id=helpers.resolve_arg(wa=self, value=sender, method_arg="sender", ...),
message_id=message_id,
)
)
```

#### Server

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

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

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

#### Listeners
```python
_MESSAGE_TYPES: dict[MessageType, type[handlers.Handler]] = {
MessageType.BUTTON: handlers.CallbackButtonHandler,
MessageType.EDIT: handlers.EditedMessageHandler,
MessageType.REVOKE: handlers.DeletedMessageHandler,
}
```

#### Handlers

The `listeners.py` file contains the listener functions and the logic to wait and listen to specific updates.
`handlers.py` contains one `Handler` subclass per update type, plus the `@wa.on_*` decorator machinery that
registers callbacks against them. When you add a new update type, add a matching `Handler` subclass here (and its
`wa.on_x` decorator / entry in `add_handlers`), then point `server.py`'s dispatch dict at it.

```python
class MessageHandler(Handler[Message]):
"""Handler for `Message` updates. Registered via `@wa.on_message`."""
```

#### Filters

The `filters.py` file contains the filters to use in the handlers to filter incoming updates.
`filters.py` holds composable `Filter` objects used to narrow which updates a handler receives. Each update type
gets a base filter for "is this update of this type at all" (`filters.message`, `filters.callback_button`, ...),
plus finer-grained filters for its different kinds (`filters.text`, `filters.image`, `filters.mimetypes(...)`, etc.).

```python
message: Filter[types.Message] = new(
lambda _, m: isinstance(m, types.Message), name="filters.message"
)
text: Filter[types.Message] = new(
lambda _, m: m.type == MessageType.TEXT, name="filters.text"
)
```

#### Types

The `types` package contains the data classes representing the different types of updates, messages, templates, flows,
business profiles, calling settings, etc.
The `types` package contains the dataclasses for every update and API resource (`Message`, `CallbackButton`,
`Template`, `FlowDetails`, business profiles, calling settings, etc.).

- `types/base_update.py` defines the shared base classes: `BaseUpdate` (every incoming update), `BaseUserUpdate`
(updates that originate from an end user — adds reply/typing-indicator machinery), and `_ClientShortcuts` (mixed
into `BaseUserUpdate` to expose convenience methods like `.reply_text(...)`, bound to the update's own `WhatsApp`
client instance).
- Most type files carry no sync/async-specific logic and don't need touching on the async side beyond a plain
re-export. Files whose types expose client-shortcut methods (e.g. `.reply_text`, `.mark_as_read`) follow this
pattern in `pywa_async/types/<file>.py`: star-import the sync module to re-export everything unchanged, import the
specific class under a private alias, then subclass it together with the async base to override only the methods
that need to become `async`:

```python
from pywa.types.message import *
from pywa.types.message import Message as _Message

class Message(BaseUserUpdateAsync, _Message):
"""Async override: same fields as the sync `Message`; shortcut methods are async."""

async def reply_text(self, ...): ...
```

So when adding a new field to a type, edit the `pywa/types/<file>.py` dataclass only (it's shared); when adding a
new *client-shortcut method*, add the sync version to the sync class and the async version to the
`pywa_async/types/<file>.py` override class.

#### Listeners

`listeners.py` implements inline "wait for the next matching update" mechanics (`msg.wait_for_reply(...)`,
`msg.wait_for_click(...)`). Unlike `api.py`/`client.py`, the async version (`pywa_async/listeners.py`) is **not** a thin override —
asyncio-based waiting requires different control flow, so it's independently implemented rather than subclassed.
Keep both in sync by behavior, not by inheritance.

#### Utils

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

#### Async

The async version of pywa (`pywa_async`) preserves the same structure as the sync version (`pywa`). Most of the code in
the async version is inherited from the sync version, while overriding every api-related method to be async. So when you
make changes to the sync version, make sure to apply the same changes to the async version.

#### Docs

The documentation is written in reStructuredText and is located in the `docs/source/content` directory. The
documentation is built using Sphinx and hosted on ReadTheDocs.
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
documentation is built using [Sphinx](https://www.sphinx-doc.org/en/master/index.html) and hosted on [ReadTheDocs](https://app.readthedocs.org/projects/pywa/).

#### Tests

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

- Run all tests:
```bash
pytest
```
- When adding new features or fixing bugs, please write corresponding tests:
- Add tests for client methods/options in `test_client.py` and `test_async.py`.
- Add tests for new filters in `test_filters.py`.
- Add tests for new types/updates in `test_types.py` or `test_updates.py`.
```bash
uv run pytest # full suite
uv run pytest tests/test_client.py # one file
uv run pytest tests/test_client.py -k test_name # one test
```

- `test_api.py` / `test_api_async.py` — `api.py` request-building/params (sync and async are separate files here,
since `GraphAPIAsync` methods must each be awaited).
- `test_client.py` / `test_async.py` — `client.py`; add tests for every new/changed client method or option to
**both** files (`test_async.py` covers `pywa_async`-specific and async-only behavior).
- `test_server.py` — webhook verification, parsing, and handler-routing decisions in `server.py`.
- `test_handlers.py` — handler classes and the `@wa.on_*` decorator machinery.
- `test_listeners.py` — `.wait_for_reply(...)` / `.ask(...)` mechanics (sync and async).
- `test_filters.py` — add a case here for every new filter in `filters.py`.
- `test_types.py` / `test_updates.py` — new/changed dataclasses go in `test_types.py`; parsing of new update shapes
(raw JSON → typed object) goes in `test_updates.py`.
- `test_templates.py`, `test_flows.py`, `test_callback_data.py`, `test_errors.py`, `test_cli.py`,
`test_helpers.py` — one file per matching module (`templates.py`/`types/templates.py`, `types/flows.py`,
`types/callback.py`, `errors.py`, `cli.py`/`__main__.py`, `_helpers.py`).
- `common.py` is a shared fixture, not a test file: it builds one sync `WhatsApp` and one async `WhatsApp` client
from the same raw JSON fixtures in `tests/data/updates/`, so update-parsing/dispatch logic is exercised
identically for both packages. Add new update fixtures there rather than hand-constructing typed objects.
4 changes: 2 additions & 2 deletions docs/source/content/cli.rst
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ Both ``pywa dev`` and ``pywa run`` share the following options:
* ``path``: Optional positional argument pointing to the Python file containing the ``WhatsApp`` instance.
* ``--host <str>``: The host to bind the socket to. Default: ``127.0.0.1``.
* ``--port <int>``: The port to bind the socket to. Default: ``8000``.
* ``--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``.
* ``--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.
* ``--entrypoint <str>``: Explicit entrypoint string (e.g., ``main:wa``). This overrides ``path`` and ``--app``.
* ``--log-level <level>``: Set the logging level (choices: ``critical``, ``error``, ``warning``, ``info``, ``debug``, ``trace``).
* ``--ssl-keyfile <path>``: Path to an SSL key file.
Expand All @@ -87,7 +87,7 @@ Both ``pywa dev`` and ``pywa run`` share the following options:
Production-only Options (``pywa run``)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

* ``--workers <int>``: Number of worker processes to run.
* ``--workers <int>``: Number of worker processes to run (this will disable the listeners feature! e.g. ``msg.wait_for_reply(...)``). Default: ``1``.
* ``--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.
* ``--forwarded-allow-ips <str>``: Comma-separated list of IPs to trust with proxy headers. Use ``*`` to trust all IPs.
* ``--timeout-keep-alive <int>``: Close keep-alive connections if no new data is received within this timeout (in seconds).
Expand Down
2 changes: 1 addition & 1 deletion examples/05-loan-application-flow/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
if not callback_url:
callback_url = start_ngrok_tunnel(auth_token=os.environ["NGROK_AUTH_TOKEN"])

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

wa = WhatsApp(
Expand Down
4 changes: 3 additions & 1 deletion examples/05-loan-application-flow/setup_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,9 @@ async def main():
# Upload your business public key once (required for encrypted flow data exchange).
# See README.md for how to generate the private.pem / public.pem pair.
key_path = pathlib.Path(os.environ["BUSINESS_PUBLIC_KEY_PATH"])
await wa.set_business_public_key(await asyncio.to_thread(key_path.read_text))
await wa.set_business_public_key(
await asyncio.to_thread(key_path.read_text, encoding="utf-8")
)

created = await wa.create_flow(
name="Loan Application",
Expand Down
10 changes: 7 additions & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -119,11 +119,15 @@ ignore = [
"F401", # `pywa_async` re-exports `pywa` names as `from pywa.X import Y as Y` on purpose
"E731", # we use lambdas in some places for brevity (for example in `StrEnum._normalize` overrides)
]
extend-select = ["T20", "E711", "E712", "E731", "E741"]
extend-select = [
"T20", # flake8-print: forbid stray `print()`/`pprint()` calls in library code (use `logging` instead)
"E711", # forbid `== None` / `!= None`; use `is None` / `is not None` instead
"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
"E741", # forbid ambiguous single-character names (`l`, `O`, `I`)
]

[tool.ruff.lint.per-file-ignores]
"pywa/types/__init__.py" = ["I001"]
"pywa_async/types/__init__.py" = ["I001"]
"pywa*/types/__init__.py" = ["I001"]
"pywa/cli.py" = ["T201"]
"tests/test_flows.py" = ["E712"]
"tests/smoke_test.py" = ["T201"]
Expand Down
6 changes: 4 additions & 2 deletions pywa/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -337,7 +337,7 @@ def generate_code(target: str | None, is_async: bool, out_path: pathlib.Path) ->
f"❌ Error: File '{out_file}' already exists. Aborting to prevent overwrite. Use --out to specify a different output directory or remove the existing file."
)
return
out_file.write_text(code)
out_file.write_text(code, encoding="utf-8")
print(f"✅ Created new Pywa project at {out_file.resolve()}")


Expand Down Expand Up @@ -419,7 +419,9 @@ def download_example(
dest_file = dest_dir / path[len(prefix) :]
dest_file.parent.mkdir(parents=True, exist_ok=True)
if path.endswith(".py") and not is_async:
dest_file.write_text(async_code_to_sync(file_response.text))
dest_file.write_text(
async_code_to_sync(file_response.text), encoding="utf-8"
)
else:
dest_file.write_bytes(file_response.content)

Expand Down
Loading