diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml index 8c262bd..791f41a 100644 --- a/.github/workflows/python-app.yml +++ b/.github/workflows/python-app.yml @@ -5,9 +5,16 @@ name: Python application on: push: - branches: [ master ] + branches: + - '**' + create: + branches: + - '**' + tags: + - '**' pull_request: - branches: [ master ] + branches: + - master # Run on pull requests targeting the master branch jobs: build: @@ -31,6 +38,16 @@ jobs: flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics + - name: Check formatting + run: | + # stop the build if there are formatting is error in any python codes + # to check whether the codes are formatted or not before merge + pip install black==24.4.2 + pip install click==8.1.7 + python -m black -t py310 --check . - name: Test with pytest run: | - pytest + export GOOGLE_APPLICATION_CREDENTIALS=service-account.json + export FCM_TEST_PROJECT_ID=test + pip install . ".[test]" + python -m pytest . diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index f8f9dd1..c902e20 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -28,15 +28,15 @@ Some simple guidelines to follow when contributing code: Tests ----- -Before commiting your changes, please run the tests. For running the tests you need an FCM API key. +Before commiting your changes, please run the tests. For running the tests you need a service account. -**Please do not use an API key, which is used in production!** +**Please do not use a service account, which is used in production!** :: pip install . ".[test]" - export FCM_TEST_API_KEY=AAA... + export GOOGLE_APPLICATION_CREDENTIALS="service_account.json" python -m pytest diff --git a/README.md b/README.md new file mode 100644 index 0000000..35b2fda --- /dev/null +++ b/README.md @@ -0,0 +1,151 @@ +# PyFCM + +[![version](http://img.shields.io/pypi/v/pyfcm.svg?style=flat-square)](https://pypi.python.org/pypi/pyfcm/) +[![license](http://img.shields.io/pypi/l/pyfcm.svg?style=flat-square)](https://pypi.python.org/pypi/pyfcm/) + +Python client for FCM - Firebase Cloud Messaging (Android, iOS and Web) + +Firebase Cloud Messaging (FCM) is the new version of GCM. It inherits +the reliable and scalable GCM infrastructure, plus new features. GCM +users are strongly recommended to upgrade to FCM. + +Using FCM, you can notify a client app that new email or other data is +available to sync. You can send notifications to drive user reengagement +and retention. For use cases such as instant messaging, a message can +transfer a payload of up to 4KB to a client app. + +For more information, visit: + + +## Links + +- Project: +- PyPi: + +### Updates (Breaking Changes) + +- MIGRATION TO FCM HTTP V1 (JUNE 2024): + (big + shoutout to @Subhrans for the PR, for more information: + ) +- MAJOR UPDATES (AUGUST 2017): + + +Installation ========== + +Install using pip: + + pip install pyfcm + + OR + + pip install git+https://github.com/olucurious/PyFCM.git + +PyFCM supports Android, iOS and Web. + +## Features + +- All FCM functionality covered +- Tornado support + +## Examples + +### Send notifications using the `FCMNotification` class + +``` python +# Send to single device. +from pyfcm import FCMNotification + +push_service = FCMNotification(service_account_file="") + +# OR initialize with proxies + +proxy_dict = { + "http" : "http://127.0.0.1", + "https" : "http://127.0.0.1", + } +push_service = FCMNotification(service_account_file="", proxy_dict=proxy_dict) + +# Your service account file can be gotten from: https://console.firebase.google.com/u/0/project/_/settings/serviceaccounts/adminsdk + +fcm_token = "" +notification_title = "Uber update" +notification_body = "Hi John, your order is on the way!" +notification_image = "https://example.com/image.png" +result = push_service.notify(fcm_token=fcm_token, notification_title=notification_title, notification_body=notification_body, notification_image=notification_image) +print result +``` + +### Send a data message + +``` python +# With FCM, you can send two types of messages to clients: +# 1. Notification messages, sometimes thought of as "display messages." +# 2. Data messages, which are handled by the client app. +# 3. Notification messages with optional data payload. + +# Client app is responsible for processing data messages. Data messages have only custom key-value pairs. (Python dict) +# Data messages let developers send up to 4KB of custom key-value pairs. + +# Sending a notification with data message payload +data_payload = { + "foo": "bar", + "body": "great match!", + "room": "PortugalVSDenmark" +} +# To a single device +result = push_service.notify(fcm_token=fcm_token, notification_body=notification_body, data_payload=data_payload) + +# Sending a data message only payload, do NOT include notification_body also do NOT include notification body +# To a single device +result = push_service.notify(fcm_token=fcm_token, data_payload=data_payload) + +# Use notification messages when you want FCM to handle displaying a notification on your app's behalf. +# Use data messages when you just want to process the messages only in your app. +# PyFCM can send a message including both notification and data payloads. +# In such cases, FCM handles displaying the notification payload, and the client app handles the data payload. +``` + +### Appengine users should define their environment + +``` python +push_service = FCMNotification(api_key="", proxy_dict=proxy_dict, env='app_engine') +result = push_service.notify(fcm_token=fcm_token, notification_body=message) +``` + +### Sending a message to a topic + +``` python +# Send a message to devices subscribed to a topic. +result = push_service.notify(topic_name="news", notification_body=message) + +# Conditional topic messaging +topic_condition = "'TopicA' in topics && ('TopicB' in topics || 'TopicC' in topics)" +result = push_service.notify(notification_body=message, topic_condition=topic_condition) +# FCM first evaluates any conditions in parentheses, and then evaluates the expression from left to right. +# In the above expression, a user subscribed to any single topic does not receive the message. Likewise, +# a user who does not subscribe to TopicA does not receive the message. These combinations do receive it: +# TopicA and TopicB +# TopicA and TopicC +# Conditions for topics support two operators per expression, and parentheses are supported. +# For more information, check: https://firebase.google.com/docs/cloud-messaging/topic-messaging +``` + +### Other argument options + +: + + android_config (dict, optional): Android specific options for messages - + https://firebase.google.com/docs/reference/fcm/rest/v1/projects.messages#androidconfig + + apns_config (dict, optional): Apple Push Notification Service specific options - + https://firebase.google.com/docs/reference/fcm/rest/v1/projects.messages#apnsconfig + + webpush_config (dict, optional): Webpush protocol options - + https://firebase.google.com/docs/reference/fcm/rest/v1/projects.messages#webpushconfig + + fcm_options (dict, optional): Platform independent options for features provided by the FCM SDKs - + https://firebase.google.com/docs/reference/fcm/rest/v1/projects.messages#fcmoptions + + dry_run (bool, optional): If `True` no message will be sent but + request will be tested. diff --git a/README.rst b/README.rst deleted file mode 100644 index 8ff69bf..0000000 --- a/README.rst +++ /dev/null @@ -1,275 +0,0 @@ -***** -PyFCM -***** -|version| |license| - -Python client for FCM - Firebase Cloud Messaging (Android, iOS and Web) - -Firebase Cloud Messaging (FCM) is the new version of GCM. It inherits the reliable and scalable GCM infrastructure, plus new features. GCM users are strongly recommended to upgrade to FCM. - -Using FCM, you can notify a client app that new email or other data is available to sync. You can send notifications to drive user reengagement and retention. For use cases such as instant messaging, a message can transfer a payload of up to 4KB to a client app. - -For more information, visit: https://firebase.google.com/docs/cloud-messaging/ - - -Links -===== - -- Project: https://github.com/olucurious/pyfcm -- PyPi: https://pypi.python.org/pypi/pyfcm/ - -Looking for a Django version? ------------------------------ -Checkout fcm-django -- Link: https://github.com/xtrinch/fcm-django - -Updates (Breaking Changes) --------------------------- - -- MAJOR UPDATES (AUGUST 2017): https://github.com/olucurious/PyFCM/releases/tag/1.4.0 - - -Installation -========== - -Install using pip: - -:: - - pip install pyfcm - - OR - - pip install git+https://github.com/olucurious/PyFCM.git - -PyFCM supports Android, iOS and Web. - - -Features -======== - -- All FCM functionality covered -- Tornado support - - -Examples -======== -| - -Send notifications using the ``FCMNotification`` class -------------------------------------------------------- - -.. code-block:: python - - # Send to single device. - from pyfcm import FCMNotification - - push_service = FCMNotification(api_key="") - - # OR initialize with proxies - - proxy_dict = { - "http" : "http://127.0.0.1", - "https" : "http://127.0.0.1", - } - push_service = FCMNotification(api_key="", proxy_dict=proxy_dict) - - # Your api-key can be gotten from: https://console.firebase.google.com/project//settings/cloudmessaging - - registration_id = "" - message_title = "Uber update" - message_body = "Hi john, your customized news for today is ready" - result = push_service.notify_single_device(registration_id=registration_id, message_title=message_title, message_body=message_body) - - # Send to multiple devices by passing a list of ids. - registration_ids = ["", "", ...] - message_title = "Uber update" - message_body = "Hope you're having fun this weekend, don't forget to check today's news" - result = push_service.notify_multiple_devices(registration_ids=registration_ids, message_title=message_title, message_body=message_body) - - print result - -| - -Send a data message --------------------- - -.. code-block:: python - - # With FCM, you can send two types of messages to clients: - # 1. Notification messages, sometimes thought of as "display messages." - # 2. Data messages, which are handled by the client app. - # 3. Notification messages with optional data payload. - - # Client app is responsible for processing data messages. Data messages have only custom key-value pairs. (Python dict) - # Data messages let developers send up to 4KB of custom key-value pairs. - - # Sending a notification with data message payload - data_message = { - "Nick" : "Mario", - "body" : "great match!", - "Room" : "PortugalVSDenmark" - } - # To multiple devices - result = push_service.notify_multiple_devices(registration_ids=registration_ids, message_body=message_body, data_message=data_message) - # To a single device - result = push_service.notify_single_device(registration_id=registration_id, message_body=message_body, data_message=data_message) - - # Sending a data message only payload, do NOT include message_body also do NOT include notification body - # To multiple devices - result = push_service.multiple_devices_data_message(registration_ids=registration_ids, data_message=data_message) - # To a single device - result = push_service.single_device_data_message(registration_id=registration_id, data_message=data_message) - - # To send extra kwargs (notification keyword arguments not provided in any of the methods), - # pass it as a key value in a dictionary to the method being used - extra_notification_kwargs = { - 'android_channel_id': 2 - } - result = push_service.notify_single_device(registration_id=registration_id, data_message=data_message, extra_notification_kwargs=extra_notification_kwargs) - - # To process background notifications in iOS 10, set content_available - result = push_service.notify_single_device(registration_id=registration_id, data_message=data_message, content_available=True) - - # To support rich notifications on iOS 10, set - extra_kwargs = { - 'mutable_content': True - } - - # and then write a NotificationService Extension in your app - - # Use notification messages when you want FCM to handle displaying a notification on your app's behalf. - # Use data messages when you just want to process the messages only in your app. - # PyFCM can send a message including both notification and data payloads. - # In such cases, FCM handles displaying the notification payload, and the client app handles the data payload. - -| - -Send a low priority message ----------------------------- - -.. code-block:: python - - # The default is low_priority == False - result = push_service.notify_multiple_devices(registration_ids=registration_ids, message_body=message, low_priority=True) - -| - -Get valid registration ids (useful for cleaning up invalid registration ids in your database) ---------------------------------------------------------------------------------------------- - -.. code-block:: python - - registration_ids = ['reg id 1', 'reg id 2', 'reg id 3', 'reg id 4', ...] - valid_registration_ids = push_service.clean_registration_ids(registration_ids) - # Shoutout to @baali for this - -| - -Appengine users should define their environment ------------------------------------------------ - -.. code-block:: python - - push_service = FCMNotification(api_key="", proxy_dict=proxy_dict, env='app_engine') - result = push_service.notify_multiple_devices(registration_ids=registration_ids, message_body=message, low_priority=True) - -| - -Manage subscriptions to a topic -------------------------------- - -.. code-block:: python - - push_service = FCMNotification(SERVER_KEY) - tokens = [ - , - , - ] - - subscribed = push_service.subscribe_registration_ids_to_topic(tokens, 'test') - # returns True if successful, raises error if unsuccessful - - unsubscribed = push_service.unsubscribe_registration_ids_from_topic(tokens, 'test') - # returns True if successful, raises error if unsuccessful - -| - -Sending a message to a topic ------------------------------ -.. code-block:: python - - # Send a message to devices subscribed to a topic. - result = push_service.notify_topic_subscribers(topic_name="news", message_body=message) - - # Conditional topic messaging - topic_condition = "'TopicA' in topics && ('TopicB' in topics || 'TopicC' in topics)" - result = push_service.notify_topic_subscribers(message_body=message, condition=topic_condition) - # FCM first evaluates any conditions in parentheses, and then evaluates the expression from left to right. - # In the above expression, a user subscribed to any single topic does not receive the message. Likewise, - # a user who does not subscribe to TopicA does not receive the message. These combinations do receive it: - # TopicA and TopicB - # TopicA and TopicC - # Conditions for topics support two operators per expression, and parentheses are supported. - # For more information, check: https://firebase.google.com/docs/cloud-messaging/topic-messaging - -| - -Other argument options ----------------------- - -:: - - - collapse_key (str, optional): Identifier for a group of messages - that can be collapsed so that only the last message gets sent - when delivery can be resumed. Defaults to `None`. - - delay_while_idle (bool, optional): If `True` indicates that the - message should not be sent until the device becomes active. - - time_to_live (int, optional): How long (in seconds) the message - should be kept in FCM storage if the device is offline. The - maximum time to live supported is 4 weeks. Defaults to ``None`` - which uses the FCM default of 4 weeks. - - low_priority (boolean, optional): Whether to send notification with - the low priority flag. Defaults to `False`. - - restricted_package_name (str, optional): Package name of the - application where the registration IDs must match in order to - receive the message. Defaults to `None`. - - dry_run (bool, optional): If `True` no message will be sent but - request will be tested. - -| - -Get response data ------------------- - -.. code-block:: python - - # Response from PyFCM. - response_dict = { - 'multicast_ids': list, # List of Unique ID (number) identifying the multicast message. - 'success': int, #Number of messages that were processed without an error. - 'failure': int, #Number of messages that could not be processed. - 'canonical_ids': int, #Number of results that contain a canonical registration token. - 'results': list, #Array of dict objects representing the status of the messages processed. - 'topic_message_id': None | str - } - - # registration_id: Optional string specifying the canonical registration token for the client app that the message - # was processed and sent to. Sender should use this value as the registration token for future requests. Otherwise, - # the messages might be rejected. - # error: String specifying the error that occurred when processing the message for the recipient - - -| - -.. |version| image:: http://img.shields.io/pypi/v/pyfcm.svg?style=flat-square - :target: https://pypi.python.org/pypi/pyfcm/ - -.. |license| image:: http://img.shields.io/pypi/l/pyfcm.svg?style=flat-square - :target: https://pypi.python.org/pypi/pyfcm/ diff --git a/pyfcm/__init__.py b/pyfcm/__init__.py index d7b883b..95534b1 100644 --- a/pyfcm/__init__.py +++ b/pyfcm/__init__.py @@ -9,11 +9,17 @@ __version__, __author__, __email__, - __license__ + __license__, ) from .fcm import FCMNotification __all__ = [ - "FCMNotification", "__title__", "__summary__", - "__url__", "__version__", "__author__", "__email__", "__license__" + "FCMNotification", + "__title__", + "__summary__", + "__url__", + "__version__", + "__author__", + "__email__", + "__license__", ] diff --git a/pyfcm/__meta__.py b/pyfcm/__meta__.py index 6f8331c..5202ac8 100644 --- a/pyfcm/__meta__.py +++ b/pyfcm/__meta__.py @@ -1,10 +1,10 @@ -__title__ = 'pyfcm' -__summary__ = 'Python client for FCM - Firebase Cloud Messaging (Android, iOS and Web)' -__url__ = 'https://github.com/olucurious/pyfcm' +__title__ = "pyfcm" +__summary__ = "Python client for FCM - Firebase Cloud Messaging (Android, iOS and Web)" +__url__ = "https://github.com/olucurious/pyfcm" -__version__ = '1.5.2' +__version__ = "2.0.0" -__author__ = 'Emmanuel Adegbite' -__email__ = 'olucurious@gmail.com' +__author__ = "Emmanuel Adegbite" +__email__ = "olucurious@gmail.com" -__license__ = 'MIT License' +__license__ = "MIT License" diff --git a/pyfcm/async_fcm.py b/pyfcm/async_fcm.py index c6a09be..14c5429 100644 --- a/pyfcm/async_fcm.py +++ b/pyfcm/async_fcm.py @@ -2,7 +2,8 @@ import aiohttp import json -async def fetch_tasks(end_point,headers,payloads,timeout): + +async def fetch_tasks(end_point, headers, payloads, timeout): """ :param end_point (str) : FCM endpoint @@ -11,11 +12,18 @@ async def fetch_tasks(end_point,headers,payloads,timeout): :param timeout (int) : FCM timeout :return: """ - fetches = [asyncio.Task(send_request(end_point=end_point,headers=headers,payload=payload,timeout=timeout)) for payload in payloads] + fetches = [ + asyncio.Task( + send_request( + end_point=end_point, headers=headers, payload=payload, timeout=timeout + ) + ) + for payload in payloads + ] return await asyncio.gather(*fetches) -async def send_request(end_point,headers,payload,timeout=5): +async def send_request(end_point, headers, payload, timeout=5): """ :param end_point (str) : FCM endpoint @@ -26,9 +34,9 @@ async def send_request(end_point,headers,payload,timeout=5): """ timeout = aiohttp.ClientTimeout(total=timeout) - async with aiohttp.ClientSession(headers=headers,timeout=timeout) as session: + async with aiohttp.ClientSession(headers=headers, timeout=timeout) as session: - async with session.post(end_point,data=payload) as res: + async with session.post(end_point, data=payload) as res: result = await res.text() result = json.loads(result) return result diff --git a/pyfcm/baseapi.py b/pyfcm/baseapi.py index 3f081df..19bd804 100644 --- a/pyfcm/baseapi.py +++ b/pyfcm/baseapi.py @@ -7,62 +7,66 @@ from requests.adapters import HTTPAdapter from urllib3 import Retry +from google.oauth2 import service_account +import google.auth.transport.requests -from .errors import AuthenticationError, InvalidDataError, FCMError, FCMServerError, FCMNotRegisteredError +from pyfcm.errors import ( + AuthenticationError, + InvalidDataError, + FCMError, + FCMServerError, + FCMNotRegisteredError, +) +# Migration to v1 - https://firebase.google.com/docs/cloud-messaging/migrate-v1 -class BaseAPI(object): - """ - Base class for the pyfcm API wrapper for FCM - - Attributes: - api_key (str): Firebase API key - proxy_dict (dict): use proxy (keys: `http`, `https`) - env (str): for example "app_engine" - json_encoder - adapter: requests.adapters.HTTPAdapter() - """ - - CONTENT_TYPE = "application/json" - FCM_END_POINT = "https://fcm.googleapis.com/fcm/send" - INFO_END_POINT = 'https://iid.googleapis.com/iid/info/' - # FCM only allows up to 1000 reg ids per bulk message. - FCM_MAX_RECIPIENTS = 1000 - - #: Indicates that the push message should be sent with low priority. Low - #: priority optimizes the client app's battery consumption, and should be used - #: unless immediate delivery is required. For messages with low priority, the - #: app may receive the message with unspecified delay. - FCM_LOW_PRIORITY = 'normal' - - #: Indicates that the push message should be sent with a high priority. When a - #: message is sent with high priority, it is sent immediately, and the app can - #: wake a sleeping device and open a network connection to your server. - FCM_HIGH_PRIORITY = 'high' - - # Number of times to retry calls to info endpoint - INFO_RETRIES = 3 - - def __init__(self, api_key=None, proxy_dict=None, env=None, json_encoder=None, adapter=None): - if api_key: - self._FCM_API_KEY = api_key - elif os.getenv('FCM_API_KEY', None): - self._FCM_API_KEY = os.getenv('FCM_API_KEY', None) - else: - raise AuthenticationError("Please provide the api_key in the google-services.json file") +class BaseAPI(object): + FCM_END_POINT = "https://fcm.googleapis.com/v1/projects" + + def __init__( + self, + service_account_file: str, + project_id: str, + proxy_dict=None, + env=None, + json_encoder=None, + adapter=None, + ): + """ + Override existing init function to give ability to use v1 endpoints of Firebase Cloud Messaging API + Attributes: + service_account_file (str): path to service account JSON file + project_id (str): project ID of Google account + proxy_dict (dict): proxy settings dictionary, use proxy (keys: `http`, `https`) + env (dict): environment settings dictionary, for example "app_engine" + json_encoder (BaseJSONEncoder): JSON encoder + adapter (BaseAdapter): adapter instance + """ + self.service_account_file = service_account_file + self.project_id = project_id + self.FCM_END_POINT = self.FCM_END_POINT + f"/{self.project_id}/messages:send" self.FCM_REQ_PROXIES = None self.custom_adapter = adapter self.thread_local = threading.local() - if proxy_dict and isinstance(proxy_dict, dict) and (('http' in proxy_dict) or ('https' in proxy_dict)): + if not service_account_file: + raise AuthenticationError( + "Please provide a service account file path in the constructor" + ) + + if ( + proxy_dict + and isinstance(proxy_dict, dict) + and (("http" in proxy_dict) or ("https" in proxy_dict)) + ): self.FCM_REQ_PROXIES = proxy_dict self.requests_session.proxies.update(proxy_dict) - self.send_request_responses = [] - if env == 'app_engine': + if env == "app_engine": try: from requests_toolbelt.adapters import appengine + appengine.monkeypatch() except ModuleNotFoundError: pass @@ -82,42 +86,69 @@ def requests_session(self): self.thread_local.requests_session.mount("http://", adapter) self.thread_local.requests_session.mount("https://", adapter) self.thread_local.requests_session.headers.update(self.request_headers()) - self.thread_local.requests_session.mount( - self.INFO_END_POINT, HTTPAdapter(max_retries=self.INFO_RETRIES) - ) return self.thread_local.requests_session + def send_request(self, payload=None, timeout=None): + response = self.requests_session.post( + self.FCM_END_POINT, data=payload, timeout=timeout + ) + if ( + "Retry-After" in response.headers + and int(response.headers["Retry-After"]) > 0 + ): + sleep_time = int(response.headers["Retry-After"]) + time.sleep(sleep_time) + return self.send_request(payload, timeout) + return response + + def send_async_request(self, params_list, timeout): + + import asyncio + from .async_fcm import fetch_tasks + + payloads = [self.parse_payload(**params) for params in params_list] + responses = asyncio.new_event_loop().run_until_complete( + fetch_tasks( + end_point=self.FCM_END_POINT, + headers=self.request_headers(), + payloads=payloads, + timeout=timeout, + ) + ) + + return responses + + def _get_access_token(self): + """ + Generates access from refresh token that contains in the service_account_file. + If token expires then new access token is generated. + Returns: + str: Access token + """ + # get OAuth 2.0 access token + try: + credentials = service_account.Credentials.from_service_account_file( + self.service_account_file, + scopes=["https://www.googleapis.com/auth/firebase.messaging"], + ) + request = google.auth.transport.requests.Request() + credentials.refresh(request) + return credentials.token + except Exception as e: + raise InvalidDataError(e) + def request_headers(self): """ - Generates request headers including Content-Type and Authorization + Generates request headers including Content-Type and Authorization of Bearer token Returns: dict: request headers """ return { - "Content-Type": self.CONTENT_TYPE, - "Authorization": "key=" + self._FCM_API_KEY, + "Content-Type": "application/json", + "Authorization": "Bearer " + self._get_access_token(), } - def registration_id_chunks(self, registration_ids): - """ - Splits registration ids in several lists of max 1000 registration ids per list - - Args: - registration_ids (list): FCM device registration ID - - Yields: - generator: list including lists with registration ids - """ - try: - xrange - except NameError: - xrange = range - - # Yield successive 1000-sized (max fcm recipients per request) chunks from registration_ids - for i in xrange(0, len(registration_ids), self.FCM_MAX_RECIPIENTS): - yield registration_ids[i:i + self.FCM_MAX_RECIPIENTS] - def json_dumps(self, data): """ Standardized json.dumps function with separators and sorted keys set @@ -130,371 +161,119 @@ def json_dumps(self, data): """ return json.dumps( data, - separators=(',', ':'), + separators=(",", ":"), sort_keys=True, cls=self.json_encoder, - ensure_ascii=False - ).encode('utf8') - - def parse_payload(self, - registration_ids=None, - topic_name=None, - message_body=None, - message_title=None, - message_icon=None, - sound=None, - condition=None, - collapse_key=None, - delay_while_idle=False, - time_to_live=None, - restricted_package_name=None, - low_priority=False, - dry_run=False, - data_message=None, - click_action=None, - badge=None, - color=None, - tag=None, - body_loc_key=None, - body_loc_args=None, - title_loc_key=None, - title_loc_args=None, - content_available=None, - remove_notification=False, - android_channel_id=None, - extra_notification_kwargs={}, - **extra_kwargs): - """ - Parses parameters of FCMNotification's methods to FCM nested json - - Args: - registration_ids (list, optional): FCM device registration IDs - topic_name (str, optional): Name of the topic to deliver messages to - message_body (str, optional): Message string to display in the notification tray - message_title (str, optional): Message title to display in the notification tray - message_icon (str, optional): Icon that apperas next to the notification - sound (str, optional): The sound file name to play. Specify "Default" for device default sound. - condition (str, optiona): Topic condition to deliver messages to - collapse_key (str, optional): Identifier for a group of messages - that can be collapsed so that only the last message gets sent - when delivery can be resumed. Defaults to `None`. - delay_while_idle (bool, optional): deprecated - time_to_live (int, optional): How long (in seconds) the message - should be kept in FCM storage if the device is offline. The - maximum time to live supported is 4 weeks. Defaults to `None` - which uses the FCM default of 4 weeks. - restricted_package_name (str, optional): Name of package - low_priority (bool, optional): Whether to send notification with - the low priority flag. Defaults to `False`. - dry_run (bool, optional): If `True` no message will be sent but request will be tested. - data_message (dict, optional): Custom key-value pairs - click_action (str, optional): Action associated with a user click on the notification - badge (str, optional): Badge of notification - color (str, optional): Color of the icon - tag (str, optional): Group notification by tag - body_loc_key (str, optional): Indicates the key to the body string for localization - body_loc_args (list, optional): Indicates the string value to replace format - specifiers in body string for localization - title_loc_key (str, optional): Indicates the key to the title string for localization - title_loc_args (list, optional): Indicates the string value to replace format - specifiers in title string for localization - content_available (bool, optional): Inactive client app is awoken - remove_notification (bool, optional): Only send a data message - android_channel_id (str, optional): Starting in Android 8.0 (API level 26), - all notifications must be assigned to a channel. For each channel, you can set the - visual and auditory behavior that is applied to all notifications in that channel. - Then, users can change these settings and decide which notification channels from - your app should be intrusive or visible at all. - extra_notification_kwargs (dict, optional): More notification keyword arguments - **extra_kwargs (dict, optional): More keyword arguments - - Returns: - string: json - - Raises: - InvalidDataError: parameters do have the wrong type or format - """ - fcm_payload = dict() - if registration_ids: - if len(registration_ids) > 1: - fcm_payload['registration_ids'] = registration_ids - else: - fcm_payload['to'] = registration_ids[0] - if condition: - fcm_payload['condition'] = condition - else: - # In the `to` reference at: https://firebase.google.com/docs/cloud-messaging/http-server-ref#send-downstream - # We have `Do not set this field (to) when sending to multiple topics` - # Which is why it's in the `else` block since `condition` is used when multiple topics are being targeted - if topic_name: - fcm_payload['to'] = '/topics/%s' % topic_name - # Revert to legacy API compatible priority - if low_priority: - fcm_payload['priority'] = self.FCM_LOW_PRIORITY - else: - fcm_payload['priority'] = self.FCM_HIGH_PRIORITY - - if delay_while_idle: - fcm_payload['delay_while_idle'] = delay_while_idle - if collapse_key: - fcm_payload['collapse_key'] = collapse_key - if time_to_live is not None: - if isinstance(time_to_live, int): - fcm_payload['time_to_live'] = time_to_live - else: - raise InvalidDataError("Provided time_to_live is not an integer") - if restricted_package_name: - fcm_payload['restricted_package_name'] = restricted_package_name - if dry_run: - fcm_payload['dry_run'] = dry_run - - if data_message: - if isinstance(data_message, dict): - fcm_payload['data'] = data_message - else: - raise InvalidDataError("Provided data_message is in the wrong format") - - fcm_payload['notification'] = {} - if message_icon: - fcm_payload['notification']['icon'] = message_icon - # If body is present, use it - if message_body: - fcm_payload['notification']['body'] = message_body - # Else use body_loc_key and body_loc_args for body - else: - if body_loc_key: - fcm_payload['notification']['body_loc_key'] = body_loc_key - if body_loc_args: - if isinstance(body_loc_args, list): - fcm_payload['notification']['body_loc_args'] = body_loc_args - else: - raise InvalidDataError('body_loc_args should be an array') - # If title is present, use it - if message_title: - fcm_payload['notification']['title'] = message_title - # Else use title_loc_key and title_loc_args for title - else: - if title_loc_key: - fcm_payload['notification']['title_loc_key'] = title_loc_key - if title_loc_args: - if isinstance(title_loc_args, list): - fcm_payload['notification']['title_loc_args'] = title_loc_args - else: - raise InvalidDataError('title_loc_args should be an array') - - if android_channel_id: - fcm_payload['notification']['android_channel_id'] = android_channel_id - - # This is needed for iOS when we are sending only custom data messages - if content_available and isinstance(content_available, bool): - fcm_payload['content_available'] = content_available - - if click_action: - fcm_payload['notification']['click_action'] = click_action - if isinstance(badge, int) and badge >= 0: - fcm_payload['notification']['badge'] = badge - if color: - fcm_payload['notification']['color'] = color - if tag: - fcm_payload['notification']['tag'] = tag - # only add the 'sound' key if sound is not None - # otherwise a default sound will play -- even with empty string args. - if sound: - fcm_payload['notification']['sound'] = sound - - if extra_kwargs: - fcm_payload.update(extra_kwargs) - - if extra_notification_kwargs: - fcm_payload['notification'].update(extra_notification_kwargs) - - # Do this if you only want to send a data message. - if remove_notification: - del fcm_payload['notification'] - - return self.json_dumps(fcm_payload) - - def do_request(self, payload, timeout): - response = self.requests_session.post(self.FCM_END_POINT, data=payload, timeout=timeout) - if 'Retry-After' in response.headers and int(response.headers['Retry-After']) > 0: - sleep_time = int(response.headers['Retry-After']) - time.sleep(sleep_time) - return self.do_request(payload, timeout) - return response + ensure_ascii=False, + ).encode("utf8") - def send_request(self, payloads=None, timeout=None): - self.send_request_responses = [] - for payload in payloads: - response = self.do_request(payload, timeout) - self.send_request_responses.append(response) - - def registration_info_request(self, registration_id): + def parse_response(self, response): """ - Makes a request for registration info and returns the response object - - Args: - registration_id: id to be checked + Parses the json response sent back by the server and tries to get out the important return variables Returns: - response of registration info request - """ - return self.requests_session.get( - self.INFO_END_POINT + registration_id, - params={'details': 'true'} - ) - - def clean_registration_ids(self, registration_ids=[]): - """ - Checks registration ids and excludes inactive ids + dict: name (str) - uThe identifier of the message sent, in the format of projects/*/messages/{message_id} - Args: - registration_ids (list, optional): list of ids to be cleaned - - Returns: - list: cleaned registration ids - """ - valid_registration_ids = [] - for registration_id in registration_ids: - details = self.registration_info_request(registration_id) - if details.status_code == 200: - valid_registration_ids.append(registration_id) - return valid_registration_ids - - def get_registration_id_info(self, registration_id): + Raises: + FCMServerError: FCM is temporary not available + AuthenticationError: error authenticating the sender account + InvalidDataError: data passed to FCM was incorrecly structured """ - Returns details related to a registration id if it exists otherwise return None - - Args: - registration_id: id to be checked - Returns: - dict: info about registration id - None: if id doesn't exist - """ - response = self.registration_info_request(registration_id) if response.status_code == 200: - return response.json() - return None - - def subscribe_registration_ids_to_topic(self, registration_ids, topic_name): - """ - Subscribes a list of registration ids to a topic - - Args: - registration_ids (list): ids to be subscribed - topic_name (str): name of topic - - Returns: - True: if operation succeeded + if ( + "content-length" in response.headers + and int(response.headers["content-length"]) <= 0 + ): + raise FCMServerError( + "FCM server connection error, the response is empty" + ) + else: + return response.json() - Raises: - InvalidDataError: data sent to server was incorrectly formatted - FCMError: an error occured on the server - """ - url = 'https://iid.googleapis.com/iid/v1:batchAdd' - payload = { - 'to': '/topics/' + topic_name, - 'registration_tokens': registration_ids, - } - response = self.requests_session.post(url, json=payload) - if response.status_code == 200: - return True + elif response.status_code == 401: + raise ("There was an error authenticating the sender account") elif response.status_code == 400: - error = response.json() - raise InvalidDataError(error['error']) + raise InvalidDataError(response.text) + elif response.status_code == 404: + raise FCMNotRegisteredError("Token not registered") else: - raise FCMError() + raise FCMServerError( + f"FCM server error: Unexpected status code {response.status_code}. The server might be temporarily unavailable." + ) - def unsubscribe_registration_ids_from_topic(self, registration_ids, topic_name): + def parse_payload( + self, + fcm_token=None, + notification_title=None, + notification_body=None, + notification_image=None, + data_payload=None, + topic_name=None, + topic_condition=None, + android_config=None, + apns_config=None, + webpush_config=None, + fcm_options=None, + dry_run=False, + ): """ - Unsubscribes a list of registration ids from a topic - Args: - registration_ids (list): ids to be unsubscribed - topic_name (str): name of topic + :rtype: json + """ + fcm_payload = dict() - Returns: - True: if operation succeeded + if fcm_token: + fcm_payload["token"] = fcm_token - Raises: - InvalidDataError: data sent to server was incorrectly formatted - FCMError: an error occured on the server - """ - url = "https://iid.googleapis.com/iid/v1:batchRemove" - payload = { - 'to': '/topics/' + topic_name, - 'registration_tokens': registration_ids, - } - response = self.requests_session.post(url, json=payload) - if response.status_code == 200: - return True - elif response.status_code == 400: - error = response.json() - raise InvalidDataError(error['error']) - else: - raise FCMError() + if topic_name: + fcm_payload["topic"] = topic_name + if topic_condition: + fcm_payload["condition"] = topic_condition - def parse_responses(self): - """ - Parses the json response sent back by the server and tries to get out the important return variables + if data_payload: + if isinstance(data_payload, dict): + fcm_payload["data"] = data_payload + else: + raise InvalidDataError("Provided data_payload is in the wrong format") - Returns: - dict: multicast_ids (list), success (int), failure (int), canonical_ids (int), - results (list) and optional topic_message_id (str but None by default) + if android_config: + if isinstance(android_config, dict): + fcm_payload["android"] = android_config + else: + raise InvalidDataError("Provided android_config is in the wrong format") - Raises: - FCMServerError: FCM is temporary not available - AuthenticationError: error authenticating the sender account - InvalidDataError: data passed to FCM was incorrecly structured - """ - response_dict = { - 'multicast_ids': [], - 'success': 0, - 'failure': 0, - 'canonical_ids': 0, - 'results': [], - 'topic_message_id': None - } + if webpush_config: + if isinstance(webpush_config, dict): + fcm_payload["webpush"] = webpush_config + else: + raise InvalidDataError("Provided webpush_config is in the wrong format") - for response in self.send_request_responses: - if response.status_code == 200: - if 'content-length' in response.headers and int(response.headers['content-length']) <= 0: - raise FCMServerError("FCM server connection error, the response is empty") - else: - parsed_response = response.json() - - multicast_id = parsed_response.get('multicast_id', None) - success = parsed_response.get('success', 0) - failure = parsed_response.get('failure', 0) - canonical_ids = parsed_response.get('canonical_ids', 0) - results = parsed_response.get('results', []) - message_id = parsed_response.get('message_id', None) # for topic messages - if message_id: - success = 1 - if multicast_id: - response_dict['multicast_ids'].append(multicast_id) - response_dict['success'] += success - response_dict['failure'] += failure - response_dict['canonical_ids'] += canonical_ids - response_dict['results'].extend(results) - response_dict['topic_message_id'] = message_id - - elif response.status_code == 401: - raise AuthenticationError("There was an error authenticating the sender account") - elif response.status_code == 400: - raise InvalidDataError(response.text) - elif response.status_code == 404: - raise FCMNotRegisteredError("Token not registered") + if apns_config: + if isinstance(apns_config, dict): + fcm_payload["apns"] = apns_config else: - raise FCMServerError("FCM server is temporarily unavailable") - return response_dict + raise InvalidDataError("Provided apns_config is in the wrong format") - def send_async_request(self,params_list,timeout): + if fcm_options: + if isinstance(fcm_options, dict): + fcm_payload["fcm_options"] = fcm_options + else: + raise InvalidDataError("Provided fcm_options is in the wrong format") - import asyncio - from .async_fcm import fetch_tasks + fcm_payload["notification"] = ( + {} + ) # - https://firebase.google.com/docs/reference/fcm/rest/v1/projects.messages#notification + # If title is present, use it + if notification_title: + fcm_payload["notification"]["title"] = notification_title + if notification_body: + fcm_payload["notification"]["body"] = notification_body + if notification_image: + fcm_payload["notification"]["image"] = notification_image - payloads = [ self.parse_payload(**params) for params in params_list ] - responses = asyncio.new_event_loop().run_until_complete(fetch_tasks(end_point=self.FCM_END_POINT,headers=self.request_headers(),payloads=payloads,timeout=timeout)) + # Do this if you only want to send a data message. + if data_payload and (not notification_title and not notification_body): + del fcm_payload["notification"] - return responses + return self.json_dumps({"message": fcm_payload, "validate_only": dry_run}) diff --git a/pyfcm/errors.py b/pyfcm/errors.py index 64cb668..a186fa3 100644 --- a/pyfcm/errors.py +++ b/pyfcm/errors.py @@ -2,12 +2,15 @@ class FCMError(Exception): """ PyFCM Error """ + pass + class AuthenticationError(FCMError): """ API key not found or there was an error authenticating the sender """ + pass @@ -16,6 +19,7 @@ class FCMNotRegisteredError(FCMError): push token is not registered https://firebase.google.com/docs/reference/fcm/rest/v1/ErrorCode """ + pass @@ -23,6 +27,7 @@ class FCMServerError(FCMError): """ Internal server error or timeout error on Firebase cloud messaging server """ + pass @@ -30,6 +35,7 @@ class InvalidDataError(FCMError): """ Invalid input """ + pass @@ -37,6 +43,7 @@ class InternalPackageError(FCMError): """ JSON parsing error, please create a new github issue describing what you're doing """ + pass @@ -44,5 +51,6 @@ class RetryAfterException(Exception): """ Retry-After must be handled by external logic. """ + def __init__(self, delay): self.delay = delay diff --git a/pyfcm/fcm.py b/pyfcm/fcm.py index cfff4e5..b4560c0 100644 --- a/pyfcm/fcm.py +++ b/pyfcm/fcm.py @@ -3,574 +3,79 @@ class FCMNotification(BaseAPI): - def notify_single_device(self, - registration_id, - message_body=None, - message_title=None, - message_icon=None, - sound=None, - condition=None, - collapse_key=None, - delay_while_idle=False, - time_to_live=None, - restricted_package_name=None, - low_priority=False, - dry_run=False, - data_message=None, - click_action=None, - badge=None, - color=None, - tag=None, - body_loc_key=None, - body_loc_args=None, - title_loc_key=None, - title_loc_args=None, - content_available=None, - android_channel_id=None, - timeout=120, - extra_notification_kwargs=None, - extra_kwargs={}): + def notify( + self, + fcm_token, + notification_title=None, + notification_body=None, + notification_image=None, + data_payload=None, + topic_name=None, + topic_condition=None, + android_config=None, + webpush_config=None, + apns_config=None, + fcm_options=None, + dry_run=False, + timeout=120, + ): """ Send push notification to a single device Args: - registration_id (list, optional): FCM device registration ID - message_body (str, optional): Message string to display in the notification tray - message_title (str, optional): Message title to display in the notification tray - message_icon (str, optional): Icon that apperas next to the notification - sound (str, optional): The sound file name to play. Specify "Default" for device default sound. - condition (str, optiona): Topic condition to deliver messages to - collapse_key (str, optional): Identifier for a group of messages - that can be collapsed so that only the last message gets sent - when delivery can be resumed. Defaults to `None`. - delay_while_idle (bool, optional): deprecated - time_to_live (int, optional): How long (in seconds) the message - should be kept in FCM storage if the device is offline. The - maximum time to live supported is 4 weeks. Defaults to `None` - which uses the FCM default of 4 weeks. - restricted_package_name (str, optional): Name of package - low_priority (bool, optional): Whether to send notification with - the low priority flag. Defaults to `False`. - dry_run (bool, optional): If `True` no message will be sent but request will be tested. - data_message (dict, optional): Custom key-value pairs - click_action (str, optional): Action associated with a user click on the notification - badge (str, optional): Badge of notification - color (str, optional): Color of the icon - tag (str, optional): Group notification by tag - body_loc_key (str, optional): Indicates the key to the body string for localization - body_loc_args (list, optional): Indicates the string value to replace format - specifiers in body string for localization - title_loc_key (str, optional): Indicates the key to the title string for localization - title_loc_args (list, optional): Indicates the string value to replace format - specifiers in title string for localization - content_available (bool, optional): Inactive client app is awoken - android_channel_id (str, optional): Starting in Android 8.0 (API level 26), - all notifications must be assigned to a channel. For each channel, you can set the - visual and auditory behavior that is applied to all notifications in that channel. - Then, users can change these settings and decide which notification channels from - your app should be intrusive or visible at all. - timeout (int, optional): set time limit for the request - extra_notification_kwargs (dict, optional): More notification keyword arguments - extra_kwargs (dict, optional): More keyword arguments - - Returns: - dict: Response from FCM server (`multicast_id`, `success`, `failure`, `canonical_ids`, `results`) - - Raises: - AuthenticationError: If :attr:`api_key` is not set or provided - or there is an error authenticating the sender. - FCMServerError: Internal server error or timeout error on Firebase cloud messaging server - InvalidDataError: Invalid data provided - InternalPackageError: Mostly from changes in the response of FCM, - contact the project owner to resolve the issue - """ + fcm_token (str): FCM device registration ID + notification_title (str, optional): Message title to display in the notification tray + notification_body (str, optional): Message string to display in the notification tray + notification_image (str, optional): Icon that appears next to the notification - # [registration_id] cos we're sending to a single device - payload = self.parse_payload( - registration_ids=[registration_id], - message_body=message_body, - message_title=message_title, - message_icon=message_icon, - sound=sound, - condition=condition, - collapse_key=collapse_key, - delay_while_idle=delay_while_idle, - time_to_live=time_to_live, - restricted_package_name=restricted_package_name, - low_priority=low_priority, - dry_run=dry_run, data_message=data_message, click_action=click_action, - badge=badge, - color=color, - tag=tag, - body_loc_key=body_loc_key, - body_loc_args=body_loc_args, - title_loc_key=title_loc_key, - title_loc_args=title_loc_args, - android_channel_id=android_channel_id, - content_available=content_available, - extra_notification_kwargs=extra_notification_kwargs, - **extra_kwargs - ) + data_payload (dict, optional): Arbitrary key/value payload, which must be UTF-8 encoded - self.send_request([payload], timeout) - return self.parse_responses() + topic_name (str, optional): Name of the topic to deliver messages to e.g. "weather". + topic_condition (str, optional): Condition to broadcast a message to, e.g. "'foo' in topics && 'bar' in topics". - def single_device_data_message(self, - registration_id=None, - condition=None, - collapse_key=None, - delay_while_idle=False, - time_to_live=None, - restricted_package_name=None, - low_priority=False, - dry_run=False, - data_message=None, - content_available=None, - android_channel_id=None, - timeout=120, - extra_notification_kwargs=None, - extra_kwargs={}): - """ - Send push message to a single device + android_config (dict, optional): Android specific options for messages - https://firebase.google.com/docs/reference/fcm/rest/v1/projects.messages#androidconfig + apns_config (dict, optional): Apple Push Notification Service specific options - https://firebase.google.com/docs/reference/fcm/rest/v1/projects.messages#apnsconfig + webpush_config (dict, optional): Webpush protocol options - https://firebase.google.com/docs/reference/fcm/rest/v1/projects.messages#webpushconfig + fcm_options (dict, optional): Platform independent options for features provided by the FCM SDKs - https://firebase.google.com/docs/reference/fcm/rest/v1/projects.messages#fcmoptions - Args: - registration_id (list, optional): FCM device registration ID - condition (str, optiona): Topic condition to deliver messages to - collapse_key (str, optional): Identifier for a group of messages - that can be collapsed so that only the last message gets sent - when delivery can be resumed. Defaults to `None`. - delay_while_idle (bool, optional): deprecated - time_to_live (int, optional): How long (in seconds) the message - should be kept in FCM storage if the device is offline. The - maximum time to live supported is 4 weeks. Defaults to `None` - which uses the FCM default of 4 weeks. - restricted_package_name (str, optional): Name of package - low_priority (bool, optional): Whether to send notification with - the low priority flag. Defaults to `False`. - dry_run (bool, optional): If `True` no message will be sent but request will be tested. - data_message (dict, optional): Custom key-value pairs - content_available (bool, optional): Inactive client app is awoken - android_channel_id (str, optional): Starting in Android 8.0 (API level 26), - all notifications must be assigned to a channel. For each channel, you can set the - visual and auditory behavior that is applied to all notifications in that channel. - Then, users can change these settings and decide which notification channels from - your app should be intrusive or visible at all. - timeout (int, optional): set time limit for the request - extra_notification_kwargs (dict, optional): More notification keyword arguments - extra_kwargs (dict, optional): More keyword arguments + timeout (int, optional): Set time limit for the request Returns: dict: Response from FCM server (`multicast_id`, `success`, `failure`, `canonical_ids`, `results`) Raises: - AuthenticationError: If :attr:`api_key` is not set or provided - or there is an error authenticating the sender. + AuthenticationError: If api_key is not set or provided or there is an error authenticating the sender. FCMServerError: Internal server error or timeout error on Firebase cloud messaging server InvalidDataError: Invalid data provided - InternalPackageError: Mostly from changes in the response of FCM, - contact the project owner to resolve the issue + InternalPackageError: Mostly from changes in the response of FCM, contact the project owner to resolve the issue """ - if registration_id is None: - raise InvalidDataError('Invalid registration ID') - # [registration_id] cos we're sending to a single device payload = self.parse_payload( - registration_ids=[registration_id], - condition=condition, - collapse_key=collapse_key, - delay_while_idle=delay_while_idle, - time_to_live=time_to_live, - restricted_package_name=restricted_package_name, - low_priority=low_priority, + fcm_token=fcm_token, + notification_title=notification_title, + notification_body=notification_body, + notification_image=notification_image, + data_payload=data_payload, + topic_name=topic_name, + topic_condition=topic_condition, + android_config=android_config, + apns_config=apns_config, + webpush_config=webpush_config, + fcm_options=fcm_options, dry_run=dry_run, - data_message=data_message, - content_available=content_available, - remove_notification=True, - android_channel_id=android_channel_id, - extra_notification_kwargs=extra_notification_kwargs, - **extra_kwargs ) + response = self.send_request(payload, timeout) + return self.parse_response(response) - self.send_request([payload], timeout) - return self.parse_responses() - - def notify_multiple_devices(self, - registration_ids, - message_body=None, - message_title=None, - message_icon=None, - sound=None, - condition=None, - collapse_key=None, - delay_while_idle=False, - time_to_live=None, - restricted_package_name=None, - low_priority=False, - dry_run=False, - data_message=None, - click_action=None, - badge=None, - color=None, - tag=None, - body_loc_key=None, - body_loc_args=None, - title_loc_key=None, - title_loc_args=None, - content_available=None, - android_channel_id=None, - timeout=120, - extra_notification_kwargs=None, - extra_kwargs={}): + def async_notify_multiple_devices(self, params_list=None, timeout=5): """ - Sends push notification to multiple devices, can send to over 1000 devices + Sends push notification to multiple devices with personalized templates Args: - registration_ids (list, optional): FCM device registration IDs - message_body (str, optional): Message string to display in the notification tray - message_title (str, optional): Message title to display in the notification tray - message_icon (str, optional): Icon that apperas next to the notification - sound (str, optional): The sound file name to play. Specify "Default" for device default sound. - condition (str, optiona): Topic condition to deliver messages to - collapse_key (str, optional): Identifier for a group of messages - that can be collapsed so that only the last message gets sent - when delivery can be resumed. Defaults to `None`. - delay_while_idle (bool, optional): deprecated - time_to_live (int, optional): How long (in seconds) the message - should be kept in FCM storage if the device is offline. The - maximum time to live supported is 4 weeks. Defaults to `None` - which uses the FCM default of 4 weeks. - restricted_package_name (str, optional): Name of package - low_priority (bool, optional): Whether to send notification with - the low priority flag. Defaults to `False`. - dry_run (bool, optional): If `True` no message will be sent but request will be tested. - data_message (dict, optional): Custom key-value pairs - click_action (str, optional): Action associated with a user click on the notification - badge (str, optional): Badge of notification - color (str, optional): Color of the icon - tag (str, optional): Group notification by tag - body_loc_key (str, optional): Indicates the key to the body string for localization - body_loc_args (list, optional): Indicates the string value to replace format - specifiers in body string for localization - title_loc_key (str, optional): Indicates the key to the title string for localization - title_loc_args (list, optional): Indicates the string value to replace format - specifiers in title string for localization - content_available (bool, optional): Inactive client app is awoken - android_channel_id (str, optional): Starting in Android 8.0 (API level 26), - all notifications must be assigned to a channel. For each channel, you can set the - visual and auditory behavior that is applied to all notifications in that channel. - Then, users can change these settings and decide which notification channels from - your app should be intrusive or visible at all. + params_list (list): list of parameters (the same as notify_multiple_devices) timeout (int, optional): set time limit for the request - extra_notification_kwargs (dict, optional): More notification keyword arguments - extra_kwargs (dict, optional): More keyword arguments - - Returns: - dict: Response from FCM server (`multicast_id`, `success`, `failure`, `canonical_ids`, `results`) - - Raises: - AuthenticationError: If :attr:`api_key` is not set or provided - or there is an error authenticating the sender. - FCMServerError: Internal server error or timeout error on Firebase cloud messaging server - InvalidDataError: Invalid data provided - InternalPackageError: JSON parsing error, mostly from changes in the response of FCM, - create a new github issue to resolve it. - """ - if not isinstance(registration_ids, list): - raise InvalidDataError('Invalid registration IDs (should be list)') - - payloads = [] - - registration_id_chunks = self.registration_id_chunks(registration_ids) - for registration_ids in registration_id_chunks: - # appends a payload with a chunk of registration ids here - payloads.append(self.parse_payload( - registration_ids=registration_ids, - message_body=message_body, - message_title=message_title, - message_icon=message_icon, - sound=sound, - condition=condition, - collapse_key=collapse_key, - delay_while_idle=delay_while_idle, - time_to_live=time_to_live, - restricted_package_name=restricted_package_name, - low_priority=low_priority, - dry_run=dry_run, data_message=data_message, - click_action=click_action, - badge=badge, - color=color, - tag=tag, - body_loc_key=body_loc_key, - body_loc_args=body_loc_args, - title_loc_key=title_loc_key, - title_loc_args=title_loc_args, - content_available=content_available, - android_channel_id=android_channel_id, - extra_notification_kwargs=extra_notification_kwargs, - **extra_kwargs - )) - self.send_request(payloads, timeout) - return self.parse_responses() - - def multiple_devices_data_message(self, - registration_ids=None, - condition=None, - collapse_key=None, - delay_while_idle=False, - time_to_live=None, - restricted_package_name=None, - low_priority=False, - dry_run=False, - data_message=None, - content_available=None, - timeout=120, - extra_notification_kwargs=None, - extra_kwargs={}): """ - Sends push message to multiple devices, can send to over 1000 devices - - Args: - registration_ids (list, optional): FCM device registration IDs - condition (str, optiona): Topic condition to deliver messages to - collapse_key (str, optional): Identifier for a group of messages - that can be collapsed so that only the last message gets sent - when delivery can be resumed. Defaults to `None`. - delay_while_idle (bool, optional): deprecated - time_to_live (int, optional): How long (in seconds) the message - should be kept in FCM storage if the device is offline. The - maximum time to live supported is 4 weeks. Defaults to `None` - which uses the FCM default of 4 weeks. - restricted_package_name (str, optional): Name of package - low_priority (bool, optional): Whether to send notification with - the low priority flag. Defaults to `False`. - dry_run (bool, optional): If `True` no message will be sent but request will be tested. - data_message (dict, optional): Custom key-value pairs - content_available (bool, optional): Inactive client app is awoken - timeout (int, optional): set time limit for the request - extra_notification_kwargs (dict, optional): More notification keyword arguments - extra_kwargs (dict, optional): More keyword arguments - - Returns: - dict: Response from FCM server (`multicast_id`, `success`, `failure`, `canonical_ids`, `results`) - - Raises: - AuthenticationError: If :attr:`api_key` is not set or provided - or there is an error authenticating the sender. - FCMServerError: Internal server error or timeout error on Firebase cloud messaging server - InvalidDataError: Invalid data provided - InternalPackageError: JSON parsing error, mostly from changes in the response of FCM, - create a new github issue to resolve it. - """ - if not isinstance(registration_ids, list): - raise InvalidDataError('Invalid registration IDs (should be list)') - - payloads = [] - registration_id_chunks = self.registration_id_chunks(registration_ids) - for registration_ids in registration_id_chunks: - # appends a payload with a chunk of registration ids here - payloads.append(self.parse_payload( - registration_ids=registration_ids, - condition=condition, - collapse_key=collapse_key, - delay_while_idle=delay_while_idle, - time_to_live=time_to_live, - restricted_package_name=restricted_package_name, - low_priority=low_priority, - dry_run=dry_run, - data_message=data_message, - content_available=content_available, - remove_notification=True, - extra_notification_kwargs=extra_notification_kwargs, - **extra_kwargs) - ) - self.send_request(payloads, timeout) - return self.parse_responses() - - def notify_topic_subscribers(self, - topic_name=None, - message_body=None, - message_title=None, - message_icon=None, - sound=None, - condition=None, - collapse_key=None, - delay_while_idle=False, - time_to_live=None, - restricted_package_name=None, - low_priority=False, - dry_run=False, - data_message=None, - click_action=None, - badge=None, - color=None, - tag=None, - body_loc_key=None, - body_loc_args=None, - title_loc_key=None, - title_loc_args=None, - content_available=None, - android_channel_id=None, - timeout=120, - extra_notification_kwargs=None, - extra_kwargs={}): - """ - Sends push notification to multiple devices subscribed to a topic - - Args: - topic_name (str, optional): Name of the topic to deliver messages to - message_body (str, optional): Message string to display in the notification tray - message_title (str, optional): Message title to display in the notification tray - message_icon (str, optional): Icon that apperas next to the notification - sound (str, optional): The sound file name to play. Specify "Default" for device default sound. - condition (str, optiona): Topic condition to deliver messages to - collapse_key (str, optional): Identifier for a group of messages - that can be collapsed so that only the last message gets sent - when delivery can be resumed. Defaults to `None`. - delay_while_idle (bool, optional): deprecated - time_to_live (int, optional): How long (in seconds) the message - should be kept in FCM storage if the device is offline. The - maximum time to live supported is 4 weeks. Defaults to `None` - which uses the FCM default of 4 weeks. - restricted_package_name (str, optional): Name of package - low_priority (bool, optional): Whether to send notification with - the low priority flag. Defaults to `False`. - dry_run (bool, optional): If `True` no message will be sent but request will be tested. - data_message (dict, optional): Custom key-value pairs - click_action (str, optional): Action associated with a user click on the notification - badge (str, optional): Badge of notification - color (str, optional): Color of the icon - tag (str, optional): Group notification by tag - body_loc_key (str, optional): Indicates the key to the body string for localization - body_loc_args (list, optional): Indicates the string value to replace format - specifiers in body string for localization - title_loc_key (str, optional): Indicates the key to the title string for localization - title_loc_args (list, optional): Indicates the string value to replace format - specifiers in title string for localization - content_available (bool, optional): Inactive client app is awoken - android_channel_id (str, optional): Starting in Android 8.0 (API level 26), - all notifications must be assigned to a channel. For each channel, you can set the - visual and auditory behavior that is applied to all notifications in that channel. - Then, users can change these settings and decide which notification channels from - your app should be intrusive or visible at all. - timeout (int, optional): set time limit for the request - extra_notification_kwargs (dict, optional): More notification keyword arguments - extra_kwargs (dict, optional): More keyword arguments - - Returns: - dict: Response from FCM server (`multicast_id`, `success`, `failure`, `canonical_ids`, `results`) - - Raises: - AuthenticationError: If :attr:`api_key` is not set or provided - or there is an error authenticating the sender. - FCMServerError: Internal server error or timeout error on Firebase cloud messaging server - InvalidDataError: Invalid data provided - InternalPackageError: JSON parsing error, mostly from changes in the response of FCM, - create a new github issue to resolve it. - """ - payload = self.parse_payload( - topic_name=topic_name, - condition=condition, - message_body=message_body, - message_title=message_title, - message_icon=message_icon, - sound=sound, - collapse_key=collapse_key, - delay_while_idle=delay_while_idle, - time_to_live=time_to_live, - restricted_package_name=restricted_package_name, - low_priority=low_priority, - dry_run=dry_run, data_message=data_message, click_action=click_action, - badge=badge, - color=color, - tag=tag, - body_loc_key=body_loc_key, - body_loc_args=body_loc_args, - title_loc_key=title_loc_key, - title_loc_args=title_loc_args, - content_available=content_available, - android_channel_id=android_channel_id, - extra_notification_kwargs=extra_notification_kwargs, - **extra_kwargs - ) - self.send_request([payload], timeout) - return self.parse_responses() - - def topic_subscribers_data_message(self, - topic_name=None, - condition=None, - collapse_key=None, - delay_while_idle=False, - time_to_live=None, - restricted_package_name=None, - low_priority=False, - dry_run=False, - data_message=None, - content_available=None, - timeout=120, - extra_notification_kwargs=None, - extra_kwargs={}): - """ - Sends data notification to multiple devices subscribed to a topic - Args: - topic_name (topic_name): Name of the topic to deliver messages to - condition (condition): Topic condition to deliver messages to - A topic name is a string that can be formed with any character in [a-zA-Z0-9-_.~%] - data_message (dict): Data message payload to send alone or with the notification message - - Keyword Args: - collapse_key (str, optional): Identifier for a group of messages - that can be collapsed so that only the last message gets sent - when delivery can be resumed. Defaults to ``None``. - delay_while_idle (bool, optional): If ``True`` indicates that the - message should not be sent until the device becomes active. - time_to_live (int, optional): How long (in seconds) the message - should be kept in FCM storage if the device is offline. The - maximum time to live supported is 4 weeks. Defaults to ``None`` - which uses the FCM default of 4 weeks. - low_priority (boolean, optional): Whether to send notification with - the low priority flag. Defaults to ``False``. - restricted_package_name (str, optional): Package name of the - application where the registration IDs must match in order to - receive the message. Defaults to ``None``. - dry_run (bool, optional): If ``True`` no message will be sent but - request will be tested. - - Returns: - :tuple:`multicast_id(long), success(int), failure(int), canonical_ids(int), results(list)`: - Response from FCM server. - Raises: - AuthenticationError: If :attr:`api_key` is not set or provided or there is an error authenticating the sender. - FCMServerError: Internal server error or timeout error on Firebase cloud messaging server - InvalidDataError: Invalid data provided - InternalPackageError: JSON parsing error, mostly from changes in the response of FCM, create a new github issue to resolve it. - """ - if extra_kwargs is None: - extra_kwargs = {} - payload = self.parse_payload(topic_name=topic_name, - condition=condition, - collapse_key=collapse_key, - delay_while_idle=delay_while_idle, - time_to_live=time_to_live, - restricted_package_name=restricted_package_name, - low_priority=low_priority, - dry_run=dry_run, - data_message=data_message, - content_available=content_available, - remove_notification=True, - extra_notification_kwargs=extra_notification_kwargs, - **extra_kwargs) - self.send_request([payload], timeout) - return self.parse_responses() - - def async_notify_multiple_devices(self,params_list=[],timeout=5): - """ - Sends push notification to multiple devices with personalized templates - - Args: - params_list (list): list of parameters ( the sames as notify_multiple_devices) - timeout (int, optional): set time limit for the request - - """ - - payloads = [ self.parse_payload(params) for params in params_list ] + if params_list is None: + params_list = [] - return self.send_async_request(payloads=payloads,timeout=timeout) + payloads = [self.parse_payload(**params) for params in params_list] + return self.send_async_request(payloads=payloads, timeout=timeout) diff --git a/requirements.txt b/requirements.txt index a081b46..1a0f2d4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,10 @@ -requests>=2.6.0 aiohttp>=3.6.2 +cachetools==5.3.3 +google-auth==2.29.0 +pyasn1==0.6.0 +pyasn1-modules==0.4.0 +rsa==4.9 +requests>=2.6.0 urllib3==1.26.5 +pytest-mock==3.14.0 + diff --git a/setup.py b/setup.py index 9d32c5c..553c4f2 100644 --- a/setup.py +++ b/setup.py @@ -19,54 +19,55 @@ def read(fname): "requests", "urllib3>=1.26.0", ] -tests_require = ['pytest'] +tests_require = ["pytest"] # We can't get the values using `from pyfcm import __meta__`, because this would import # the other modules too and raise an exception (dependencies are not installed at this point yet). meta = {} -exec(read('pyfcm/__meta__.py'), meta) +exec(read("pyfcm/__meta__.py"), meta) -if sys.argv[-1] == 'publish': +if sys.argv[-1] == "publish": os.system("rm dist/*.gz dist/*.whl") - os.system("git tag -a %s -m 'v%s'" % (meta['__version__'], meta['__version__'])) - os.system("python setup.py sdist bdist_wheel") + os.system("git tag -a %s -m 'v%s'" % (meta["__version__"], meta["__version__"])) + os.system("python -m build") os.system("twine upload dist/*") os.system("git push --tags") sys.exit() setup( - name=meta['__title__'], - version=meta['__version__'], - url=meta['__url__'], - license=meta['__license__'], - author=meta['__author__'], - author_email=meta['__email__'], - description=meta['__summary__'], - long_description=read('README.rst'), - packages=['pyfcm'], + name=meta["__title__"], + version=meta["__version__"], + url=meta["__url__"], + license=meta["__license__"], + author=meta["__author__"], + author_email=meta["__email__"], + description=meta["__summary__"], + long_description=read("README.md"), + long_description_content_type="text/markdown", + packages=["pyfcm"], install_requires=install_requires, tests_require=tests_require, test_suite="tests.get_tests", - extras_require={'test': tests_require}, - keywords='firebase fcm apns ios gcm android push notifications', + extras_require={"test": tests_require}, + keywords="firebase fcm apns ios gcm android push notifications", classifiers=[ - 'Development Status :: 5 - Production/Stable', - 'Intended Audience :: Developers', - 'Operating System :: OS Independent', - 'Programming Language :: Python', - 'License :: OSI Approved :: MIT License', - 'Topic :: Communications', - 'Topic :: Internet', - 'Topic :: Software Development :: Libraries :: Python Modules', - 'Topic :: Utilities', - 'Programming Language :: Python', - 'Programming Language :: Python :: 2', - 'Programming Language :: Python :: 2.6', - 'Programming Language :: Python :: 2.7', - 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.2', - 'Programming Language :: Python :: 3.3', - 'Programming Language :: Python :: 3.4', - 'Programming Language :: Python :: 3.5', - ] + "Development Status :: 5 - Production/Stable", + "Intended Audience :: Developers", + "Operating System :: OS Independent", + "Programming Language :: Python", + "License :: OSI Approved :: MIT License", + "Topic :: Communications", + "Topic :: Internet", + "Topic :: Software Development :: Libraries :: Python Modules", + "Topic :: Utilities", + "Programming Language :: Python", + "Programming Language :: Python :: 2", + "Programming Language :: Python :: 2.6", + "Programming Language :: Python :: 2.7", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.2", + "Programming Language :: Python :: 3.3", + "Programming Language :: Python :: 3.4", + "Programming Language :: Python :: 3.5", + ], ) diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..bdcdf4f --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,57 @@ +import json +import os +from unittest.mock import AsyncMock + +import pytest + +from pyfcm import FCMNotification, errors +from pyfcm.baseapi import BaseAPI + + +@pytest.fixture(scope="module") +def push_service(): + service_account_file = os.getenv("GOOGLE_APPLICATION_CREDENTIALS", None) + project_id = os.getenv("FCM_TEST_PROJECT_ID", None) + assert ( + service_account_file + ), "Please set the service_account for testing according to CONTRIBUTING.rst" + + return FCMNotification( + service_account_file=service_account_file, project_id=project_id + ) + + +@pytest.fixture +def generate_response(mocker): + response = {"test": "test"} + mock_response = mocker.Mock() + mock_response.json.return_value = response + mock_response.status_code = 200 + mock_response.headers = {"Content-Length": "123"} + mocker.patch("pyfcm.baseapi.BaseAPI.send_request", return_value=mock_response) + + +@pytest.fixture +def mock_aiohttp_session(mocker): + # Define the fake response data + response = {"test": "test"} + + # Create a mock response object + mock_response = AsyncMock() + mock_response.text = AsyncMock(return_value=json.dumps(response)) + mock_response.status = 200 + mock_response.headers = {"Content-Length": "123"} + + mock_send = mocker.patch("pyfcm.async_fcm.send_request", new_callable=AsyncMock) + mock_send.return_value = mock_response + return mock_send + + +@pytest.fixture(scope="module") +def base_api(): + service_account = os.getenv("GOOGLE_APPLICATION_CREDENTIALS", None) + assert ( + service_account + ), "Please set the service_account for testing according to CONTRIBUTING.rst" + + return BaseAPI(api_key=service_account) diff --git a/tests/test_baseapi.py b/tests/test_baseapi.py index c14a036..e5cdfe2 100644 --- a/tests/test_baseapi.py +++ b/tests/test_baseapi.py @@ -8,116 +8,43 @@ @pytest.fixture(scope="module") def base_api(): - api_key = os.getenv("FCM_TEST_API_KEY", None) - assert api_key, "Please set the environment variables for testing according to CONTRIBUTING.rst" + service_account_file = os.getenv("GOOGLE_APPLICATION_CREDENTIALS", None) + project_id = os.getenv("FCM_TEST_PROJECT_ID", None) + assert ( + project_id + ), "Please set the environment variables for testing according to CONTRIBUTING.rst" - return BaseAPI(api_key=api_key) - - -def test_init_baseapi(): - try: - BaseAPI() - assert False, "Should raise AuthenticationError" - except errors.AuthenticationError: - pass - - -def test_request_headers(base_api): - headers = base_api.request_headers() - - assert headers["Content-Type"] == "application/json" - assert headers["Authorization"] == "key=" + os.getenv("FCM_TEST_API_KEY") - - -def test_registration_id_chunks(base_api): - registrations_ids = range(9999) - chunks = list(base_api.registration_id_chunks(registrations_ids)) - - assert len(chunks) == 10 - assert len(chunks[0]) == 1000 - assert len(chunks[-1]) == 999 + return BaseAPI(service_account_file=service_account_file, project_id=project_id) def test_json_dumps(base_api): - json_string = base_api.json_dumps( - [ - {"test": "Test"}, - {"test2": "Test2"} - ] - ) + json_string = base_api.json_dumps([{"test": "Test"}, {"test2": "Test2"}]) - assert json_string == b"[{\"test\":\"Test\"},{\"test2\":\"Test2\"}]" + assert json_string == b'[{"test":"Test"},{"test2":"Test2"}]' def test_parse_payload(base_api): json_string = base_api.parse_payload( - registration_ids=["Test"], - message_body="Test", - message_title="Test", - message_icon="Test", - sound="Test", - collapse_key="Test", - delay_while_idle=False, - time_to_live=0, - restricted_package_name="Test", - low_priority=False, + fcm_token="test", + notification_title="test", + notification_body="test", + notification_image="test", + data_payload={"test": "test"}, + topic_name="test", + topic_condition="test", + android_config={}, + apns_config={}, + webpush_config={}, + fcm_options={}, dry_run=False, - data_message={"test": "test"}, - click_action="Test", - badge="Test", - color="Test", - tag="Test", - body_loc_key="Test", - body_loc_args="Test", - title_loc_key="Test", - title_loc_args="Test", - content_available="Test", - android_channel_id="Test", - timeout=5, - extra_notification_kwargs={}, - extra_kwargs={} ) data = json.loads(json_string.decode("utf-8")) - assert data["notification"] == { - "android_channel_id": "Test", - "body": "Test", - "click_action": "Test", - "color": "Test", - "icon": "Test", - "sound": "Test", - "tag": "Test", - "title": "Test" + assert data["message"]["notification"] == { + "body": "test", + "title": "test", + "image": "test", } - assert 'time_to_live' in data - assert data['time_to_live'] == 0 - - -def test_clean_registration_ids(base_api): - registrations_ids = base_api.clean_registration_ids(["Test"]) - assert len(registrations_ids) == 0 - - -def test_subscribe_registration_ids_to_topic(base_api): - # TODO - pass - - -def test_unsubscribe_registration_ids_from_topic(base_api): - # TODO - pass - - -def test_parse_responses(base_api): - response = base_api.parse_responses() - - assert response == { - "multicast_ids": [], - "success": 0, - "failure": 0, - "canonical_ids": 0, - "results": [], - "topic_message_id": None - } + assert data["message"]["data"] == {"test": "test"} diff --git a/tests/test_fcm.py b/tests/test_fcm.py index 4f40572..f7f3b7c 100644 --- a/tests/test_fcm.py +++ b/tests/test_fcm.py @@ -1,159 +1,21 @@ -import os import pytest - from pyfcm import FCMNotification, errors -@pytest.fixture(scope="module") -def push_service(): - api_key = os.getenv("FCM_TEST_API_KEY", None) - assert api_key, "Please set the environment variables for testing according to CONTRIBUTING.rst" - - return FCMNotification(api_key=api_key) - - def test_push_service_without_credentials(): try: - FCMNotification() + FCMNotification(service_account_file="", project_id="") assert False, "Should raise AuthenticationError without credentials" except errors.AuthenticationError: pass -def test_notify_single_device(push_service): - response = push_service.notify_single_device( - registration_id="Test", - message_body="Test", - message_title="Test", - dry_run=True - ) - - assert isinstance(response, dict) - - -def test_single_device_data_message(push_service): - try: - push_service.single_device_data_message( - data_message={"test": "Test"}, - dry_run=True - ) - assert False, "Should raise InvalidDataError without registration id" - except errors.InvalidDataError: - pass - - response = push_service.single_device_data_message( - registration_id="Test", - data_message={"test": "Test"}, - dry_run=True - ) - - assert isinstance(response, dict) - - -def test_notify_multiple_devices(push_service): - response = push_service.notify_multiple_devices( - registration_ids=["Test"], - message_body="Test", - message_title="Test", - dry_run=True - ) - - assert isinstance(response, dict) - - -def test_multiple_devices_data_message(push_service): - try: - push_service.multiple_devices_data_message( - data_message={"test": "Test"}, - dry_run=True - ) - assert False, "Should raise InvalidDataError without registration ids" - except errors.InvalidDataError: - pass - - response = push_service.multiple_devices_data_message( - registration_ids=["Test"], - data_message={"test": "Test"}, - dry_run=True - ) - - assert isinstance(response, dict) - - -def test_notify_topic_subscribers(push_service): - try: - push_service.notify_topic_subscribers( - message_body="Test", - dry_run=True - ) - assert False, "Should raise InvalidDataError without topic" - except errors.InvalidDataError: - pass - - response = push_service.notify_topic_subscribers( - topic_name="test", - message_body="Test", - message_title="Test", - dry_run=True - ) - - assert isinstance(response, dict) - - -def test_notify_with_args(push_service): - push_service.notify_single_device( - registration_id="Test", - message_body="Test", - message_title="Test", - message_icon="Test", - sound="Test", - collapse_key="Test", - delay_while_idle=False, - time_to_live=100, - restricted_package_name="Test", - low_priority=False, +def test_notify(push_service, generate_response): + response = push_service.notify( + fcm_token="Test", + notification_body="Test", + notification_title="Test", dry_run=True, - data_message={"test": "test"}, - click_action="Test", - badge="Test", - color="Test", - tag="Test", - body_loc_key="Test", - body_loc_args="Test", - title_loc_key="Test", - title_loc_args="Test", - content_available="Test", - android_channel_id="Test", - timeout=5, - extra_notification_kwargs={}, - extra_kwargs={} ) -def test_async_notify(push_service): - params = {"registration_ids" : ['Test'], - "message_body" : "Test", - "message_title" : "Test", - "message_icon" : "Test", - "sound" : "Test", - "collapse_key" : "Test", - "delay_while_idle" : False, - "time_to_live" : 100, - "restricted_package_name" : "Test", - "low_priority" : False, - "dry_run" : True, - "data_message" : {"test": "test"}, - "click_action" : "Test", - "badge" : "Test", - "color" : "Test", - "tag" : "Test", - "body_loc_key" : "Test", - "body_loc_args" : "Test", - "title_loc_key" : "Test", - "title_loc_args" : "Test", - "content_available" : "Test", - "android_channel_id" : "Test" - } - - params_list = [params for _ in range(100)] - - push_service.send_async_request(params_list,timeout=5) + assert isinstance(response, dict)