From 64e98d13af7fe07ebf41244433abc3d0844933d1 Mon Sep 17 00:00:00 2001 From: Yvonne Pan <103622026+yvonnep165@users.noreply.github.com> Date: Wed, 6 May 2026 16:44:14 -0400 Subject: [PATCH 1/9] add fid arg and token deprecate warning to Message class and add unit tests --- firebase_admin/_messaging_encoder.py | 19 +++++++++++++++---- tests/test_messaging.py | 27 +++++++++++++++++++++++---- 2 files changed, 38 insertions(+), 8 deletions(-) diff --git a/firebase_admin/_messaging_encoder.py b/firebase_admin/_messaging_encoder.py index 4c0c6daa..172ae9b8 100644 --- a/firebase_admin/_messaging_encoder.py +++ b/firebase_admin/_messaging_encoder.py @@ -19,6 +19,7 @@ import math import numbers import re +import warnings from firebase_admin import _messaging_utils @@ -37,20 +38,29 @@ class Message: webpush: An instance of ``messaging.WebpushConfig`` (optional). apns: An instance of ``messaging.ApnsConfig`` (optional). fcm_options: An instance of ``messaging.FCMOptions`` (optional). - token: The registration token of the device to which the message should be sent (optional). + fid: The Firebase installation ID of an FCM registered app instance to which the + message should be sent (optional) + token: Deprecated. Use ``fid`` instead. topic: Name of the FCM topic to which the message should be sent (optional). Topic name may contain the ``/topics/`` prefix. condition: The FCM condition to which the message should be sent (optional). """ def __init__(self, data=None, notification=None, android=None, webpush=None, apns=None, - fcm_options=None, token=None, topic=None, condition=None): + fcm_options=None, fid=None, token=None, topic=None, condition=None): + if token is not None: + warnings.warn( + "Deprecated. Use 'fid' instead.", + DeprecationWarning, + stacklevel=2 + ) self.data = data self.notification = notification self.android = android self.webpush = webpush self.apns = apns self.fcm_options = fcm_options + self.fid = fid self.token = token self.topic = topic self.condition = condition @@ -695,6 +705,7 @@ def default(self, o): # pylint: disable=method-hidden 'Message.condition', o.condition, non_empty=True), 'data': _Validators.check_string_dict('Message.data', o.data), 'notification': MessageEncoder.encode_notification(o.notification), + 'fid': _Validators.check_string('Message.fid', o.fid, non_empty=True), 'token': _Validators.check_string('Message.token', o.token, non_empty=True), 'topic': _Validators.check_string('Message.topic', o.topic, non_empty=True), 'webpush': MessageEncoder.encode_webpush(o.webpush), @@ -702,9 +713,9 @@ def default(self, o): # pylint: disable=method-hidden } result['topic'] = MessageEncoder.sanitize_topic_name(result.get('topic')) result = MessageEncoder.remove_null_values(result) - target_count = sum(t in result for t in ['token', 'topic', 'condition']) + target_count = sum(t in result for t in ['fid', 'token', 'topic', 'condition']) if target_count != 1: - raise ValueError('Exactly one of token, topic or condition must be specified.') + raise ValueError('Exactly one of fid, token, topic or condition must be specified.') return result @classmethod diff --git a/tests/test_messaging.py b/tests/test_messaging.py index b30790f1..1cc8a981 100644 --- a/tests/test_messaging.py +++ b/tests/test_messaging.py @@ -73,18 +73,22 @@ class TestMessageStr: messaging.Message(topic='topic', condition='condition'), messaging.Message(condition='condition', token='token'), messaging.Message(topic='topic', token='token', condition='condition'), + messaging.Message(fid='fid', token='token'), + messaging.Message(fid='fid', topic='topic'), + messaging.Message(fid='fid', condition='condition'), + messaging.Message(fid='fid', token='token', topic='topic'), ]) def test_invalid_target_message(self, msg): with pytest.raises(ValueError) as excinfo: str(msg) assert str( - excinfo.value) == 'Exactly one of token, topic or condition must be specified.' + excinfo.value) == 'Exactly one of fid, token, topic or condition must be specified.' def test_empty_message(self): assert str(messaging.Message(token='value')) == '{"token": "value"}' assert str(messaging.Message(topic='value')) == '{"topic": "value"}' - assert str(messaging.Message(condition='value') - ) == '{"condition": "value"}' + assert str(messaging.Message(condition='value')) == '{"condition": "value"}' + assert str(messaging.Message(fid='value')) == '{"fid": "value"}' def test_data_message(self): assert str(messaging.Message(topic='topic', data={}) @@ -128,11 +132,15 @@ class TestMessageEncoder: messaging.Message(topic='topic', condition='condition'), messaging.Message(condition='condition', token='token'), messaging.Message(topic='topic', token='token', condition='condition'), + messaging.Message(fid='fid', token='token'), + messaging.Message(fid='fid', topic='topic'), + messaging.Message(fid='fid', condition='condition'), + messaging.Message(fid='fid', token='token', topic='topic'), ]) def test_invalid_target_message(self, msg): with pytest.raises(ValueError) as excinfo: check_encoding(msg) - assert str(excinfo.value) == 'Exactly one of token, topic or condition must be specified.' + assert str(excinfo.value) == 'Exactly one of fid, token, topic or condition must be specified.' @pytest.mark.parametrize('target', NON_STRING_ARGS + ['']) def test_invalid_token(self, target): @@ -140,6 +148,12 @@ def test_invalid_token(self, target): check_encoding(messaging.Message(token=target)) assert str(excinfo.value) == 'Message.token must be a non-empty string.' + @pytest.mark.parametrize('target', NON_STRING_ARGS + ['']) + def test_invalid_fid(self, target): + with pytest.raises(ValueError) as excinfo: + check_encoding(messaging.Message(fid=target)) + assert str(excinfo.value) == 'Message.fid must be a non-empty string.' + @pytest.mark.parametrize('target', NON_STRING_ARGS + ['']) def test_invalid_topic(self, target): with pytest.raises(ValueError) as excinfo: @@ -159,9 +173,14 @@ def test_malformed_topic_name(self, topic): def test_empty_message(self): check_encoding(messaging.Message(token='value'), {'token': 'value'}) + check_encoding(messaging.Message(fid='value'), {'fid': 'value'}) check_encoding(messaging.Message(topic='value'), {'topic': 'value'}) check_encoding(messaging.Message(condition='value'), {'condition': 'value'}) + def test_token_deprecation_warning(self): + with pytest.deprecated_call(): + messaging.Message(token='value') + @pytest.mark.parametrize('data', NON_DICT_ARGS) def test_invalid_data_message(self, data): with pytest.raises(ValueError): From 92fa458b3f665f59015f21ae847b178b4d94ea7a Mon Sep 17 00:00:00 2001 From: Yvonne Pan <103622026+yvonnep165@users.noreply.github.com> Date: Thu, 7 May 2026 10:57:56 -0400 Subject: [PATCH 2/9] add fids to MulticastMessage and token deprecate warning and add unit tests --- firebase_admin/_messaging_encoder.py | 36 +++++++++++---- firebase_admin/messaging.py | 67 +++++++++++++++++++--------- tests/test_messaging.py | 51 +++++++++++++++++++++ 3 files changed, 126 insertions(+), 28 deletions(-) diff --git a/firebase_admin/_messaging_encoder.py b/firebase_admin/_messaging_encoder.py index 172ae9b8..6ca1b025 100644 --- a/firebase_admin/_messaging_encoder.py +++ b/firebase_admin/_messaging_encoder.py @@ -28,7 +28,7 @@ class Message: """A message that can be sent via Firebase Cloud Messaging. Contains payload information as well as recipient information. In particular, the message must - contain exactly one of token, topic or condition fields. + contain exactly one of fid, token, topic or condition fields. Args: data: A dictionary of data fields (optional). All keys and values in the dictionary must be @@ -70,10 +70,11 @@ def __str__(self): class MulticastMessage: - """A message that can be sent to multiple tokens via Firebase Cloud Messaging. + """A message that can be sent to multiple tokens or fids via Firebase Cloud Messaging. Args: - tokens: A list of registration tokens of targeted devices. + fids: A list of Firebase Installation IDs of targeted app instances (optional) + tokens: Deprecated. Use ``fids`` instead (optional). data: A dictionary of data fields (optional). All keys and values in the dictionary must be strings. notification: An instance of ``messaging.Notification`` (optional). @@ -82,12 +83,31 @@ class MulticastMessage: apns: An instance of ``messaging.ApnsConfig`` (optional). fcm_options: An instance of ``messaging.FCMOptions`` (optional). """ - def __init__(self, tokens, data=None, notification=None, android=None, webpush=None, apns=None, + def __init__(self, fids=None, tokens=None, data=None, notification=None, android=None, webpush=None, apns=None, fcm_options=None): - _Validators.check_string_list('MulticastMessage.tokens', tokens) - if len(tokens) > 500: - raise ValueError('MulticastMessage.tokens must not contain more than 500 tokens.') - self.tokens = tokens + if tokens is not None: + warnings.warn( + "Deprecated. Use 'fids' instead.", + DeprecationWarning, + stacklevel=2 + ) + + if (tokens is None and fids is None) or (tokens is not None and fids is not None): + raise ValueError("Must specify either 'tokens' or 'fids'.") + + if tokens is not None: + _Validators.check_string_list('MulticastMessage.tokens', tokens) + if len(tokens) > 500: + raise ValueError('MulticastMessage.tokens must not contain more than 500 tokens.') + self.tokens = tokens + self.fids = None + else: + _Validators.check_string_list('MulticastMessage.fids', fids) + if len(fids) > 500: + raise ValueError('MulticastMessage.fids must not contain more than 500 fids.') + self.fids = fids + self.tokens = None + self.data = data self.notification = notification self.android = android diff --git a/firebase_admin/messaging.py b/firebase_admin/messaging.py index 74904443..753c06c9 100644 --- a/firebase_admin/messaging.py +++ b/firebase_admin/messaging.py @@ -20,6 +20,7 @@ import json import asyncio import logging +import warnings import requests import httpx @@ -177,7 +178,7 @@ async def send_each_for_multicast_async( dry_run: bool = False, app: Optional[App] = None ) -> BatchResponse: - """Sends the given mutlicast message to each token asynchronously via Firebase Cloud Messaging + """Sends the given multicast message to each token or fid asynchronously via Firebase Cloud Messaging (FCM). If the ``dry_run`` mode is enabled, the message will not be actually delivered to the @@ -197,19 +198,32 @@ async def send_each_for_multicast_async( """ if not isinstance(multicast_message, MulticastMessage): raise ValueError('Message must be an instance of messaging.MulticastMessage class.') - messages = [Message( - data=multicast_message.data, - notification=multicast_message.notification, - android=multicast_message.android, - webpush=multicast_message.webpush, - apns=multicast_message.apns, - fcm_options=multicast_message.fcm_options, - token=token - ) for token in multicast_message.tokens] + if multicast_message.tokens is not None: + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + messages = [Message( + data=multicast_message.data, + notification=multicast_message.notification, + android=multicast_message.android, + webpush=multicast_message.webpush, + apns=multicast_message.apns, + fcm_options=multicast_message.fcm_options, + token=token + ) for token in multicast_message.tokens] + else: + messages = [Message( + data=multicast_message.data, + notification=multicast_message.notification, + android=multicast_message.android, + webpush=multicast_message.webpush, + apns=multicast_message.apns, + fcm_options=multicast_message.fcm_options, + fid=fid + ) for fid in multicast_message.fids] return await _get_messaging_service(app).send_each_async(messages, dry_run) def send_each_for_multicast(multicast_message, dry_run=False, app=None): - """Sends the given mutlicast message to each token via Firebase Cloud Messaging (FCM). + """Sends the given multicast message to each token or fid via Firebase Cloud Messaging (FCM). If the ``dry_run`` mode is enabled, the message will not be actually delivered to the recipients. Instead, FCM performs all the usual validations and emulates the send operation. @@ -228,15 +242,28 @@ def send_each_for_multicast(multicast_message, dry_run=False, app=None): """ if not isinstance(multicast_message, MulticastMessage): raise ValueError('Message must be an instance of messaging.MulticastMessage class.') - messages = [Message( - data=multicast_message.data, - notification=multicast_message.notification, - android=multicast_message.android, - webpush=multicast_message.webpush, - apns=multicast_message.apns, - fcm_options=multicast_message.fcm_options, - token=token - ) for token in multicast_message.tokens] + if multicast_message.tokens is not None: + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + messages = [Message( + data=multicast_message.data, + notification=multicast_message.notification, + android=multicast_message.android, + webpush=multicast_message.webpush, + apns=multicast_message.apns, + fcm_options=multicast_message.fcm_options, + token=token + ) for token in multicast_message.tokens] + else: + messages = [Message( + data=multicast_message.data, + notification=multicast_message.notification, + android=multicast_message.android, + webpush=multicast_message.webpush, + apns=multicast_message.apns, + fcm_options=multicast_message.fcm_options, + fid=fid + ) for fid in multicast_message.fids] return _get_messaging_service(app).send_each(messages, dry_run) def subscribe_to_topic(tokens, topic, app=None): diff --git a/tests/test_messaging.py b/tests/test_messaging.py index 1cc8a981..8d18f041 100644 --- a/tests/test_messaging.py +++ b/tests/test_messaging.py @@ -99,6 +99,15 @@ def test_data_message(self): class TestMulticastMessage: + def test_invalid_targets(self): + with pytest.raises(ValueError) as excinfo: + messaging.MulticastMessage() + assert str(excinfo.value) == "Must specify either 'tokens' or 'fids'." + + with pytest.raises(ValueError) as excinfo: + messaging.MulticastMessage(tokens=['token'], fids=['fid']) + assert str(excinfo.value) == "Must specify either 'tokens' or 'fids'." + @pytest.mark.parametrize('tokens', NON_LIST_ARGS) def test_invalid_tokens_type(self, tokens): with pytest.raises(ValueError) as excinfo: @@ -123,6 +132,34 @@ def test_tokens_type(self): message = messaging.MulticastMessage(tokens=['token' for _ in range(0, 500)]) assert len(message.tokens) == 500 + @pytest.mark.parametrize('fids', NON_LIST_ARGS) + def test_invalid_fids_type(self, fids): + with pytest.raises(ValueError) as excinfo: + messaging.MulticastMessage(fids=fids) + if isinstance(fids, list): + expected = 'MulticastMessage.fids must not contain non-string values.' + assert str(excinfo.value) == expected + else: + expected = 'MulticastMessage.fids must be a list of strings.' + assert str(excinfo.value) == expected + + def test_fids_over_500(self): + with pytest.raises(ValueError) as excinfo: + messaging.MulticastMessage(fids=['fid' for _ in range(0, 501)]) + expected = 'MulticastMessage.fids must not contain more than 500 fids.' + assert str(excinfo.value) == expected + + def test_fids_type(self): + message = messaging.MulticastMessage(fids=['fid']) + assert len(message.fids) == 1 + + message = messaging.MulticastMessage(fids=['fid' for _ in range(0, 500)]) + assert len(message.fids) == 500 + + def test_tokens_deprecation_warning(self): + with pytest.deprecated_call(): + messaging.MulticastMessage(tokens=['token']) + class TestMessageEncoder: @@ -2231,6 +2268,20 @@ def test_send_each_for_multicast(self): assert all(r.success for r in batch_response.responses) assert not any(r.exception for r in batch_response.responses) + def test_send_each_for_multicast_fids(self): + payload1 = json.dumps({'name': 'message-id1'}) + payload2 = json.dumps({'name': 'message-id2'}) + _ = self._instrument_messaging_service( + response_dict={'foo1': [200, payload1], 'foo2': [200, payload2]}) + msg = messaging.MulticastMessage(fids=['foo1', 'foo2']) + batch_response = messaging.send_each_for_multicast(msg, dry_run=True) + assert batch_response.success_count == 2 + assert batch_response.failure_count == 0 + assert len(batch_response.responses) == 2 + assert [r.message_id for r in batch_response.responses] == ['message-id1', 'message-id2'] + assert all(r.success for r in batch_response.responses) + assert not any(r.exception for r in batch_response.responses) + @pytest.mark.parametrize('status', HTTP_ERROR_CODES) def test_send_each_for_multicast_detailed_error(self, status): success_payload = json.dumps({'name': 'message-id'}) From f384d6e076cf979a47d1b422c41624fc8281e658 Mon Sep 17 00:00:00 2001 From: Yvonne Pan <103622026+yvonnep165@users.noreply.github.com> Date: Thu, 7 May 2026 13:28:23 -0400 Subject: [PATCH 3/9] Fix lint formatting error --- firebase_admin/_messaging_encoder.py | 5 +++-- firebase_admin/messaging.py | 4 ++-- tests/test_messaging.py | 3 ++- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/firebase_admin/_messaging_encoder.py b/firebase_admin/_messaging_encoder.py index 6ca1b025..477adcc7 100644 --- a/firebase_admin/_messaging_encoder.py +++ b/firebase_admin/_messaging_encoder.py @@ -83,8 +83,9 @@ class MulticastMessage: apns: An instance of ``messaging.ApnsConfig`` (optional). fcm_options: An instance of ``messaging.FCMOptions`` (optional). """ - def __init__(self, fids=None, tokens=None, data=None, notification=None, android=None, webpush=None, apns=None, - fcm_options=None): + def __init__( + self, fids=None, tokens=None, data=None, notification=None, android=None, + webpush=None, apns=None, fcm_options=None): if tokens is not None: warnings.warn( "Deprecated. Use 'fids' instead.", diff --git a/firebase_admin/messaging.py b/firebase_admin/messaging.py index 753c06c9..879368db 100644 --- a/firebase_admin/messaging.py +++ b/firebase_admin/messaging.py @@ -178,8 +178,8 @@ async def send_each_for_multicast_async( dry_run: bool = False, app: Optional[App] = None ) -> BatchResponse: - """Sends the given multicast message to each token or fid asynchronously via Firebase Cloud Messaging - (FCM). + """Sends the given multicast message to each token or fid asynchronously via + Firebase Cloud Messaging (FCM). If the ``dry_run`` mode is enabled, the message will not be actually delivered to the recipients. Instead, FCM performs all the usual validations and emulates the send operation. diff --git a/tests/test_messaging.py b/tests/test_messaging.py index 8d18f041..23ab7cbc 100644 --- a/tests/test_messaging.py +++ b/tests/test_messaging.py @@ -177,7 +177,8 @@ class TestMessageEncoder: def test_invalid_target_message(self, msg): with pytest.raises(ValueError) as excinfo: check_encoding(msg) - assert str(excinfo.value) == 'Exactly one of fid, token, topic or condition must be specified.' + expected = 'Exactly one of fid, token, topic or condition must be specified.' + assert str(excinfo.value) == expected @pytest.mark.parametrize('target', NON_STRING_ARGS + ['']) def test_invalid_token(self, target): From 0509f0a207360dc82da25a0b8f2600747fe3a202 Mon Sep 17 00:00:00 2001 From: Yvonne Pan <103622026+yvonnep165@users.noreply.github.com> Date: Thu, 7 May 2026 13:43:50 -0400 Subject: [PATCH 4/9] Position `tokens` first to guarantee backward compatibility for legacy positional arguments and add a unit test for it --- firebase_admin/_messaging_encoder.py | 2 +- tests/test_messaging.py | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/firebase_admin/_messaging_encoder.py b/firebase_admin/_messaging_encoder.py index 477adcc7..77a37c0a 100644 --- a/firebase_admin/_messaging_encoder.py +++ b/firebase_admin/_messaging_encoder.py @@ -84,7 +84,7 @@ class MulticastMessage: fcm_options: An instance of ``messaging.FCMOptions`` (optional). """ def __init__( - self, fids=None, tokens=None, data=None, notification=None, android=None, + self, tokens=None, fids=None, data=None, notification=None, android=None, webpush=None, apns=None, fcm_options=None): if tokens is not None: warnings.warn( diff --git a/tests/test_messaging.py b/tests/test_messaging.py index 23ab7cbc..85ae84e0 100644 --- a/tests/test_messaging.py +++ b/tests/test_messaging.py @@ -160,6 +160,10 @@ def test_tokens_deprecation_warning(self): with pytest.deprecated_call(): messaging.MulticastMessage(tokens=['token']) + def test_tokens_deprecation_warning_positional(self): + with pytest.deprecated_call(): + messaging.MulticastMessage(['token']) + class TestMessageEncoder: From 5c68f2aba7550e8438b2719a6d54642ac8ba53fb Mon Sep 17 00:00:00 2001 From: Yvonne Pan <103622026+yvonnep165@users.noreply.github.com> Date: Thu, 7 May 2026 13:51:59 -0400 Subject: [PATCH 5/9] Extract multicast-to-message-list conversion logic into a private helper function --- firebase_admin/messaging.py | 76 ++++++++++++++----------------------- 1 file changed, 28 insertions(+), 48 deletions(-) diff --git a/firebase_admin/messaging.py b/firebase_admin/messaging.py index 879368db..52218ef7 100644 --- a/firebase_admin/messaging.py +++ b/firebase_admin/messaging.py @@ -173,6 +173,32 @@ async def send_each_async( """ return await _get_messaging_service(app).send_each_async(messages, dry_run) +def _get_messages_from_multicast(multicast_message: MulticastMessage) -> List[Message]: + if not isinstance(multicast_message, MulticastMessage): + raise ValueError('Message must be an instance of messaging.MulticastMessage class.') + if multicast_message.tokens is not None: + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + return [Message( + data=multicast_message.data, + notification=multicast_message.notification, + android=multicast_message.android, + webpush=multicast_message.webpush, + apns=multicast_message.apns, + fcm_options=multicast_message.fcm_options, + token=token + ) for token in multicast_message.tokens] + + return [Message( + data=multicast_message.data, + notification=multicast_message.notification, + android=multicast_message.android, + webpush=multicast_message.webpush, + apns=multicast_message.apns, + fcm_options=multicast_message.fcm_options, + fid=fid + ) for fid in multicast_message.fids] + async def send_each_for_multicast_async( multicast_message: MulticastMessage, dry_run: bool = False, @@ -196,30 +222,7 @@ async def send_each_for_multicast_async( FirebaseError: If an error occurs while sending the message to the FCM service. ValueError: If the input arguments are invalid. """ - if not isinstance(multicast_message, MulticastMessage): - raise ValueError('Message must be an instance of messaging.MulticastMessage class.') - if multicast_message.tokens is not None: - with warnings.catch_warnings(): - warnings.simplefilter("ignore", DeprecationWarning) - messages = [Message( - data=multicast_message.data, - notification=multicast_message.notification, - android=multicast_message.android, - webpush=multicast_message.webpush, - apns=multicast_message.apns, - fcm_options=multicast_message.fcm_options, - token=token - ) for token in multicast_message.tokens] - else: - messages = [Message( - data=multicast_message.data, - notification=multicast_message.notification, - android=multicast_message.android, - webpush=multicast_message.webpush, - apns=multicast_message.apns, - fcm_options=multicast_message.fcm_options, - fid=fid - ) for fid in multicast_message.fids] + messages = _get_messages_from_multicast(multicast_message) return await _get_messaging_service(app).send_each_async(messages, dry_run) def send_each_for_multicast(multicast_message, dry_run=False, app=None): @@ -240,30 +243,7 @@ def send_each_for_multicast(multicast_message, dry_run=False, app=None): FirebaseError: If an error occurs while sending the message to the FCM service. ValueError: If the input arguments are invalid. """ - if not isinstance(multicast_message, MulticastMessage): - raise ValueError('Message must be an instance of messaging.MulticastMessage class.') - if multicast_message.tokens is not None: - with warnings.catch_warnings(): - warnings.simplefilter("ignore", DeprecationWarning) - messages = [Message( - data=multicast_message.data, - notification=multicast_message.notification, - android=multicast_message.android, - webpush=multicast_message.webpush, - apns=multicast_message.apns, - fcm_options=multicast_message.fcm_options, - token=token - ) for token in multicast_message.tokens] - else: - messages = [Message( - data=multicast_message.data, - notification=multicast_message.notification, - android=multicast_message.android, - webpush=multicast_message.webpush, - apns=multicast_message.apns, - fcm_options=multicast_message.fcm_options, - fid=fid - ) for fid in multicast_message.fids] + messages = _get_messages_from_multicast(multicast_message) return _get_messaging_service(app).send_each(messages, dry_run) def subscribe_to_topic(tokens, topic, app=None): From f258b3f5ff6323e4a482783bbda6b5f1ca9c6cf0 Mon Sep 17 00:00:00 2001 From: Yvonne Pan <103622026+yvonnep165@users.noreply.github.com> Date: Thu, 7 May 2026 13:55:26 -0400 Subject: [PATCH 6/9] Add docstring for the helper function --- firebase_admin/messaging.py | 1 + 1 file changed, 1 insertion(+) diff --git a/firebase_admin/messaging.py b/firebase_admin/messaging.py index 52218ef7..e326fd36 100644 --- a/firebase_admin/messaging.py +++ b/firebase_admin/messaging.py @@ -174,6 +174,7 @@ async def send_each_async( return await _get_messaging_service(app).send_each_async(messages, dry_run) def _get_messages_from_multicast(multicast_message: MulticastMessage) -> List[Message]: + """Extracts individual Message objects from a MulticastMessage.""" if not isinstance(multicast_message, MulticastMessage): raise ValueError('Message must be an instance of messaging.MulticastMessage class.') if multicast_message.tokens is not None: From 1831f6a0e7bcecba52d2d731f9ea7bb9326d3f60 Mon Sep 17 00:00:00 2001 From: Yvonne Pan <103622026+yvonnep165@users.noreply.github.com> Date: Thu, 7 May 2026 17:12:11 -0400 Subject: [PATCH 7/9] Add unit tests for the async function, change deprecate message and address other review comments --- firebase_admin/_messaging_encoder.py | 10 +++--- tests/test_messaging.py | 51 ++++++++++++++++++++++++++-- 2 files changed, 53 insertions(+), 8 deletions(-) diff --git a/firebase_admin/_messaging_encoder.py b/firebase_admin/_messaging_encoder.py index 77a37c0a..34ecea2d 100644 --- a/firebase_admin/_messaging_encoder.py +++ b/firebase_admin/_messaging_encoder.py @@ -39,7 +39,7 @@ class Message: apns: An instance of ``messaging.ApnsConfig`` (optional). fcm_options: An instance of ``messaging.FCMOptions`` (optional). fid: The Firebase installation ID of an FCM registered app instance to which the - message should be sent (optional) + message should be sent (optional). token: Deprecated. Use ``fid`` instead. topic: Name of the FCM topic to which the message should be sent (optional). Topic name may contain the ``/topics/`` prefix. @@ -47,10 +47,10 @@ class Message: """ def __init__(self, data=None, notification=None, android=None, webpush=None, apns=None, - fcm_options=None, fid=None, token=None, topic=None, condition=None): + fcm_options=None, token=None, topic=None, condition=None, fid=None): if token is not None: warnings.warn( - "Deprecated. Use 'fid' instead.", + "Message.token is deprecated. Use fid instead.", DeprecationWarning, stacklevel=2 ) @@ -73,7 +73,7 @@ class MulticastMessage: """A message that can be sent to multiple tokens or fids via Firebase Cloud Messaging. Args: - fids: A list of Firebase Installation IDs of targeted app instances (optional) + fids: A list of Firebase Installation IDs of targeted app instances (optional). tokens: Deprecated. Use ``fids`` instead (optional). data: A dictionary of data fields (optional). All keys and values in the dictionary must be strings. @@ -88,7 +88,7 @@ def __init__( webpush=None, apns=None, fcm_options=None): if tokens is not None: warnings.warn( - "Deprecated. Use 'fids' instead.", + "MulticastMessage.tokens is deprecated. Use fids instead.", DeprecationWarning, stacklevel=2 ) diff --git a/tests/test_messaging.py b/tests/test_messaging.py index 85ae84e0..f27322a7 100644 --- a/tests/test_messaging.py +++ b/tests/test_messaging.py @@ -157,11 +157,13 @@ def test_fids_type(self): assert len(message.fids) == 500 def test_tokens_deprecation_warning(self): - with pytest.deprecated_call(): + msg = 'MulticastMessage.tokens is deprecated. Use fids instead.' + with pytest.warns(DeprecationWarning, match=msg): messaging.MulticastMessage(tokens=['token']) def test_tokens_deprecation_warning_positional(self): - with pytest.deprecated_call(): + msg = 'MulticastMessage.tokens is deprecated. Use fids instead.' + with pytest.warns(DeprecationWarning, match=msg): messaging.MulticastMessage(['token']) @@ -220,7 +222,8 @@ def test_empty_message(self): check_encoding(messaging.Message(condition='value'), {'condition': 'value'}) def test_token_deprecation_warning(self): - with pytest.deprecated_call(): + msg = 'Message.token is deprecated. Use fid instead.' + with pytest.warns(DeprecationWarning, match=msg): messaging.Message(token='value') @pytest.mark.parametrize('data', NON_DICT_ARGS) @@ -2287,6 +2290,48 @@ def test_send_each_for_multicast_fids(self): assert all(r.success for r in batch_response.responses) assert not any(r.exception for r in batch_response.responses) + @respx.mock + @pytest.mark.asyncio + async def test_send_each_for_multicast_async(self): + responses = [ + respx.MockResponse(200, http_version='HTTP/2', json={'name': 'message-id1'}), + respx.MockResponse(200, http_version='HTTP/2', json={'name': 'message-id2'}), + ] + msg = messaging.MulticastMessage(tokens=['foo1', 'foo2']) + route = respx.request( + method='POST', + url='https://fcm.googleapis.com/v1/projects/explicit-project-id/messages:send' + ) + route.side_effect = responses + batch_response = await messaging.send_each_for_multicast_async(msg, dry_run=True) + assert batch_response.success_count == 2 + assert batch_response.failure_count == 0 + assert len(batch_response.responses) == 2 + assert [r.message_id for r in batch_response.responses] == ['message-id1', 'message-id2'] + assert all(r.success for r in batch_response.responses) + assert not any(r.exception for r in batch_response.responses) + + @respx.mock + @pytest.mark.asyncio + async def test_send_each_for_multicast_async_fids(self): + responses = [ + respx.MockResponse(200, http_version='HTTP/2', json={'name': 'message-id1'}), + respx.MockResponse(200, http_version='HTTP/2', json={'name': 'message-id2'}), + ] + msg = messaging.MulticastMessage(fids=['foo1', 'foo2']) + route = respx.request( + method='POST', + url='https://fcm.googleapis.com/v1/projects/explicit-project-id/messages:send' + ) + route.side_effect = responses + batch_response = await messaging.send_each_for_multicast_async(msg, dry_run=True) + assert batch_response.success_count == 2 + assert batch_response.failure_count == 0 + assert len(batch_response.responses) == 2 + assert [r.message_id for r in batch_response.responses] == ['message-id1', 'message-id2'] + assert all(r.success for r in batch_response.responses) + assert not any(r.exception for r in batch_response.responses) + @pytest.mark.parametrize('status', HTTP_ERROR_CODES) def test_send_each_for_multicast_detailed_error(self, status): success_payload = json.dumps({'name': 'message-id'}) From 73f4b47b53ba970bf2d6af40fba958ef7c029d79 Mon Sep 17 00:00:00 2001 From: Yvonne Pan <103622026+yvonnep165@users.noreply.github.com> Date: Fri, 8 May 2026 15:27:56 -0400 Subject: [PATCH 8/9] Fix error messages and MulticastMessage constructor and add mix types of fids and tokens --- firebase_admin/_messaging_encoder.py | 33 ++++++++++---------- firebase_admin/messaging.py | 29 ++++++++++-------- tests/test_messaging.py | 45 +++++++++++++++++++++------- 3 files changed, 69 insertions(+), 38 deletions(-) diff --git a/firebase_admin/_messaging_encoder.py b/firebase_admin/_messaging_encoder.py index 34ecea2d..b7c69107 100644 --- a/firebase_admin/_messaging_encoder.py +++ b/firebase_admin/_messaging_encoder.py @@ -50,7 +50,7 @@ def __init__(self, data=None, notification=None, android=None, webpush=None, apn fcm_options=None, token=None, topic=None, condition=None, fid=None): if token is not None: warnings.warn( - "Message.token is deprecated. Use fid instead.", + "Message.token is deprecated. Use Message.fid instead.", DeprecationWarning, stacklevel=2 ) @@ -73,7 +73,6 @@ class MulticastMessage: """A message that can be sent to multiple tokens or fids via Firebase Cloud Messaging. Args: - fids: A list of Firebase Installation IDs of targeted app instances (optional). tokens: Deprecated. Use ``fids`` instead (optional). data: A dictionary of data fields (optional). All keys and values in the dictionary must be strings. @@ -82,33 +81,35 @@ class MulticastMessage: webpush: An instance of ``messaging.WebpushConfig`` (optional). apns: An instance of ``messaging.ApnsConfig`` (optional). fcm_options: An instance of ``messaging.FCMOptions`` (optional). + fids: A list of Firebase Installation IDs of targeted app instances (optional). """ def __init__( - self, tokens=None, fids=None, data=None, notification=None, android=None, - webpush=None, apns=None, fcm_options=None): + self, tokens=None, data=None, notification=None, android=None, + webpush=None, apns=None, fcm_options=None, fids=None): if tokens is not None: warnings.warn( - "MulticastMessage.tokens is deprecated. Use fids instead.", + "MulticastMessage.tokens is deprecated. Use MulticastMessage.fids instead.", DeprecationWarning, stacklevel=2 ) - if (tokens is None and fids is None) or (tokens is not None and fids is not None): - raise ValueError("Must specify either 'tokens' or 'fids'.") + if tokens is None and fids is None: + raise ValueError( + "Must specify at least one of MulticastMessage.tokens or MulticastMessage.fids.") if tokens is not None: _Validators.check_string_list('MulticastMessage.tokens', tokens) - if len(tokens) > 500: - raise ValueError('MulticastMessage.tokens must not contain more than 500 tokens.') - self.tokens = tokens - self.fids = None - else: + if fids is not None: _Validators.check_string_list('MulticastMessage.fids', fids) - if len(fids) > 500: - raise ValueError('MulticastMessage.fids must not contain more than 500 fids.') - self.fids = fids - self.tokens = None + tokens_len = len(tokens) if tokens is not None else 0 + fids_len = len(fids) if fids is not None else 0 + if tokens_len + fids_len > 500: + raise ValueError( + 'Total number of tokens and fids must not exceed 500.') + + self.tokens = tokens + self.fids = fids self.data = data self.notification = notification self.android = android diff --git a/firebase_admin/messaging.py b/firebase_admin/messaging.py index e326fd36..93ecfda1 100644 --- a/firebase_admin/messaging.py +++ b/firebase_admin/messaging.py @@ -177,10 +177,12 @@ def _get_messages_from_multicast(multicast_message: MulticastMessage) -> List[Me """Extracts individual Message objects from a MulticastMessage.""" if not isinstance(multicast_message, MulticastMessage): raise ValueError('Message must be an instance of messaging.MulticastMessage class.') + + messages = [] if multicast_message.tokens is not None: with warnings.catch_warnings(): warnings.simplefilter("ignore", DeprecationWarning) - return [Message( + messages.extend([Message( data=multicast_message.data, notification=multicast_message.notification, android=multicast_message.android, @@ -188,17 +190,20 @@ def _get_messages_from_multicast(multicast_message: MulticastMessage) -> List[Me apns=multicast_message.apns, fcm_options=multicast_message.fcm_options, token=token - ) for token in multicast_message.tokens] - - return [Message( - data=multicast_message.data, - notification=multicast_message.notification, - android=multicast_message.android, - webpush=multicast_message.webpush, - apns=multicast_message.apns, - fcm_options=multicast_message.fcm_options, - fid=fid - ) for fid in multicast_message.fids] + ) for token in multicast_message.tokens]) + + if multicast_message.fids is not None: + messages.extend([Message( + data=multicast_message.data, + notification=multicast_message.notification, + android=multicast_message.android, + webpush=multicast_message.webpush, + apns=multicast_message.apns, + fcm_options=multicast_message.fcm_options, + fid=fid + ) for fid in multicast_message.fids]) + + return messages async def send_each_for_multicast_async( multicast_message: MulticastMessage, diff --git a/tests/test_messaging.py b/tests/test_messaging.py index f27322a7..749e5311 100644 --- a/tests/test_messaging.py +++ b/tests/test_messaging.py @@ -102,11 +102,8 @@ class TestMulticastMessage: def test_invalid_targets(self): with pytest.raises(ValueError) as excinfo: messaging.MulticastMessage() - assert str(excinfo.value) == "Must specify either 'tokens' or 'fids'." - - with pytest.raises(ValueError) as excinfo: - messaging.MulticastMessage(tokens=['token'], fids=['fid']) - assert str(excinfo.value) == "Must specify either 'tokens' or 'fids'." + expected = "Must specify at least one of MulticastMessage.tokens or MulticastMessage.fids." + assert str(excinfo.value) == expected @pytest.mark.parametrize('tokens', NON_LIST_ARGS) def test_invalid_tokens_type(self, tokens): @@ -122,7 +119,7 @@ def test_invalid_tokens_type(self, tokens): def test_tokens_over_500(self): with pytest.raises(ValueError) as excinfo: messaging.MulticastMessage(tokens=['token' for _ in range(0, 501)]) - expected = 'MulticastMessage.tokens must not contain more than 500 tokens.' + expected = 'Total number of tokens and fids must not exceed 500.' assert str(excinfo.value) == expected def test_tokens_type(self): @@ -146,7 +143,7 @@ def test_invalid_fids_type(self, fids): def test_fids_over_500(self): with pytest.raises(ValueError) as excinfo: messaging.MulticastMessage(fids=['fid' for _ in range(0, 501)]) - expected = 'MulticastMessage.fids must not contain more than 500 fids.' + expected = 'Total number of tokens and fids must not exceed 500.' assert str(excinfo.value) == expected def test_fids_type(self): @@ -156,13 +153,27 @@ def test_fids_type(self): message = messaging.MulticastMessage(fids=['fid' for _ in range(0, 500)]) assert len(message.fids) == 500 + def test_combined_over_500(self): + with pytest.raises(ValueError) as excinfo: + messaging.MulticastMessage( + tokens=['token' for _ in range(0, 250)], + fids=['fid' for _ in range(0, 251)] + ) + expected = 'Total number of tokens and fids must not exceed 500.' + assert str(excinfo.value) == expected + + def test_mixed_targets(self): + message = messaging.MulticastMessage(tokens=['token'], fids=['fid']) + assert len(message.tokens) == 1 + assert len(message.fids) == 1 + def test_tokens_deprecation_warning(self): - msg = 'MulticastMessage.tokens is deprecated. Use fids instead.' + msg = 'MulticastMessage.tokens is deprecated. Use MulticastMessage.fids instead.' with pytest.warns(DeprecationWarning, match=msg): messaging.MulticastMessage(tokens=['token']) def test_tokens_deprecation_warning_positional(self): - msg = 'MulticastMessage.tokens is deprecated. Use fids instead.' + msg = 'MulticastMessage.tokens is deprecated. Use MulticastMessage.fids instead.' with pytest.warns(DeprecationWarning, match=msg): messaging.MulticastMessage(['token']) @@ -222,7 +233,7 @@ def test_empty_message(self): check_encoding(messaging.Message(condition='value'), {'condition': 'value'}) def test_token_deprecation_warning(self): - msg = 'Message.token is deprecated. Use fid instead.' + msg = 'Message.token is deprecated. Use Message.fid instead.' with pytest.warns(DeprecationWarning, match=msg): messaging.Message(token='value') @@ -2290,6 +2301,20 @@ def test_send_each_for_multicast_fids(self): assert all(r.success for r in batch_response.responses) assert not any(r.exception for r in batch_response.responses) + def test_send_each_for_multicast_mixed(self): + payload1 = json.dumps({'name': 'message-id1'}) + payload2 = json.dumps({'name': 'message-id2'}) + _ = self._instrument_messaging_service( + response_dict={'foo1': [200, payload1], 'foo2': [200, payload2]}) + msg = messaging.MulticastMessage(tokens=['foo1'], fids=['foo2']) + batch_response = messaging.send_each_for_multicast(msg, dry_run=True) + assert batch_response.success_count == 2 + assert batch_response.failure_count == 0 + assert len(batch_response.responses) == 2 + assert [r.message_id for r in batch_response.responses] == ['message-id1', 'message-id2'] + assert all(r.success for r in batch_response.responses) + assert not any(r.exception for r in batch_response.responses) + @respx.mock @pytest.mark.asyncio async def test_send_each_for_multicast_async(self): From 3ee857c8f24fd99b2b4bb528ce02519ed5d272ca Mon Sep 17 00:00:00 2001 From: Yvonne Pan <103622026+yvonnep165@users.noreply.github.com> Date: Fri, 8 May 2026 16:04:42 -0400 Subject: [PATCH 9/9] Add integration tests for fid as argument --- integration/test_messaging.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/integration/test_messaging.py b/integration/test_messaging.py index e7208674..51e83811 100644 --- a/integration/test_messaging.py +++ b/integration/test_messaging.py @@ -87,6 +87,14 @@ def test_send_malformed_token(): with pytest.raises(exceptions.InvalidArgumentError): messaging.send(msg, dry_run=True) +def test_send_invalid_fid(): + msg = messaging.Message( + fid='not-a-fid', + notification=messaging.Notification('test-title', 'test-body') + ) + with pytest.raises(exceptions.InvalidArgumentError): + messaging.send(msg, dry_run=True) + def test_send_each(): messages = [ messaging.Message( @@ -149,6 +157,21 @@ def test_send_each_for_multicast(): assert response.exception is not None assert response.message_id is None +def test_send_each_for_multicast_fids(): + multicast = messaging.MulticastMessage( + notification=messaging.Notification('Title', 'Body'), + fids=['not-a-fid', 'also-not-a-fid']) + + batch_response = messaging.send_each_for_multicast(multicast) + + assert batch_response.success_count == 0 + assert batch_response.failure_count == 2 + assert len(batch_response.responses) == 2 + for response in batch_response.responses: + assert response.success is False + assert response.exception is not None + assert response.message_id is None + def test_subscribe(): resp = messaging.subscribe_to_topic(_REGISTRATION_TOKEN, 'mock-topic') assert resp.success_count + resp.failure_count == 1