diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 9fa63a02..0a8d2b48 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -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 @@ -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: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c608d947..7b75adc4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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 @@ -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: @@ -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/.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/.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/.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 @@ -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. diff --git a/docs/source/content/cli.rst b/docs/source/content/cli.rst index a6200678..fd9ae7d5 100644 --- a/docs/source/content/cli.rst +++ b/docs/source/content/cli.rst @@ -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 ``: The host to bind the socket to. Default: ``127.0.0.1``. * ``--port ``: The port to bind the socket to. Default: ``8000``. -* ``--app ``: 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 ``: 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 ``: Explicit entrypoint string (e.g., ``main:wa``). This overrides ``path`` and ``--app``. * ``--log-level ``: Set the logging level (choices: ``critical``, ``error``, ``warning``, ``info``, ``debug``, ``trace``). * ``--ssl-keyfile ``: Path to an SSL key file. @@ -87,7 +87,7 @@ Both ``pywa dev`` and ``pywa run`` share the following options: Production-only Options (``pywa run``) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -* ``--workers ``: Number of worker processes to run. +* ``--workers ``: 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 ``: Comma-separated list of IPs to trust with proxy headers. Use ``*`` to trust all IPs. * ``--timeout-keep-alive ``: Close keep-alive connections if no new data is received within this timeout (in seconds). diff --git a/examples/05-loan-application-flow/main.py b/examples/05-loan-application-flow/main.py index 47752eb0..186414e4 100644 --- a/examples/05-loan-application-flow/main.py +++ b/examples/05-loan-application-flow/main.py @@ -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( diff --git a/examples/05-loan-application-flow/setup_flow.py b/examples/05-loan-application-flow/setup_flow.py index ede57418..8a2f4909 100644 --- a/examples/05-loan-application-flow/setup_flow.py +++ b/examples/05-loan-application-flow/setup_flow.py @@ -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", diff --git a/pyproject.toml b/pyproject.toml index 5cd84188..9e87c875 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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"] diff --git a/pywa/cli.py b/pywa/cli.py index b514d97b..b0fca2ca 100644 --- a/pywa/cli.py +++ b/pywa/cli.py @@ -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()}") @@ -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)