diff --git a/CHANGES.md b/CHANGES.md index 80a01b8..2b44cba 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,4 +1,8 @@ # Changelog +## 3.6.0 +* Introduce send_each and send_each_for_multicast methods +* Add deprecation warnings to send_all and send_multicast methods, because they use the API that + Google may deprecate soon. The newly introduced methods should be safe to use. ## 3.5.0 * [BREAKING] Drop support of **Python 3.7** diff --git a/async_firebase/client.py b/async_firebase/client.py index 0171bca..2445c69 100644 --- a/async_firebase/client.py +++ b/async_firebase/client.py @@ -4,9 +4,12 @@ Documentation for google-auth package https://google-auth.readthedocs.io/en/latest/user-guide.html that is used to authorize request which is being made to Firebase. """ +import asyncio +import collections import json import logging import typing as t +import warnings from dataclasses import replace from datetime import datetime, timedelta from email.mime.multipart import MIMEMultipart @@ -412,6 +415,9 @@ async def send_multicast( :return: instance of ``messages.FCMBatchResponse`` """ + warnings.warn( + "send_multicast is going to be deprecated, please use send_each_for_multicast instead", DeprecationWarning + ) if len(multicast_message.tokens) > MULTICAST_MESSAGE_MAX_DEVICE_TOKENS: raise ValueError( @@ -447,6 +453,7 @@ async def send_all( without actually delivering the message. Default to ``False``. :returns: instance of ``messages.FCMBatchResponse`` """ + warnings.warn("send_all is going to be deprecated, please use send_each instead", DeprecationWarning) if len(messages) > BATCH_MAX_MESSAGES: raise ValueError(f"A list of messages must not contain more than {BATCH_MAX_MESSAGES} elements") @@ -489,3 +496,54 @@ async def send_all( if not isinstance(batch_response, FCMBatchResponse): raise ValueError("Wrong return type, perhaps because of a response handler misuse.") return batch_response + + async def send_each( + self, + messages: t.Union[t.List[Message], t.Tuple[Message]], + *, + dry_run: bool = False, + ) -> FCMBatchResponse: + if len(messages) > BATCH_MAX_MESSAGES: + raise ValueError(f"Can not send more than {BATCH_MAX_MESSAGES} messages in a single batch") + + push_notifications = [ + self.assemble_push_notification(apns_config=message.apns, dry_run=dry_run, message=message) + for message in messages + ] + + request_tasks: t.Collection[collections.abc.Awaitable] = [ + self.send_request( + uri=self.FCM_ENDPOINT.format(project_id=self._credentials.project_id), # type: ignore + json_payload=push_notification, + response_handler=FCMResponseHandler(), + ) + for push_notification in push_notifications + ] + fcm_responses = await asyncio.gather(*request_tasks) + return FCMBatchResponse(responses=fcm_responses) + + async def send_each_for_multicast( + self, + multicast_message: MulticastMessage, + *, + dry_run: bool = False, + ) -> FCMBatchResponse: + if len(multicast_message.tokens) > MULTICAST_MESSAGE_MAX_DEVICE_TOKENS: + raise ValueError( + f"A single ``messages.MulticastMessage`` may contain up to {MULTICAST_MESSAGE_MAX_DEVICE_TOKENS} " + "device tokens." + ) + + messages = [ + Message( + token=token, + data=multicast_message.data, + notification=multicast_message.notification, + android=multicast_message.android, + webpush=multicast_message.webpush, + apns=multicast_message.apns, + ) + for token in multicast_message.tokens + ] + + return await self.send_each(messages, dry_run=dry_run) diff --git a/poetry.lock b/poetry.lock index eeb1c3b..c5b0ee1 100644 --- a/poetry.lock +++ b/poetry.lock @@ -815,6 +815,7 @@ files = [ {file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"}, {file = "PyYAML-6.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28"}, {file = "PyYAML-6.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9"}, + {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a08c6f0fe150303c1c6b71ebcd7213c2858041a7e01975da3a99aed1e7a378ef"}, {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0"}, {file = "PyYAML-6.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4"}, {file = "PyYAML-6.0.1-cp312-cp312-win32.whl", hash = "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54"}, diff --git a/pyproject.toml b/pyproject.toml index fcdbf6b..617db4e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "async-firebase" -version = "3.5.0" +version = "3.6.0" description = "Async Firebase Client - a Python asyncio client to interact with Firebase Cloud Messaging in an easy way." license = "MIT" authors = [ diff --git a/tests/test_client.py b/tests/test_client.py index 1c4bce5..5ec61cd 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1,6 +1,7 @@ import json import uuid from datetime import datetime +from unittest import mock import pkg_resources import pytest @@ -8,6 +9,7 @@ from pytest_httpx import HTTPXMock from async_firebase.client import AsyncFirebaseClient +from async_firebase.errors import InternalError from async_firebase.messages import ( AndroidConfig, AndroidNotification, @@ -224,7 +226,7 @@ async def test_send_unauthenticated(fake_async_fcm_client_w_creds, httpx_mock: H assert fcm_response.exception.cause.response.status_code == 401 -async def test_send_data_has_not_provided(fake_async_fcm_client_w_creds): +async def test_send_data_has_not_been_provided(fake_async_fcm_client_w_creds): message = Message(token="device_id:device_token") with pytest.raises(ValueError): await fake_async_fcm_client_w_creds.send(message) @@ -333,6 +335,140 @@ async def test_send_all(fake_async_fcm_client_w_creds, fake_multi_device_tokens: assert response.responses[2].message_id == "projects/fake-mobile-app/messages/0:1612788010922733%7606eb247606eb26" +@pytest.mark.parametrize("fake_multi_device_tokens", (3,), indirect=True) +async def test_send_each_makes_proper_http_calls( + fake_async_fcm_client_w_creds, fake_multi_device_tokens: list, httpx_mock: HTTPXMock +): + fake_async_fcm_client_w_creds._get_access_token = fake__get_access_token + creds = fake_async_fcm_client_w_creds._credentials + fake_async_fcm_client_w_creds._get_access_token = fake__get_access_token + creds = fake_async_fcm_client_w_creds._credentials + response_message_ids = [ + "0:1612788010922733%7606eb247606eb24", + "0:1612788010922733%7606eb247606eb35", + "0:1612788010922733%7606eb247606eb46", + ] + for message_id in response_message_ids: + httpx_mock.add_response( + status_code=200, + json={"name": f"projects/{creds.project_id}/messages/{message_id}"}, + ) + apns_config: APNSConfig = fake_async_fcm_client_w_creds.build_apns_config( + priority="normal", + apns_topic="Your bucket has been updated", + collapse_key="BUCKET_UPDATED", + badge=1, + category="CATEGORY_BUCKET_UPDATED", + custom_data={"foo": "bar"}, + mutable_content=True, + content_available=True, + ) + messages = [ + Message(apns=apns_config, token=fake_device_token) for fake_device_token in fake_multi_device_tokens + ] + await fake_async_fcm_client_w_creds.send_each(messages) + request_payloads = [json.loads(request.read()) for request in httpx_mock.get_requests()] + expected_request_payloads = [ + { + "message": { + "apns": { + "headers": apns_config.headers, + "payload": { + "aps": { + "badge": 1, + "category": "CATEGORY_BUCKET_UPDATED", + "content-available": True, + "mutable-content": True, + }, + "foo": "bar", + }, + }, + "token": fake_device_token, + }, + "validate_only": False, + } for fake_device_token in fake_multi_device_tokens + ] + for payload, expected_payload in zip(request_payloads, expected_request_payloads): + assert payload == expected_payload + + +@pytest.mark.parametrize("fake_multi_device_tokens", (3,), indirect=True) +async def test_send_each_returns_correct_data( + fake_async_fcm_client_w_creds, fake_multi_device_tokens: list, httpx_mock: HTTPXMock +): + fake_async_fcm_client_w_creds._get_access_token = fake__get_access_token + creds = fake_async_fcm_client_w_creds._credentials + fake_async_fcm_client_w_creds._get_access_token = fake__get_access_token + creds = fake_async_fcm_client_w_creds._credentials + response_message_ids = [ + "0:1612788010922733%7606eb247606eb24", + "0:1612788010922733%7606eb247606eb35", + "0:1612788010922733%7606eb247606eb46", + ] + for message_id in (response_message_ids[0], response_message_ids[1]): + httpx_mock.add_response( + status_code=200, + json={"name": f"projects/{creds.project_id}/messages/{message_id}"}, + ) + httpx_mock.add_response(status_code=500) + apns_config: APNSConfig = fake_async_fcm_client_w_creds.build_apns_config( + priority="normal", + apns_topic="Your bucket has been updated", + collapse_key="BUCKET_UPDATED", + badge=1, + category="CATEGORY_BUCKET_UPDATED", + custom_data={"foo": "bar"}, + mutable_content=True, + content_available=True, + ) + messages = [ + Message(apns=apns_config, token=fake_device_token) for fake_device_token in fake_multi_device_tokens + ] + fcm_batch_response = await fake_async_fcm_client_w_creds.send_each(messages) + + assert fcm_batch_response.success_count == 2 + assert fcm_batch_response.failure_count == 1 + assert isinstance(fcm_batch_response, FCMBatchResponse) + for fcm_response in fcm_batch_response.responses: + assert isinstance(fcm_response, FCMResponse) + + # check successful responses + for fcm_response, response_message_id in list(zip(fcm_batch_response.responses, response_message_ids))[1:2]: + assert fcm_response.message_id == f"projects/{creds.project_id}/messages/{response_message_id}" + assert fcm_response.exception is None + + # check failed response + failed_fcm_response = fcm_batch_response.responses[2] + assert failed_fcm_response.message_id is None + assert isinstance(failed_fcm_response.exception, InternalError) + + +@pytest.mark.parametrize("fake_multi_device_tokens", (3,), indirect=True) +async def test_send_each_for_multicast( + fake_async_fcm_client_w_creds, fake_multi_device_tokens: list, +): + fake_async_fcm_client_w_creds._get_access_token = fake__get_access_token + send_each_mock = mock.AsyncMock() + fake_async_fcm_client_w_creds.send_each = send_each_mock + apns_config = fake_async_fcm_client_w_creds.build_apns_config( + priority="normal", + apns_topic="test-push", + collapse_key="push", + badge=0, + category="test-category", + custom_data={"foo": "bar"}, + ) + await fake_async_fcm_client_w_creds.send_each_for_multicast( + MulticastMessage(apns=apns_config, tokens=fake_multi_device_tokens), + ) + send_each_argument = send_each_mock.call_args[0][0] + assert isinstance(send_each_argument, list) + for message in send_each_argument: + assert isinstance(message, Message) + assert message.apns == apns_config + assert message.token is not None + + @pytest.mark.parametrize("fake_multi_device_tokens", (3,), indirect=True) async def test_send_all_dry_run( fake_async_fcm_client_w_creds, fake_multi_device_tokens: list, httpx_mock: HTTPXMock