├── .editorconfig ├── .github └── workflows │ ├── python-app.yml │ └── python-publish.yml ├── .gitignore ├── .travis.yml ├── CHANGES.rst ├── CONTRIBUTING.rst ├── LICENSE ├── MANIFEST.in ├── README.md ├── pyfcm ├── __init__.py ├── __meta__.py ├── async_fcm.py ├── baseapi.py ├── errors.py └── fcm.py ├── requirements.txt ├── setup.py └── tests ├── conftest.py ├── test_baseapi.py └── test_fcm.py /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | indent_style = space 7 | insert_final_newline = true 8 | trim_trailing_whitespace = true 9 | 10 | [*.rst] 11 | indent_style = space 12 | indent_size = 4 13 | -------------------------------------------------------------------------------- /.github/workflows/python-app.yml: -------------------------------------------------------------------------------- 1 | # This workflow will install Python dependencies, run tests and lint with a single version of Python 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions 3 | 4 | name: Python application 5 | 6 | on: 7 | push: 8 | branches: 9 | - '**' 10 | create: 11 | branches: 12 | - '**' 13 | tags: 14 | - '**' 15 | pull_request: 16 | branches: 17 | - master # Run on pull requests targeting the master branch 18 | 19 | jobs: 20 | build: 21 | 22 | runs-on: ubuntu-latest 23 | 24 | steps: 25 | - uses: actions/checkout@v2 26 | - name: Set up Python 3.10 27 | uses: actions/setup-python@v2 28 | with: 29 | python-version: "3.10" 30 | - name: Install dependencies 31 | run: | 32 | python -m pip install --upgrade pip 33 | pip install flake8 pytest 34 | if [ -f requirements.txt ]; then pip install -r requirements.txt; fi 35 | - name: Lint with flake8 36 | run: | 37 | # stop the build if there are Python syntax errors or undefined names 38 | flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics 39 | # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide 40 | flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics 41 | - name: Check formatting 42 | run: | 43 | # stop the build if there are formatting is error in any python codes 44 | # to check whether the codes are formatted or not before merge 45 | pip install black==24.4.2 46 | pip install click==8.1.7 47 | python -m black -t py310 --check . 48 | - name: Test with pytest 49 | run: | 50 | export GOOGLE_APPLICATION_CREDENTIALS=service-account.json 51 | export FCM_TEST_PROJECT_ID=test 52 | pip install . ".[test]" 53 | python -m pytest . 54 | -------------------------------------------------------------------------------- /.github/workflows/python-publish.yml: -------------------------------------------------------------------------------- 1 | # This workflows will upload a Python Package using Twine when a release is created 2 | # For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries 3 | 4 | name: Upload Python Package 5 | 6 | on: 7 | release: 8 | types: [created] 9 | 10 | jobs: 11 | deploy: 12 | 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - uses: actions/checkout@v2 17 | - name: Set up Python 18 | uses: actions/setup-python@v2 19 | with: 20 | python-version: '3.x' 21 | - name: Install dependencies 22 | run: | 23 | python -m pip install --upgrade pip 24 | pip install setuptools wheel twine 25 | - name: Build and publish 26 | env: 27 | TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} 28 | TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} 29 | run: | 30 | python setup.py sdist bdist_wheel 31 | twine upload dist/* 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | MANIFEST 27 | /.pypirc 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .coverage 43 | .coverage.* 44 | .cache 45 | nosetests.xml 46 | coverage.xml 47 | *.cover 48 | .hypothesis/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | .static_storage/ 57 | .media/ 58 | local_settings.py 59 | 60 | # Flask stuff: 61 | instance/ 62 | .webassets-cache 63 | 64 | # Scrapy stuff: 65 | .scrapy 66 | 67 | # Sphinx documentation 68 | docs/_build/ 69 | 70 | # PyBuilder 71 | target/ 72 | 73 | # Jupyter Notebook 74 | .ipynb_checkpoints 75 | 76 | # pyenv 77 | .python-version 78 | 79 | # celery beat schedule file 80 | celerybeat-schedule 81 | 82 | # SageMath parsed files 83 | *.sage.py 84 | 85 | # Environments 86 | .env 87 | .venv 88 | env/ 89 | venv/ 90 | ENV/ 91 | env.bak/ 92 | venv.bak/ 93 | 94 | # Spyder project settings 95 | .spyderproject 96 | .spyproject 97 | 98 | # Rope project settings 99 | .ropeproject 100 | 101 | # mkdocs documentation 102 | /site 103 | 104 | # mypy 105 | .mypy_cache/ 106 | 107 | # pycharm 108 | .idea 109 | 110 | # vscode 111 | .vscode 112 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | env: 3 | global: 4 | - FCM_TEST_API_KEY=AIzaSyCCRBsh8Sw3YdycGcpU71MeUiXxciqW0go 5 | python: 6 | - "2.7" 7 | - "3.4" 8 | - "3.6" 9 | - "3.7-dev" 10 | install: 11 | - pip install . ".[test]" 12 | script: 13 | - pytest 14 | -------------------------------------------------------------------------------- /CHANGES.rst: -------------------------------------------------------------------------------- 1 | .. _changelog: 2 | 3 | Changelog 4 | ========= 5 | 6 | v0.0.1 (05-06-2016) 7 | ------------------- 8 | 9 | - First release. 10 | 11 | .. _Emmanuel Adegbite: https://github.com/olucurious 12 | 13 | 14 | v0.0.2 (07-06-2016) 15 | ------------------- 16 | 17 | - Added topic messaging functionality. 18 | 19 | .. _Emmanuel Adegbite: https://github.com/olucurious 20 | 21 | 22 | v0.0.3 (08-06-2016) 23 | ------------------- 24 | 25 | - changes in README.rst 26 | 27 | .. _Emmanuel Adegbite: https://github.com/olucurious 28 | 29 | v0.0.4 (09-06-2016) 30 | ------------------- 31 | 32 | - Fixed "registration_id of notify_single_device does not work with a str input" 33 | 34 | .. _Emmanuel Adegbite: https://github.com/olucurious 35 | 36 | v0.0.5 (22-06-2016) 37 | ------------------- 38 | 39 | - Fixed python 3 import issue 40 | 41 | .. _MrLucasCardoso: https://github.com/MrLucasCardoso 42 | 43 | v0.0.6 (23-06-2016) 44 | ------------------- 45 | 46 | - Fixed xrange issue in python3 47 | 48 | .. _Emmanuel Adegbite: https://github.com/olucurious 49 | 50 | v0.0.7 (24-06-2016) 51 | ------------------- 52 | 53 | - Added support for sending data only messages 54 | 55 | .. _Emmanuel Adegbite: https://github.com/olucurious 56 | 57 | v0.0.8 (26-06-2016) 58 | ------------------- 59 | 60 | - Checking content-length in response.headers, otherwise it will crash, when calling response.json() 61 | 62 | .. _Rishabh : https://gihub.com/elpoisterio 63 | 64 | v1.0.0 (12-07-2016) 65 | ------------------- 66 | 67 | - Added proxy support, more fcm arguments and bump version to 1.0.0 68 | 69 | .. _Emmanuel Adegbite: https://github.com/olucurious 70 | 71 | v1.0.0 (16-07-2016) 72 | ------------------- 73 | 74 | - Added extra_kwargs for dinamic vars in notify_single_device/notify_multiple_devices functions 75 | 76 | .. _Sergey Afonin: https://github.com/safonin 77 | 78 | v1.0.1 (04-08-2016) 79 | ------------------- 80 | 81 | - Added tornado support 82 | 83 | .. _Dmitry Nazarov: https://github.com/mkn8rd 84 | 85 | v1.1.4 (11-11-2016) 86 | ------------------- 87 | 88 | - added body_loc_key support and notify single device single response 89 | 90 | .. _Emmanuel Adegbite: https://github.com/olucurious 91 | 92 | v1.1.5 (16-11-2016) 93 | ------------------- 94 | 95 | - Fix some message components not being sent if message_body is None (click_action, badge, sound, etc) 96 | 97 | .. _João Ricardo Lourenço: https://github.com/Jorl17 98 | 99 | v1.2.0 (16-11-2016) 100 | ------------------- 101 | 102 | - Updated response retrieval, notify_single_device response returns single dict while notify_multiple_devices returns a list of dicts 103 | - You can now pass extra argument by passing it as key value in a dictionary as extra_kwargs to any notification sending method you want to use 104 | - It is now possible to send a notification without setting body or content available 105 | 106 | .. _Emmanuel Adegbite: https://github.com/olucurious 107 | 108 | v1.2.3 (09-02-2017) 109 | ------------------- 110 | 111 | - Added support for checking for and returning valid registration ids, useful for cleaning up database 112 | 113 | .. _baali: https://github.com/baali 114 | 115 | 116 | v1.2.9 (07-04-2017) 117 | ------------------- 118 | 119 | - Fixed issue with notification extra kwargs 120 | 121 | .. _Emmanuel Adegbite: https://github.com/olucurious 122 | 123 | Unreleased 124 | ------------------- 125 | 126 | - Add optional json_encoder argument to BaseAPI to allow configuring the JSONEncoder used for parse_payload 127 | 128 | .. _Carlos Arrastia: https://github.com/carrasti 129 | 130 | - Addition of a android dictionary to set fcm priority 131 | 132 | .. _Pratik Sayare: https://github.com/gizmopratik 133 | 134 | - Add android_channel_id 135 | 136 | .. _Lucas Hild: https://github.com/Lanseuo 137 | 138 | - Add configurable retries to info endpoint 139 | 140 | .. _Christy O'Reilly: https://github.com/c-oreills 141 | 142 | - Fix time_to_live check to allow 0 143 | 144 | .. _Stephen Kwong: https://github.com/skwong2 145 | 146 | - Add pycharm and vscode to gitignore 147 | 148 | .. _Alexandr Sukhryn: https://github.com/alexsukhrin 149 | 150 | - Fix CONTRIBUTING.rst 151 | 152 | .. _Alexandr Sukhryn: https://github.com/alexsukhrin 153 | 154 | - Replace deprecated urllib3.Retry options 155 | 156 | .. _Frederico Jordan: https://github.com/fredericojordan 157 | 158 | - Replace deprecated urllib3.Retry options v2 159 | 160 | .. _Łukasz Rogowski: https://github.com/Rogalek 161 | 162 | - Explicitly handle 403 SENDER_ID_MISMATCH response 163 | 164 | .. _James Priebe: https://github.com/J-Priebe/ 165 | -------------------------------------------------------------------------------- /CONTRIBUTING.rst: -------------------------------------------------------------------------------- 1 | How to Contribute 2 | ================= 3 | 4 | - Overview_ 5 | - Guidelines_ 6 | - Branching_ 7 | 8 | 9 | Overview 10 | -------- 11 | 12 | 1. Fork the repo. 13 | 2. Improve/fix the code. 14 | 3. Write and run tests 15 | 4. Add your changes to CHANGES.rst 16 | 5. Push to your fork and submit a pull request to the ``develop`` branch. 17 | 18 | 19 | Guidelines 20 | ---------- 21 | 22 | Some simple guidelines to follow when contributing code: 23 | 24 | - Adhere to `PEP8`. 25 | - Clean, well documented code. 26 | 27 | 28 | Tests 29 | ----- 30 | 31 | Before commiting your changes, please run the tests. For running the tests you need a service account. 32 | 33 | **Please do not use a service account, which is used in production!** 34 | 35 | :: 36 | 37 | pip install . ".[test]" 38 | 39 | export GOOGLE_APPLICATION_CREDENTIALS="service_account.json" 40 | 41 | python -m pytest 42 | 43 | If you add a new fixture or fix a bug, please make sure to write a new unit test. This makes development easier and avoids new bugs. 44 | 45 | 46 | Branching 47 | --------- 48 | 49 | There are two main development branches: ``master`` and ``develop``. ``master`` represents the currently released version while ``develop`` is the latest development work. When submitting a pull request, be sure to submit to ``develop``. 50 | 51 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2019 Emmanuel Adegbite 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation 4 | files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, 5 | modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software 6 | is furnished to do so, subject to the following conditions: 7 | 8 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 9 | 10 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 11 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 12 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR 13 | IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 14 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE.rst README.rst CHANGES.rst pyfcm/extensions/*.py -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PyFCM 2 | 3 | [![version](http://img.shields.io/pypi/v/pyfcm.svg?style=flat-square)](https://pypi.python.org/pypi/pyfcm/) 4 | [![license](http://img.shields.io/pypi/l/pyfcm.svg?style=flat-square)](https://pypi.python.org/pypi/pyfcm/) 5 | 6 | Python client for FCM - Firebase Cloud Messaging (Android, iOS and Web) 7 | 8 | Firebase Cloud Messaging (FCM) is the new version of GCM. It inherits 9 | the reliable and scalable GCM infrastructure, plus new features. GCM 10 | users are strongly recommended to upgrade to FCM. 11 | 12 | Using FCM, you can notify a client app that new email or other data is 13 | available to sync. You can send notifications to drive user reengagement 14 | and retention. For use cases such as instant messaging, a message can 15 | transfer a payload of up to 4KB to a client app. 16 | 17 | For more information, visit: 18 | 19 | 20 | ## Links 21 | 22 | - Project: 23 | - PyPi: 24 | 25 | ### Updates (Breaking Changes) 26 | 27 | - MIGRATION TO FCM HTTP V1 (JUNE 2024): 28 | (big 29 | shoutout to @Subhrans for the PR, for more information: 30 | ) 31 | - MAJOR UPDATES (AUGUST 2017): 32 | 33 | 34 | Installation ========== 35 | 36 | Install using pip: 37 | 38 | pip install pyfcm 39 | 40 | OR 41 | 42 | pip install git+https://github.com/olucurious/PyFCM.git 43 | 44 | PyFCM supports Android, iOS and Web. 45 | 46 | ## Features 47 | 48 | - All FCM functionality covered 49 | - Tornado support 50 | 51 | ## Examples 52 | 53 | ### Send notifications using the `FCMNotification` class 54 | 55 | ``` python 56 | # Send to single device. 57 | from pyfcm import FCMNotification 58 | 59 | fcm = FCMNotification(service_account_file="", project_id="") 60 | 61 | # Google oauth2 credentials(such as ADC, impersonate credentials) can be used instead of service account file. 62 | 63 | fcm = FCMNotification( 64 | service_account_file=None, credentials=your_credentials, project_id="" 65 | ) 66 | 67 | # OR initialize with proxies 68 | 69 | proxy_dict = { 70 | "http" : "http://127.0.0.1", 71 | "https" : "http://127.0.0.1", 72 | } 73 | fcm = FCMNotification(service_account_file="", project_id="", proxy_dict=proxy_dict) 74 | 75 | # OR using credentials from environment variable 76 | # Often you would save service account json in evironment variable 77 | # Assuming GCP_CREDENTIALS contains the data (TIP: use "export GCP_CREDENTIALS=$(filename.json)" to quickly load the json) 78 | 79 | from google.oauth2 import service_account 80 | gcp_json_credentials_dict = json.loads(os.getenv('GCP_CREDENTIALS', None)) 81 | credentials = service_account.Credentials.from_service_account_info(gcp_json_credentials_dict, scopes=['https://www.googleapis.com/auth/firebase.messaging']) 82 | fcm = FCMNotification(service_account_file=None, credentials=credentials, project_id="") 83 | 84 | # Your service account file can be gotten from: https://console.firebase.google.com/u/0/project/_/settings/serviceaccounts/adminsdk 85 | 86 | # Now you are ready to send notification 87 | fcm_token = "" 88 | notification_title = "Uber update" 89 | notification_body = "Hi John, your order is on the way!" 90 | notification_image = "https://example.com/image.png" 91 | result = fcm.notify(fcm_token=fcm_token, notification_title=notification_title, notification_body=notification_body, notification_image=notification_image) 92 | print result 93 | ``` 94 | 95 | ### Send a data message 96 | 97 | ``` python 98 | # With FCM, you can send two types of messages to clients: 99 | # 1. Notification messages, sometimes thought of as "display messages." 100 | # 2. Data messages, which are handled by the client app. 101 | # 3. Notification messages with optional data payload. 102 | 103 | # Client app is responsible for processing data messages. Data messages have only custom key-value pairs. (Python dict) 104 | # Data messages let developers send up to 4KB of custom key-value pairs. 105 | 106 | # Sending a notification with data message payload 107 | data_payload = { 108 | "foo": "bar", 109 | "body": "great match!", 110 | "room": "PortugalVSDenmark" 111 | } 112 | # To a single device 113 | result = fcm.notify(fcm_token=fcm_token, notification_body=notification_body, data_payload=data_payload) 114 | 115 | # Sending a data message only payload, do NOT include notification_body also do NOT include notification body 116 | # To a single device 117 | result = fcm.notify(fcm_token=fcm_token, data_payload=data_payload) 118 | 119 | # Only string key and values are accepted. booleans, nested dicts are not supported 120 | # To send nested dict, use something like 121 | data_payload = { 122 | "foo": "bar", 123 | "data": json.dumps(data). 124 | } 125 | # For more info on format see https://firebase.google.com/docs/reference/fcm/rest/v1/projects.messages#Message 126 | # and https://firebase.google.com/docs/cloud-messaging/http-server-ref#downstream-http-messages-json 127 | 128 | # Use notification messages when you want FCM to handle displaying a notification on your app's behalf. 129 | # Use data messages when you just want to process the messages only in your app. 130 | # PyFCM can send a message including both notification and data payloads. 131 | # In such cases, FCM handles displaying the notification payload, and the client app handles the data payload. 132 | ``` 133 | 134 | ### Appengine users should define their environment 135 | 136 | ``` python 137 | fcm = FCMNotification(service_account_file="", project_id="", proxy_dict=proxy_dict, env='app_engine') 138 | result = fcm.notify(fcm_token=fcm_token, notification_body=message) 139 | ``` 140 | 141 | ### Sending a message to a topic 142 | 143 | ``` python 144 | # Send a message to devices subscribed to a topic. 145 | result = fcm.notify(topic_name="news", notification_body=message) 146 | 147 | # Conditional topic messaging 148 | topic_condition = "'TopicA' in topics && ('TopicB' in topics || 'TopicC' in topics)" 149 | result = fcm.notify(notification_body=message, topic_condition=topic_condition) 150 | # FCM first evaluates any conditions in parentheses, and then evaluates the expression from left to right. 151 | # In the above expression, a user subscribed to any single topic does not receive the message. Likewise, 152 | # a user who does not subscribe to TopicA does not receive the message. These combinations do receive it: 153 | # TopicA and TopicB 154 | # TopicA and TopicC 155 | # Conditions for topics support two operators per expression, and parentheses are supported. 156 | # For more information, check: https://firebase.google.com/docs/cloud-messaging/topic-messaging 157 | ``` 158 | 159 | ### Extra argument options 160 | 161 | - android_config (dict, optional): Android specific options for messages - 162 | 163 | 164 | - apns_config (dict, optional): Apple Push Notification Service specific options - 165 | 166 | 167 | - webpush_config (dict, optional): Webpush protocol options - 168 | 169 | 170 | - fcm_options (dict, optional): Platform independent options for features provided by the FCM SDKs - 171 | 172 | - dry_run (bool, optional): If `True` no message will be sent but 173 | request will be tested. 174 | -------------------------------------------------------------------------------- /pyfcm/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | PyFCM 3 | """ 4 | 5 | from .__meta__ import ( 6 | __title__, 7 | __summary__, 8 | __url__, 9 | __version__, 10 | __author__, 11 | __email__, 12 | __license__, 13 | ) 14 | from .fcm import FCMNotification 15 | 16 | __all__ = [ 17 | "FCMNotification", 18 | "__title__", 19 | "__summary__", 20 | "__url__", 21 | "__version__", 22 | "__author__", 23 | "__email__", 24 | "__license__", 25 | ] 26 | -------------------------------------------------------------------------------- /pyfcm/__meta__.py: -------------------------------------------------------------------------------- 1 | __title__ = "pyfcm" 2 | __summary__ = "Python client for FCM - Firebase Cloud Messaging (Android, iOS and Web)" 3 | __url__ = "https://github.com/olucurious/pyfcm" 4 | 5 | __version__ = "2.0.7" 6 | 7 | __author__ = "Emmanuel Adegbite" 8 | __email__ = "olucurious@gmail.com" 9 | 10 | __license__ = "MIT License" 11 | -------------------------------------------------------------------------------- /pyfcm/async_fcm.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import aiohttp 3 | import json 4 | 5 | 6 | async def fetch_tasks(end_point, headers, payloads, timeout): 7 | """ 8 | 9 | :param end_point (str) : FCM endpoint 10 | :param headers (dict) : FCM Request Headers 11 | :param payloads (list) : payloads contains bytes after self.parse_payload 12 | :param timeout (int) : FCM timeout 13 | :return: 14 | """ 15 | fetches = [ 16 | asyncio.Task( 17 | send_request( 18 | end_point=end_point, headers=headers, payload=payload, timeout=timeout 19 | ) 20 | ) 21 | for payload in payloads 22 | ] 23 | return await asyncio.gather(*fetches) 24 | 25 | 26 | async def send_request(end_point, headers, payload, timeout=5): 27 | """ 28 | 29 | :param end_point (str) : FCM endpoint 30 | :param headers (dict) : FCM Request Headers 31 | :param payloads (list) : payloads contains bytes after self.parse_payload 32 | :param timeout (int) : FCM timeout 33 | :return: 34 | """ 35 | timeout = aiohttp.ClientTimeout(total=timeout) 36 | 37 | async with aiohttp.ClientSession(headers=headers, timeout=timeout) as session: 38 | 39 | async with session.post(end_point, data=payload) as res: 40 | result = await res.text() 41 | result = json.loads(result) 42 | return result 43 | -------------------------------------------------------------------------------- /pyfcm/baseapi.py: -------------------------------------------------------------------------------- 1 | # from __future__ import annotations 2 | 3 | import json 4 | import os 5 | import time 6 | import threading 7 | 8 | import requests 9 | from requests.adapters import HTTPAdapter 10 | from urllib3 import Retry 11 | 12 | from google.oauth2 import service_account 13 | import google.auth.transport.requests 14 | 15 | from pyfcm.errors import ( 16 | AuthenticationError, 17 | InvalidDataError, 18 | FCMError, 19 | FCMSenderIdMismatchError, 20 | FCMServerError, 21 | FCMNotRegisteredError, 22 | ) 23 | 24 | # Migration to v1 - https://firebase.google.com/docs/cloud-messaging/migrate-v1 25 | 26 | 27 | class BaseAPI(object): 28 | FCM_END_POINT = "https://fcm.googleapis.com/v1/projects" 29 | 30 | def __init__( 31 | self, 32 | service_account_file: str, 33 | project_id: str, 34 | credentials=None, 35 | proxy_dict=None, 36 | env=None, 37 | json_encoder=None, 38 | adapter=None, 39 | ): 40 | """ 41 | Override existing init function to give ability to use v1 endpoints of Firebase Cloud Messaging API 42 | Attributes: 43 | service_account_file (str): path to service account JSON file 44 | project_id (str): project ID of Google account 45 | credentials (Credentials): Google oauth2 credentials instance, such as ADC 46 | proxy_dict (dict): proxy settings dictionary, use proxy (keys: `http`, `https`) 47 | env (dict): environment settings dictionary, for example "app_engine" 48 | json_encoder (BaseJSONEncoder): JSON encoder 49 | adapter (BaseAdapter): adapter instance 50 | """ 51 | self.service_account_file = service_account_file 52 | self.project_id = project_id 53 | self.FCM_END_POINT = self.FCM_END_POINT + f"/{self.project_id}/messages:send" 54 | self.FCM_REQ_PROXIES = None 55 | self.custom_adapter = adapter 56 | self.thread_local = threading.local() 57 | self.credentials = credentials 58 | 59 | if not service_account_file and not credentials: 60 | raise AuthenticationError( 61 | "Please provide a service account file path or credentials in the constructor" 62 | ) 63 | 64 | if ( 65 | proxy_dict 66 | and isinstance(proxy_dict, dict) 67 | and (("http" in proxy_dict) or ("https" in proxy_dict)) 68 | ): 69 | self.FCM_REQ_PROXIES = proxy_dict 70 | self.requests_session.proxies.update(proxy_dict) 71 | 72 | if env == "app_engine": 73 | try: 74 | from requests_toolbelt.adapters import appengine 75 | 76 | appengine.monkeypatch() 77 | except ModuleNotFoundError: 78 | pass 79 | 80 | self.json_encoder = json_encoder 81 | 82 | @property 83 | def requests_session(self): 84 | if getattr(self.thread_local, "requests_session", None) is None: 85 | retries = Retry( 86 | backoff_factor=1, 87 | status_forcelist=[502, 503], 88 | allowed_methods=(Retry.DEFAULT_ALLOWED_METHODS | frozenset(["POST"])), 89 | ) 90 | adapter = self.custom_adapter or HTTPAdapter(max_retries=retries) 91 | self.thread_local.requests_session = requests.Session() 92 | self.thread_local.requests_session.mount("http://", adapter) 93 | self.thread_local.requests_session.mount("https://", adapter) 94 | self.thread_local.token_expiry = 0 95 | 96 | current_timestamp = time.time() 97 | if self.thread_local.token_expiry < current_timestamp: 98 | self.thread_local.requests_session.headers.update(self.request_headers()) 99 | self.thread_local.token_expiry = current_timestamp + 1800 100 | return self.thread_local.requests_session 101 | 102 | def send_request(self, payload=None, timeout=None): 103 | response = self.requests_session.post( 104 | self.FCM_END_POINT, data=payload, timeout=timeout 105 | ) 106 | if ( 107 | "Retry-After" in response.headers 108 | and int(response.headers["Retry-After"]) > 0 109 | ): 110 | sleep_time = int(response.headers["Retry-After"]) 111 | time.sleep(sleep_time) 112 | return self.send_request(payload, timeout) 113 | return response 114 | 115 | def send_async_request(self, params_list, timeout): 116 | 117 | import asyncio 118 | from .async_fcm import fetch_tasks 119 | 120 | payloads = [self.parse_payload(**params) for params in params_list] 121 | responses = asyncio.new_event_loop().run_until_complete( 122 | fetch_tasks( 123 | end_point=self.FCM_END_POINT, 124 | headers=self.request_headers(), 125 | payloads=payloads, 126 | timeout=timeout, 127 | ) 128 | ) 129 | 130 | return responses 131 | 132 | def _get_access_token(self): 133 | """ 134 | Generates access token from credentials. 135 | If token expires then new access token is generated. 136 | Returns: 137 | str: Access token 138 | """ 139 | # get OAuth 2.0 access token 140 | try: 141 | if self.service_account_file: 142 | credentials = service_account.Credentials.from_service_account_file( 143 | self.service_account_file, 144 | scopes=["https://www.googleapis.com/auth/firebase.messaging"], 145 | ) 146 | else: 147 | credentials = self.credentials 148 | request = google.auth.transport.requests.Request() 149 | credentials.refresh(request) 150 | return credentials.token 151 | except Exception as e: 152 | raise InvalidDataError(e) 153 | 154 | def request_headers(self): 155 | """ 156 | Generates request headers including Content-Type and Authorization of Bearer token 157 | 158 | Returns: 159 | dict: request headers 160 | """ 161 | return { 162 | "Content-Type": "application/json", 163 | "Authorization": "Bearer " + self._get_access_token(), 164 | } 165 | 166 | def json_dumps(self, data): 167 | """ 168 | Standardized json.dumps function with separators and sorted keys set 169 | 170 | Args: 171 | data (dict or list): data to be dumped 172 | 173 | Returns: 174 | string: json 175 | """ 176 | return json.dumps( 177 | data, 178 | separators=(",", ":"), 179 | sort_keys=True, 180 | cls=self.json_encoder, 181 | ensure_ascii=False, 182 | ).encode("utf8") 183 | 184 | def parse_response(self, response): 185 | """ 186 | Parses the json response sent back by the server and tries to get out the important return variables 187 | 188 | Returns: 189 | dict: name (str) - The identifier of the message sent, in the format of projects/*/messages/{message_id} 190 | 191 | Raises: 192 | FCMServerError: FCM is temporary not available 193 | AuthenticationError: error authenticating the sender account 194 | InvalidDataError: data passed to FCM was incorrecly structured 195 | FCMSenderIdMismatchError: the authenticated sender is different from the sender registered to the token 196 | FCMNotRegisteredError: device token is missing, not registered, or invalid 197 | """ 198 | 199 | if response.status_code == 200: 200 | if ( 201 | "content-length" in response.headers 202 | and int(response.headers["content-length"]) <= 0 203 | ): 204 | raise FCMServerError( 205 | "FCM server connection error, the response is empty" 206 | ) 207 | else: 208 | return response.json() 209 | 210 | elif response.status_code == 401: 211 | raise AuthenticationError( 212 | "There was an error authenticating the sender account" 213 | ) 214 | elif response.status_code == 400: 215 | raise InvalidDataError(response.text) 216 | elif response.status_code == 403: 217 | raise FCMSenderIdMismatchError( 218 | "The authenticated sender ID is different from the sender ID for the registration token." 219 | ) 220 | elif response.status_code == 404: 221 | raise FCMNotRegisteredError("Token not registered") 222 | else: 223 | raise FCMServerError( 224 | f"FCM server error: Unexpected status code {response.status_code}. The server might be temporarily unavailable." 225 | ) 226 | 227 | def parse_payload( 228 | self, 229 | fcm_token=None, 230 | notification_title=None, 231 | notification_body=None, 232 | notification_image=None, 233 | data_payload=None, 234 | topic_name=None, 235 | topic_condition=None, 236 | android_config=None, 237 | apns_config=None, 238 | webpush_config=None, 239 | fcm_options=None, 240 | dry_run=False, 241 | ): 242 | """ 243 | 244 | :rtype: json 245 | """ 246 | fcm_payload = dict() 247 | 248 | if fcm_token: 249 | fcm_payload["token"] = fcm_token 250 | 251 | if topic_name: 252 | fcm_payload["topic"] = topic_name 253 | if topic_condition: 254 | fcm_payload["condition"] = topic_condition 255 | 256 | if data_payload: 257 | if isinstance(data_payload, dict): 258 | fcm_payload["data"] = data_payload 259 | else: 260 | raise InvalidDataError("Provided data_payload is in the wrong format") 261 | 262 | if android_config: 263 | if isinstance(android_config, dict): 264 | fcm_payload["android"] = android_config 265 | else: 266 | raise InvalidDataError("Provided android_config is in the wrong format") 267 | 268 | if webpush_config: 269 | if isinstance(webpush_config, dict): 270 | fcm_payload["webpush"] = webpush_config 271 | else: 272 | raise InvalidDataError("Provided webpush_config is in the wrong format") 273 | 274 | if apns_config: 275 | if isinstance(apns_config, dict): 276 | fcm_payload["apns"] = apns_config 277 | else: 278 | raise InvalidDataError("Provided apns_config is in the wrong format") 279 | 280 | if fcm_options: 281 | if isinstance(fcm_options, dict): 282 | fcm_payload["fcm_options"] = fcm_options 283 | else: 284 | raise InvalidDataError("Provided fcm_options is in the wrong format") 285 | 286 | fcm_payload["notification"] = ( 287 | {} 288 | ) # - https://firebase.google.com/docs/reference/fcm/rest/v1/projects.messages#notification 289 | # If title is present, use it 290 | if notification_title: 291 | fcm_payload["notification"]["title"] = notification_title 292 | if notification_body: 293 | fcm_payload["notification"]["body"] = notification_body 294 | if notification_image: 295 | fcm_payload["notification"]["image"] = notification_image 296 | 297 | # Do this if you only want to send a data message. 298 | if data_payload and (not notification_title and not notification_body): 299 | del fcm_payload["notification"] 300 | 301 | return self.json_dumps({"message": fcm_payload, "validate_only": dry_run}) 302 | -------------------------------------------------------------------------------- /pyfcm/errors.py: -------------------------------------------------------------------------------- 1 | class FCMError(Exception): 2 | """ 3 | PyFCM Error 4 | """ 5 | 6 | pass 7 | 8 | 9 | class AuthenticationError(FCMError): 10 | """ 11 | API key not found or there was an error authenticating the sender 12 | """ 13 | 14 | pass 15 | 16 | 17 | class FCMNotRegisteredError(FCMError): 18 | """ 19 | push token is not registered 20 | https://firebase.google.com/docs/reference/fcm/rest/v1/ErrorCode 21 | """ 22 | 23 | pass 24 | 25 | 26 | class FCMSenderIdMismatchError(FCMError): 27 | """ 28 | Sender is not allowed for the given device tokens 29 | https://firebase.google.com/docs/reference/fcm/rest/v1/ErrorCode 30 | """ 31 | 32 | pass 33 | 34 | 35 | class FCMServerError(FCMError): 36 | """ 37 | Internal server error or timeout error on Firebase cloud messaging server 38 | """ 39 | 40 | pass 41 | 42 | 43 | class InvalidDataError(FCMError): 44 | """ 45 | Invalid input 46 | """ 47 | 48 | pass 49 | 50 | 51 | class InternalPackageError(FCMError): 52 | """ 53 | JSON parsing error, please create a new github issue describing what you're doing 54 | """ 55 | 56 | pass 57 | 58 | 59 | class RetryAfterException(Exception): 60 | """ 61 | Retry-After must be handled by external logic. 62 | """ 63 | 64 | def __init__(self, delay): 65 | self.delay = delay 66 | -------------------------------------------------------------------------------- /pyfcm/fcm.py: -------------------------------------------------------------------------------- 1 | from .baseapi import BaseAPI 2 | from .errors import InvalidDataError 3 | 4 | 5 | class FCMNotification(BaseAPI): 6 | def notify( 7 | self, 8 | fcm_token=None, 9 | notification_title=None, 10 | notification_body=None, 11 | notification_image=None, 12 | data_payload=None, 13 | topic_name=None, 14 | topic_condition=None, 15 | android_config=None, 16 | webpush_config=None, 17 | apns_config=None, 18 | fcm_options=None, 19 | dry_run=False, 20 | timeout=120, 21 | ): 22 | """ 23 | Send push notification to a single device 24 | 25 | Args: 26 | fcm_token (str, optional): FCM device registration ID 27 | notification_title (str, optional): Message title to display in the notification tray 28 | notification_body (str, optional): Message string to display in the notification tray 29 | notification_image (str, optional): Icon that appears next to the notification 30 | 31 | data_payload (dict, optional): Arbitrary key/value payload, which must be UTF-8 encoded 32 | 33 | topic_name (str, optional): Name of the topic to deliver messages to e.g. "weather". 34 | topic_condition (str, optional): Condition to broadcast a message to, e.g. "'foo' in topics && 'bar' in topics". 35 | 36 | android_config (dict, optional): Android specific options for messages - https://firebase.google.com/docs/reference/fcm/rest/v1/projects.messages#androidconfig 37 | apns_config (dict, optional): Apple Push Notification Service specific options - https://firebase.google.com/docs/reference/fcm/rest/v1/projects.messages#apnsconfig 38 | webpush_config (dict, optional): Webpush protocol options - https://firebase.google.com/docs/reference/fcm/rest/v1/projects.messages#webpushconfig 39 | 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 40 | 41 | timeout (int, optional): Set time limit for the request 42 | 43 | Returns: 44 | dict: name (str) - The identifier of the message sent, in the format of projects/*/messages/{message_id} 45 | 46 | Raises: 47 | FCMServerError: FCM is temporary not available 48 | AuthenticationError: error authenticating the sender account 49 | InvalidDataError: data passed to FCM was incorrecly structured 50 | FCMSenderIdMismatchError: the authenticated sender is different from the sender registered to the token 51 | FCMNotRegisteredError: device token is missing, not registered, or invalid 52 | """ 53 | payload = self.parse_payload( 54 | fcm_token=fcm_token, 55 | notification_title=notification_title, 56 | notification_body=notification_body, 57 | notification_image=notification_image, 58 | data_payload=data_payload, 59 | topic_name=topic_name, 60 | topic_condition=topic_condition, 61 | android_config=android_config, 62 | apns_config=apns_config, 63 | webpush_config=webpush_config, 64 | fcm_options=fcm_options, 65 | dry_run=dry_run, 66 | ) 67 | response = self.send_request(payload, timeout) 68 | return self.parse_response(response) 69 | 70 | def async_notify_multiple_devices(self, params_list=None, timeout=5): 71 | """ 72 | Sends push notification to multiple devices with personalized templates 73 | 74 | Args: 75 | params_list (list): list of parameters (the same as notify_multiple_devices) 76 | timeout (int, optional): set time limit for the request 77 | """ 78 | if params_list is None: 79 | params_list = [] 80 | 81 | return self.send_async_request(params_list=params_list, timeout=timeout) 82 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | aiohttp>=3.8.6 2 | cachetools==5.3.3 3 | google-auth==2.22.0 4 | pyasn1==0.6.0 5 | pyasn1-modules==0.4.0 6 | rsa==4.9 7 | requests>=2.6.0 8 | urllib3==1.26.19 9 | pytest-mock==3.14.0 10 | 11 | 12 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | """ 2 | pyfcm 3 | ======== 4 | 5 | Python client for FCM - Firebase Cloud Messaging (Android, iOS and Web) 6 | Project: https://github.com/olucurious/pyfcm 7 | """ 8 | 9 | import os 10 | import sys 11 | from setuptools import setup 12 | 13 | 14 | def read(fname): 15 | return open(os.path.join(os.path.dirname(__file__), fname)).read() 16 | 17 | 18 | install_requires = [ 19 | "requests", 20 | "urllib3>=1.26.0", 21 | "google-auth>=2.22.0", 22 | "aiohttp>=3.8.6", 23 | ] 24 | tests_require = ["pytest"] 25 | 26 | # We can't get the values using `from pyfcm import __meta__`, because this would import 27 | # the other modules too and raise an exception (dependencies are not installed at this point yet). 28 | meta = {} 29 | exec(read("pyfcm/__meta__.py"), meta) 30 | 31 | if sys.argv[-1] == "publish": 32 | os.system("rm dist/*.gz dist/*.whl") 33 | os.system("git tag -a %s -m 'v%s'" % (meta["__version__"], meta["__version__"])) 34 | os.system("python -m build") 35 | os.system("twine upload dist/*") 36 | os.system("git push --tags") 37 | sys.exit() 38 | 39 | setup( 40 | name=meta["__title__"], 41 | version=meta["__version__"], 42 | url=meta["__url__"], 43 | license=meta["__license__"], 44 | author=meta["__author__"], 45 | author_email=meta["__email__"], 46 | description=meta["__summary__"], 47 | long_description=read("README.md"), 48 | long_description_content_type="text/markdown", 49 | packages=["pyfcm"], 50 | install_requires=install_requires, 51 | tests_require=tests_require, 52 | test_suite="tests.get_tests", 53 | extras_require={"test": tests_require}, 54 | keywords="firebase fcm apns ios gcm android push notifications", 55 | classifiers=[ 56 | "Development Status :: 5 - Production/Stable", 57 | "Intended Audience :: Developers", 58 | "Operating System :: OS Independent", 59 | "Programming Language :: Python", 60 | "License :: OSI Approved :: MIT License", 61 | "Topic :: Communications", 62 | "Topic :: Internet", 63 | "Topic :: Software Development :: Libraries :: Python Modules", 64 | "Topic :: Utilities", 65 | "Programming Language :: Python", 66 | "Programming Language :: Python :: 2", 67 | "Programming Language :: Python :: 2.6", 68 | "Programming Language :: Python :: 2.7", 69 | "Programming Language :: Python :: 3", 70 | "Programming Language :: Python :: 3.2", 71 | "Programming Language :: Python :: 3.3", 72 | "Programming Language :: Python :: 3.4", 73 | "Programming Language :: Python :: 3.5", 74 | ], 75 | ) 76 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | from unittest.mock import AsyncMock 4 | 5 | import pytest 6 | 7 | from pyfcm import FCMNotification, errors 8 | from pyfcm.baseapi import BaseAPI 9 | 10 | 11 | @pytest.fixture(scope="module") 12 | def push_service(): 13 | service_account_file = os.getenv("GOOGLE_APPLICATION_CREDENTIALS", None) 14 | project_id = os.getenv("FCM_TEST_PROJECT_ID", None) 15 | assert ( 16 | service_account_file 17 | ), "Please set the service_account for testing according to CONTRIBUTING.rst" 18 | 19 | return FCMNotification( 20 | service_account_file=service_account_file, project_id=project_id 21 | ) 22 | 23 | 24 | @pytest.fixture 25 | def generate_response(mocker): 26 | response = {"test": "test"} 27 | mock_response = mocker.Mock() 28 | mock_response.json.return_value = response 29 | mock_response.status_code = 200 30 | mock_response.headers = {"Content-Length": "123"} 31 | mocker.patch("pyfcm.baseapi.BaseAPI.send_request", return_value=mock_response) 32 | 33 | 34 | @pytest.fixture 35 | def mock_aiohttp_session(mocker): 36 | # Define the fake response data 37 | response = {"test": "test"} 38 | 39 | # Create a mock response object 40 | mock_response = AsyncMock() 41 | mock_response.text = AsyncMock(return_value=json.dumps(response)) 42 | mock_response.status = 200 43 | mock_response.headers = {"Content-Length": "123"} 44 | 45 | mock_send = mocker.patch("pyfcm.async_fcm.send_request", new_callable=AsyncMock) 46 | mock_send.return_value = mock_response 47 | return mock_send 48 | 49 | 50 | @pytest.fixture(scope="module") 51 | def base_api(): 52 | service_account = os.getenv("GOOGLE_APPLICATION_CREDENTIALS", None) 53 | assert ( 54 | service_account 55 | ), "Please set the service_account for testing according to CONTRIBUTING.rst" 56 | 57 | return BaseAPI(api_key=service_account) 58 | -------------------------------------------------------------------------------- /tests/test_baseapi.py: -------------------------------------------------------------------------------- 1 | import os 2 | import json 3 | import pytest 4 | 5 | from pyfcm import errors 6 | from pyfcm.baseapi import BaseAPI 7 | 8 | 9 | @pytest.fixture(scope="module") 10 | def base_api(): 11 | service_account_file = os.getenv("GOOGLE_APPLICATION_CREDENTIALS", None) 12 | project_id = os.getenv("FCM_TEST_PROJECT_ID", None) 13 | assert ( 14 | project_id 15 | ), "Please set the environment variables for testing according to CONTRIBUTING.rst" 16 | 17 | return BaseAPI(service_account_file=service_account_file, project_id=project_id) 18 | 19 | 20 | def test_json_dumps(base_api): 21 | json_string = base_api.json_dumps([{"test": "Test"}, {"test2": "Test2"}]) 22 | 23 | assert json_string == b'[{"test":"Test"},{"test2":"Test2"}]' 24 | 25 | 26 | def test_parse_payload(base_api): 27 | json_string = base_api.parse_payload( 28 | fcm_token="test", 29 | notification_title="test", 30 | notification_body="test", 31 | notification_image="test", 32 | data_payload={"test": "test"}, 33 | topic_name="test", 34 | topic_condition="test", 35 | android_config={}, 36 | apns_config={}, 37 | webpush_config={}, 38 | fcm_options={}, 39 | dry_run=False, 40 | ) 41 | 42 | data = json.loads(json_string.decode("utf-8")) 43 | 44 | assert data["message"]["notification"] == { 45 | "body": "test", 46 | "title": "test", 47 | "image": "test", 48 | } 49 | 50 | assert data["message"]["data"] == {"test": "test"} 51 | -------------------------------------------------------------------------------- /tests/test_fcm.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from pyfcm import FCMNotification, errors 3 | 4 | 5 | def test_push_service_without_credentials(): 6 | try: 7 | FCMNotification(service_account_file="", project_id="", credentials=None) 8 | assert False, "Should raise AuthenticationError without credentials" 9 | except errors.AuthenticationError: 10 | pass 11 | 12 | 13 | def test_notify(push_service, generate_response): 14 | response = push_service.notify( 15 | fcm_token="Test", 16 | notification_body="Test", 17 | notification_title="Test", 18 | dry_run=True, 19 | ) 20 | 21 | assert isinstance(response, dict) 22 | --------------------------------------------------------------------------------