Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
0a7c0fb
make service file/project id optional arguments, and clean up variabl…
J-Priebe Sep 16, 2024
ee719ba
cleanup, doc update, test
J-Priebe Oct 1, 2024
f00ba0d
md
J-Priebe Oct 1, 2024
fb14cff
code block
J-Priebe Oct 1, 2024
a64e44e
Update CONTRIBUTING.rst
J-Priebe Oct 1, 2024
23de6d8
remove test file
J-Priebe Oct 1, 2024
21323b7
black
J-Priebe Oct 1, 2024
4f38a86
Merge pull request #362 from J-Priebe/creds-args
olucurious Oct 31, 2024
a04f862
Update __meta__.py
olucurious Feb 22, 2025
4a4e722
Fix type hint on FCMNotification.__init__
dmelo Mar 20, 2025
162d2ae
bump
dmelo Mar 20, 2025
140a685
Black fixing formatting
dmelo Mar 20, 2025
c8a47fc
Adjust type hint for other params on BaseAPI.__init__
dmelo Mar 20, 2025
4a5920c
feat: refresh access token when expired
Niccari Jul 26, 2025
fcd01c4
fix: format by black
Niccari Jul 26, 2025
df57b55
fix: format by black
Niccari Jul 26, 2025
6e51515
fix: flake8 warnings
Niccari Jul 26, 2025
23ec1b4
Merge pull request #371 from Niccari/feat/refresh_access_token_on_expire
olucurious Jul 26, 2025
10569b5
Merge pull request #372 from Niccari/fix/lint
olucurious Jul 26, 2025
80fec94
fix: defer credentials load, now unittest does not needs private key …
Niccari Jul 26, 2025
70ea278
fix: matches CONTRIBUTING.rst to current unittest state
Niccari Jul 26, 2025
52826ca
Merge pull request #373 from Niccari/feat/unittest_without_private_key
olucurious Jul 27, 2025
a8c249c
fix: requirements.txt, match master's urllib3 version (2.5.0)
Niccari Jul 28, 2025
e34c8e6
Merge branch 'master' into fix/deps_issue
Niccari Jul 28, 2025
7749345
Merge pull request #374 from Niccari/fix/deps_issue
olucurious Jul 28, 2025
0573560
bump version
olucurious Jul 28, 2025
fadf75d
Merge pull request #369 from dmelo/fix-typehint-init
olucurious Jul 28, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 1 addition & 6 deletions CONTRIBUTING.rst
Original file line number Diff line number Diff line change
Expand Up @@ -28,16 +28,11 @@ Some simple guidelines to follow when contributing code:
Tests
-----

Before commiting your changes, please run the tests. For running the tests you need a service account.

**Please do not use a service account, which is used in production!**
Before commiting your changes, please run the tests.

::

pip install . ".[test]"

export GOOGLE_APPLICATION_CREDENTIALS="service_account.json"

python -m pytest

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.
Expand Down
2 changes: 1 addition & 1 deletion pyfcm/__meta__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
__summary__ = "Python client for FCM - Firebase Cloud Messaging (Android, iOS and Web)"
__url__ = "https://github.com/olucurious/pyfcm"

__version__ = "2.0.7"
__version__ = "2.0.9"

__author__ = "Emmanuel Adegbite"
__email__ = "olucurious@gmail.com"
Expand Down
1 change: 0 additions & 1 deletion pyfcm/async_fcm.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,6 @@ async def send_request(end_point, headers, payload, timeout=5):
timeout = aiohttp.ClientTimeout(total=timeout)

async with aiohttp.ClientSession(headers=headers, timeout=timeout) as session:

async with session.post(end_point, data=payload) as res:
result = await res.text()
result = json.loads(result)
Expand Down
115 changes: 82 additions & 33 deletions pyfcm/baseapi.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
# from __future__ import annotations

import json
import os
import time
import threading

Expand All @@ -10,12 +9,12 @@
from urllib3 import Retry

from google.oauth2 import service_account
from google.oauth2.credentials import Credentials
import google.auth.transport.requests

from pyfcm.errors import (
AuthenticationError,
InvalidDataError,
FCMError,
FCMSenderIdMismatchError,
FCMServerError,
FCMNotRegisteredError,
Expand All @@ -25,15 +24,15 @@


class BaseAPI(object):
FCM_END_POINT = "https://fcm.googleapis.com/v1/projects"
FCM_END_POINT_BASE = "https://fcm.googleapis.com/v1/projects"

def __init__(
self,
service_account_file: str,
project_id: str,
credentials=None,
proxy_dict=None,
env=None,
service_account_file: str | None = None,
project_id: str | None = None,
credentials: Credentials | None = None,
proxy_dict: dict | None = None,
env: str | None = None,
json_encoder=None,
adapter=None,
):
Expand All @@ -48,25 +47,23 @@ def __init__(
json_encoder (BaseJSONEncoder): JSON encoder
adapter (BaseAdapter): adapter instance
"""
self.service_account_file = service_account_file
self.project_id = project_id
self.FCM_END_POINT = self.FCM_END_POINT + f"/{self.project_id}/messages:send"
self.FCM_REQ_PROXIES = None
self.custom_adapter = adapter
self.thread_local = threading.local()
self.credentials = credentials

if not service_account_file and not credentials:
if not (service_account_file or credentials):
raise AuthenticationError(
"Please provide a service account file path or credentials in the constructor"
)

self._service_account_file = service_account_file
self._fcm_end_point = None
self._project_id = project_id
self.credentials = credentials
self.custom_adapter = adapter
self.thread_local = threading.local()

if (
proxy_dict
and isinstance(proxy_dict, dict)
and (("http" in proxy_dict) or ("https" in proxy_dict))
):
self.FCM_REQ_PROXIES = proxy_dict
self.requests_session.proxies.update(proxy_dict)

if env == "app_engine":
Expand All @@ -79,6 +76,23 @@ def __init__(

self.json_encoder = json_encoder

@property
def fcm_end_point(self) -> str:
if self._fcm_end_point is not None:
return self._fcm_end_point
if self.credentials is None:
self._initialize_credentials()
# prefer the project ID scoped to the supplied credentials.
# If, for some reason, the credentials do not specify a project id,
# we'll check for an explicitly supplied one, and raise an error otherwise
project_id = getattr(self.credentials, "project_id", None) or self._project_id
if not project_id:
raise AuthenticationError(
"Please provide a project_id either explicitly or through Google credentials."
)
self._fcm_end_point = self.FCM_END_POINT_BASE + f"/{project_id}/messages:send"
return self._fcm_end_point

@property
def requests_session(self):
if getattr(self.thread_local, "requests_session", None) is None:
Expand All @@ -101,7 +115,7 @@ def requests_session(self):

def send_request(self, payload=None, timeout=None):
response = self.requests_session.post(
self.FCM_END_POINT, data=payload, timeout=timeout
self.fcm_end_point, data=payload, timeout=timeout
)
if (
"Retry-After" in response.headers
Expand All @@ -110,17 +124,21 @@ def send_request(self, payload=None, timeout=None):
sleep_time = int(response.headers["Retry-After"])
time.sleep(sleep_time)
return self.send_request(payload, timeout)

if self._is_access_token_expired(response):
self.thread_local.token_expiry = 0
return self.send_request(payload, timeout)

return response

def send_async_request(self, params_list, timeout):

import asyncio
from .async_fcm import fetch_tasks

payloads = [self.parse_payload(**params) for params in params_list]
responses = asyncio.new_event_loop().run_until_complete(
fetch_tasks(
end_point=self.FCM_END_POINT,
end_point=self.fcm_end_point,
headers=self.request_headers(),
payloads=payloads,
timeout=timeout,
Expand All @@ -129,25 +147,56 @@ def send_async_request(self, params_list, timeout):

return responses

def _is_access_token_expired(self, response):
"""
Check if the response indicates an expired access token

Args:
response: HTTP response object

Returns:
bool: True if access token is expired, False otherwise
"""
if response.status_code != 401:
return False

try:
error_response = response.json()
error_details = error_response.get("error", {}).get("details", [])
for detail in error_details:
if detail.get("reason") == "ACCESS_TOKEN_EXPIRED":
return True
except (ValueError, AttributeError):
pass

return False

def _initialize_credentials(self):
"""
Initialize credentials and FCM endpoint if not already initialized.
"""
if self.credentials is None:
self.credentials = service_account.Credentials.from_service_account_file(
self._service_account_file,
scopes=["https://www.googleapis.com/auth/firebase.messaging"],
)
self._service_account_file = None

def _get_access_token(self):
"""
Generates access token from credentials.
If token expires then new access token is generated.
Returns:
str: Access token
"""
if self.credentials is None:
self._initialize_credentials()

# get OAuth 2.0 access token
try:
if self.service_account_file:
credentials = service_account.Credentials.from_service_account_file(
self.service_account_file,
scopes=["https://www.googleapis.com/auth/firebase.messaging"],
)
else:
credentials = self.credentials
request = google.auth.transport.requests.Request()
credentials.refresh(request)
return credentials.token
self.credentials.refresh(request)
return self.credentials.token
except Exception as e:
raise InvalidDataError(e)

Expand Down Expand Up @@ -195,7 +244,6 @@ def parse_response(self, response):
FCMSenderIdMismatchError: the authenticated sender is different from the sender registered to the token
FCMNotRegisteredError: device token is missing, not registered, or invalid
"""

if response.status_code == 200:
if (
"content-length" in response.headers
Expand All @@ -221,10 +269,11 @@ def parse_response(self, response):
raise FCMNotRegisteredError("Token not registered")
else:
raise FCMServerError(
f"FCM server error: Unexpected status code {response.status_code}. The server might be temporarily unavailable."
f"FCM server error: Unexpected status code {response.status_code}. "
"The server might be temporarily unavailable."
)

def parse_payload(
def parse_payload( # noqa: C901
self,
fcm_token=None,
notification_title=None,
Expand Down
13 changes: 8 additions & 5 deletions pyfcm/fcm.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
from .baseapi import BaseAPI
from .errors import InvalidDataError


class FCMNotification(BaseAPI):
Expand Down Expand Up @@ -33,10 +32,14 @@ def notify(
topic_name (str, optional): Name of the topic to deliver messages to e.g. "weather".
topic_condition (str, optional): Condition to broadcast a message to, e.g. "'foo' in topics && 'bar' in topics".

android_config (dict, optional): Android specific options for messages - https://firebase.google.com/docs/reference/fcm/rest/v1/projects.messages#androidconfig
apns_config (dict, optional): Apple Push Notification Service specific options - https://firebase.google.com/docs/reference/fcm/rest/v1/projects.messages#apnsconfig
webpush_config (dict, optional): Webpush protocol options - https://firebase.google.com/docs/reference/fcm/rest/v1/projects.messages#webpushconfig
fcm_options (dict, optional): Platform independent options for features provided by the FCM SDKs - https://firebase.google.com/docs/reference/fcm/rest/v1/projects.messages#fcmoptions
android_config (dict, optional): Android specific options for messages -
https://firebase.google.com/docs/reference/fcm/rest/v1/projects.messages#androidconfig
apns_config (dict, optional): Apple Push Notification Service specific options -
https://firebase.google.com/docs/reference/fcm/rest/v1/projects.messages#apnsconfig
webpush_config (dict, optional): Webpush protocol options -
https://firebase.google.com/docs/reference/fcm/rest/v1/projects.messages#webpushconfig
fcm_options (dict, optional): Platform independent options for features provided by the FCM SDKs -
https://firebase.google.com/docs/reference/fcm/rest/v1/projects.messages#fcmoptions

timeout (int, optional): Set time limit for the request

Expand Down
14 changes: 6 additions & 8 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -1,11 +1,9 @@
aiohttp>=3.8.6
cachetools==5.3.3
google-auth==2.22.0
pyasn1==0.6.0
pyasn1-modules==0.4.0
rsa==4.9
cachetools==5.5.2
google-auth==2.24.0
pyasn1==0.6.1
pyasn1-modules==0.4.2
rsa==4.9.1
requests>=2.6.0
urllib3==2.5.0
pytest-mock==3.14.0


pytest-mock==3.14.1
30 changes: 13 additions & 17 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -1,24 +1,25 @@
import json
import os
from unittest.mock import AsyncMock

import pytest

from pyfcm import FCMNotification, errors
from pyfcm import FCMNotification
from pyfcm.baseapi import BaseAPI
from google.auth.credentials import Credentials


class DummyCredentials(Credentials):
def refresh():
pass

@property
def project_id(self):
return "test"


@pytest.fixture(scope="module")
def push_service():
service_account_file = os.getenv("GOOGLE_APPLICATION_CREDENTIALS", None)
project_id = os.getenv("FCM_TEST_PROJECT_ID", None)
assert (
service_account_file
), "Please set the service_account for testing according to CONTRIBUTING.rst"

return FCMNotification(
service_account_file=service_account_file, project_id=project_id
)
return FCMNotification(credentials=DummyCredentials())


@pytest.fixture
Expand Down Expand Up @@ -49,9 +50,4 @@ def mock_aiohttp_session(mocker):

@pytest.fixture(scope="module")
def base_api():
service_account = os.getenv("GOOGLE_APPLICATION_CREDENTIALS", None)
assert (
service_account
), "Please set the service_account for testing according to CONTRIBUTING.rst"

return BaseAPI(api_key=service_account)
return BaseAPI(credentials=DummyCredentials())
Loading