├── .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 | [](https://pypi.python.org/pypi/pyfcm/)
4 | [](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 |
--------------------------------------------------------------------------------