π€ Contributing#
Thank you for considering contributing to pywa! We appreciate your time and effort in helping improve this project. This guide will walk you through the steps and standards to follow for contributing.
Prerequisites#
Getting Started#
Fork the repository and clone your fork locally:
git clone https://github.com/<your-username>/pywa.git cd pywa
Sync the virtual environment and install the required dependencies:
uv sync # for docs changes: uv sync --group docs
Activate pre-commit to ensure code quality:
uv run pre-commit install
Run the tests to make sure everything is working:
uv run pytest
Now you are ready to start contributing!
Code Standards#
Follow the PEP 8 style guide.
Use Google Style Python Docstrings for docstrings.
Include type annotations for all function parameters and return types.
The project uses Ruff for linting and code formatting. You can run checks manually:
uv run ruff check . uv run ruff format .
The project uses ty for static type checking. You can run it manually:
uv run ty check
Making Changes#
Create a new branch for your changes
git checkout -b my-new-feature
Use descriptive names like
feature-add-listenersorbugfix-handler-issue.
Test your changes:
pytest
If youβre making doc changes, you can build the docs locally:
make -C docs html
And run a local server to view the changes:
python3 -m http.server 8000 -d docs/build/html
Then launch your browser and navigate to
http://localhost:8000.Commit your changes:
git add . git commit -m "[listeners] add `.ask(...)` shortcut"
Push your changes to your fork and submit a pull request:
git push origin my-new-feature
Important: Pull requests must target the
devbranch, notmaster.
Communication#
If you have questions, need help, or want to discuss changes, feel free to reach out via:
Our Telegram Group: pywa Chat
GitHub Issues for bug reports and feature requests.
GitHub Discussions for general questions, ideas, and showcase.
License#
By contributing to pywa, you agree that your contributions will be licensed under the MIT License. See the LICENSE file for details.
Project Structure#
This project provides both synchronous (pywa) and asynchronous (pywa_async) implementations. The asynchronous
implementation structure mirrors the synchronous implementation structure.
Synchronous Structure (pywa)#
pywa/
βββ __init__.py
βββ __main__.py
βββ _helpers.py
βββ api.py
βββ cli.py
βββ client.py
βββ errors.py
βββ filters.py
βββ handlers.py
βββ listeners.py
βββ server.py
βββ types/
β βββ __init__.py
β βββ base_update.py
β βββ account_update.py
β βββ callback.py
β βββ calls.py
β βββ chat.py
β βββ flows.py
β βββ groups.py
β βββ media.py
β βββ message.py
β βββ message_status.py
β βββ others.py
β βββ sent_update.py
β βββ system.py
β βββ templates.py
β βββ user.py
β βββ user_preferences.py
βββ utils.py
Asynchronous Structure (pywa_async)#
pywa_async/
βββ __init__.py
βββ _helpers.py
βββ api.py
βββ client.py
βββ errors.py
βββ filters.py
βββ handlers.py
βββ listeners.py
βββ server.py
βββ types/
β βββ __init__.py
β βββ base_update.py
β βββ account_update.py
β βββ callback.py
β βββ calls.py
β βββ chat.py
β βββ flows.py
β βββ groups.py
β βββ media.py
β βββ message.py
β βββ message_status.py
β βββ others.py
β βββ sent_update.py
β βββ system.py
β βββ templates.py
β βββ user.py
β βββ user_preferences.py
βββ utils.py
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#
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.) β neverpywa.typesdataclasses 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 intopywa.typesobjects happens here.Every method added or changed in
pywa/api.pymust be mirrored exactly inpywa_async/api.py(same signature,async def,await self._request(...)).
Example (pywa/api.py):
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 client.py is the user-facing layer built on top of api.py.
Methods accept nicer-to-use Python values (enums, dataclasses,
int | strphone numbers, file paths/bytes, etc.) instead of raw API params.Each method calls the matching
self.api.*method and parses the rawdictit gets back into apywa.typesobject (or a small result type likeSuccessResult), rather than returning the raw dict.Same mirroring rule as
api.py: every method added or changed inpywa/client.pymust be mirrored inpywa_async/client.pyasasync def.
Example (pywa/client.py), wrapping the api.py example above:
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 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.
If you add support for a new webhook field, message type, or interactive/system sub-type, the routing decision belongs here β in the
_handle_*_fieldfunctions and the_MESSAGE_TYPES/_INTERACTIVE_TYPES/_SYSTEM_TYPES/_CALL_EVENTSlookup dicts β not inhandlers.pyortypes/.server.pyis also responsible for registering the webhook routes (Flask/FastAPI/built-in server) and the callback URL.
Example β mapping a message type to the handler that should process it:
_MESSAGE_TYPES: dict[MessageType, type[handlers.Handler]] = {
MessageType.BUTTON: handlers.CallbackButtonHandler,
MessageType.EDIT: handlers.EditedMessageHandler,
MessageType.REVOKE: handlers.DeletedMessageHandler,
}
Handlers#
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.
class MessageHandler(Handler[Message]):
"""Handler for `Message` updates. Registered via `@wa.on_message`."""
Filters#
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.).
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 dataclasses for every update and API resource (Message, CallbackButton,
Template, FlowDetails, business profiles, calling settings, etc.).
types/base_update.pydefines the shared base classes:BaseUpdate(every incoming update),BaseUserUpdate(updates that originate from an end user β adds reply/typing-indicator machinery), and_ClientShortcuts(mixed intoBaseUserUpdateto expose convenience methods like.reply_text(...), bound to the updateβs ownWhatsAppclient 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 inpywa_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 becomeasync:
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#
Contains utility functions used across the library and by the users (unlike _helpers.py which is used internally).
Errors#
Contains the custom exceptions used in the library.
CLI#
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.
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.
Tests#
The tests live in tests/ and are written using pytest. The split mirrors
the modules above β put new tests next to the existing ones for the module you touched, not in a new file:
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.pyrequest-building/params (sync and async are separate files here, sinceGraphAPIAsyncmethods 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.pycoverspywa_async-specific and async-only behavior).test_server.pyβ webhook verification, parsing, and handler-routing decisions inserver.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 infilters.py.test_types.py/test_updates.pyβ new/changed dataclasses go intest_types.py; parsing of new update shapes (raw JSON β typed object) goes intest_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.pyis a shared fixture, not a test file: it builds one syncWhatsAppand one asyncWhatsAppclient from the same raw JSON fixtures intests/data/updates/, so update-parsing/dispatch logic is exercised identically for both packages. Add new update fixtures there rather than hand-constructing typed objects.