From f7f383e9c31020a17e14c96403ef70e96a260335 Mon Sep 17 00:00:00 2001 From: Izaak Gough Date: Fri, 16 Oct 2026 16:17:38 +0100 Subject: [PATCH 1/4] fix(pubsub): accept messages without data --- src/firebase_functions/pubsub_fn.py | 11 ++++-- tests/test_pubsub_fn.py | 58 +++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+), 4 deletions(-) diff --git a/src/firebase_functions/pubsub_fn.py b/src/firebase_functions/pubsub_fn.py index 7297599d..b96a2f56 100644 --- a/src/firebase_functions/pubsub_fn.py +++ b/src/firebase_functions/pubsub_fn.py @@ -41,9 +41,9 @@ class Message(_typing.Generic[T]): Autogenerated ID that uniquely identifies this message. """ - publish_time: str + publish_time: _dt.datetime """ - Time the message was published. + Time the message was published, as a timezone-aware datetime. """ attributes: dict[str, str] @@ -51,9 +51,10 @@ class Message(_typing.Generic[T]): User-defined attributes published with the message, if any. """ - data: str + data: str | None """ - The data payload of this message object as a base64-encoded string. + The data payload of this message object as a base64-encoded string, + or `None` if the message was published without a payload. """ ordering_key: str @@ -136,6 +137,8 @@ def _message_handler( # Include empty attributes property if missing message_dict["attributes"] = message_dict.get("attributes", {}) + # Pub/Sub omits `data` when the message has an empty payload. + message_dict.setdefault("data", None) message: MessagePublishedData = MessagePublishedData( message=Message( diff --git a/tests/test_pubsub_fn.py b/tests/test_pubsub_fn.py index d0bf81b7..ff734363 100644 --- a/tests/test_pubsub_fn.py +++ b/tests/test_pubsub_fn.py @@ -151,3 +151,61 @@ def test_datetime_without_mircroseconds_doesnt_throw(self): # pylint: disable=broad-except except Exception: self.fail("Datetime without microseconds should not throw an exception") + + +def _raw_event(message: dict) -> _CloudEvent: + return _CloudEvent( + attributes={ + "id": "test-message", + "source": "https://example.com/pubsub", + "specversion": "1.0", + "time": "2023-03-11T13:25:37.403Z", + "type": "com.example.pubsub.message", + }, + data={ + "message": { + "message_id": "message-id-123", + "publish_time": "2023-03-11T13:25:37.403Z", + **message, + }, + "subscription": "my-subscription", + }, + ) + + +def _handled_message(message: dict) -> Message: + func = MagicMock() + _message_handler(func, _raw_event(message)) + func.assert_called_once() + return func.call_args.args[0].data.message + + +class TestPubSubMessageData(unittest.TestCase): + def test_message_without_data_reaches_handler_with_none(self): + message = _handled_message({"attributes": {"key": "value"}}) + self.assertIsNone(message.data) + self.assertIsNone(message.json) + self.assertEqual(message.attributes, {"key": "value"}) + self.assertEqual(message.message_id, "message-id-123") + + def test_message_with_data_decodes_json(self): + # base64 of '{"test": "value"}' + message = _handled_message({"data": "eyJ0ZXN0IjogInZhbHVlIn0="}) + self.assertEqual(message.data, "eyJ0ZXN0IjogInZhbHVlIn0=") + self.assertEqual(message.json, {"test": "value"}) + self.assertEqual(message.attributes, {}) + + def test_message_with_empty_string_data_keeps_it_and_json_raises(self): + message = _handled_message({"data": ""}) + self.assertEqual(message.data, "") + with self.assertRaisesRegex(ValueError, "^Unable to parse Pub/Sub message data as JSON: "): + _ = message.json + + def test_publish_time_is_timezone_aware_datetime(self): + message = _handled_message({"data": "eyJ0ZXN0IjogInZhbHVlIn0="}) + self.assertIsInstance(message.publish_time, _dt.datetime) + self.assertEqual( + message.publish_time, + _dt.datetime(2023, 3, 11, 13, 25, 37, 403000, tzinfo=_dt.timezone.utc), + ) + self.assertEqual(message.publish_time.utcoffset(), _dt.timedelta(0)) From 012988b02091081244ee8a8eef7aa24f4de976c2 Mon Sep 17 00:00:00 2001 From: Izaak Gough Date: Thu, 22 Oct 2026 11:40:19 +0100 Subject: [PATCH 2/4] fix(pubsub): default Message data and ordering_key to None --- src/firebase_functions/pubsub_fn.py | 9 ++++----- tests/test_pubsub_fn.py | 10 ++++++++++ 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/src/firebase_functions/pubsub_fn.py b/src/firebase_functions/pubsub_fn.py index b96a2f56..80105b83 100644 --- a/src/firebase_functions/pubsub_fn.py +++ b/src/firebase_functions/pubsub_fn.py @@ -51,15 +51,16 @@ class Message(_typing.Generic[T]): User-defined attributes published with the message, if any. """ - data: str | None + data: str | None = None """ The data payload of this message object as a base64-encoded string, or `None` if the message was published without a payload. """ - ordering_key: str + ordering_key: str | None = None """ - User-defined key used to ensure ordering amongst messages with the same key. + User-defined key used to ensure ordering amongst messages with the same key, + or `None` if the message was published without one. """ @property @@ -137,8 +138,6 @@ def _message_handler( # Include empty attributes property if missing message_dict["attributes"] = message_dict.get("attributes", {}) - # Pub/Sub omits `data` when the message has an empty payload. - message_dict.setdefault("data", None) message: MessagePublishedData = MessagePublishedData( message=Message( diff --git a/tests/test_pubsub_fn.py b/tests/test_pubsub_fn.py index ff734363..b2ed3b61 100644 --- a/tests/test_pubsub_fn.py +++ b/tests/test_pubsub_fn.py @@ -201,6 +201,16 @@ def test_message_with_empty_string_data_keeps_it_and_json_raises(self): with self.assertRaisesRegex(ValueError, "^Unable to parse Pub/Sub message data as JSON: "): _ = message.json + def test_message_built_directly_defaults_data_and_ordering_key_to_none(self): + message = Message( + message_id="message-id-123", + publish_time=_dt.datetime(2023, 3, 11, tzinfo=_dt.timezone.utc), + attributes={}, + ) + self.assertIsNone(message.data) + self.assertIsNone(message.ordering_key) + self.assertIsNone(message.json) + def test_publish_time_is_timezone_aware_datetime(self): message = _handled_message({"data": "eyJ0ZXN0IjogInZhbHVlIn0="}) self.assertIsInstance(message.publish_time, _dt.datetime) From 9783b591da468fe3a2ea26f85246d94332a91bd3 Mon Sep 17 00:00:00 2001 From: Izaak Gough Date: Fri, 23 Oct 2026 13:30:39 +0100 Subject: [PATCH 3/4] fix(pubsub): return None from Message.json for empty data The emulator sends data as an empty string for attribute-only messages. Also cover a present ordering key and reuse _raw_event in older tests. --- src/firebase_functions/pubsub_fn.py | 2 +- tests/test_pubsub_fn.py | 127 ++++++++++------------------ 2 files changed, 46 insertions(+), 83 deletions(-) diff --git a/src/firebase_functions/pubsub_fn.py b/src/firebase_functions/pubsub_fn.py index 80105b83..1acedb04 100644 --- a/src/firebase_functions/pubsub_fn.py +++ b/src/firebase_functions/pubsub_fn.py @@ -66,7 +66,7 @@ class Message(_typing.Generic[T]): @property def json(self) -> T | None: try: - if self.data is not None: + if self.data: return _json.loads(_base64.b64decode(self.data).decode("utf-8")) else: return None diff --git a/tests/test_pubsub_fn.py b/tests/test_pubsub_fn.py index b2ed3b61..0543754a 100644 --- a/tests/test_pubsub_fn.py +++ b/tests/test_pubsub_fn.py @@ -29,6 +29,33 @@ ) +def _raw_event(message: dict, time: str = "2023-03-11T13:25:37.403Z") -> _CloudEvent: + return _CloudEvent( + attributes={ + "id": "test-message", + "source": "https://example.com/pubsub", + "specversion": "1.0", + "time": time, + "type": "com.example.pubsub.message", + }, + data={ + "message": { + "message_id": "message-id-123", + "publish_time": time, + **message, + }, + "subscription": "my-subscription", + }, + ) + + +def _handled_message(message: dict) -> Message: + func = MagicMock() + _message_handler(func, _raw_event(message)) + func.assert_called_once() + return func.call_args.args[0].data.message + + class TestPubSub(unittest.TestCase): """ PubSub function tests. @@ -55,24 +82,12 @@ def test_message_handler(self): formatted CloudEvent instance. """ func = MagicMock() - raw_event = _CloudEvent( - attributes={ - "id": "test-message", - "source": "https://example.com/pubsub", - "specversion": "1.0", - "time": "2023-03-11T13:25:37.403Z", - "type": "com.example.pubsub.message", - }, - data={ - "message": { - "attributes": {"key": "value"}, - # {"test": "value"} - "data": "eyJ0ZXN0IjogInZhbHVlIn0=", - "message_id": "message-id-123", - "publish_time": "2023-03-11T13:25:37.403Z", - }, - "subscription": "my-subscription", - }, + raw_event = _raw_event( + { + "attributes": {"key": "value"}, + # {"test": "value"} + "data": "eyJ0ZXN0IjogInZhbHVlIn0=", + } ) _message_handler(func, raw_event) @@ -103,24 +118,7 @@ def init(): hello = "world" func = MagicMock() - raw_event = _CloudEvent( - attributes={ - "id": "test-message", - "source": "https://example.com/pubsub", - "specversion": "1.0", - "time": "2023-03-11T13:25:37.403Z", - "type": "com.example.pubsub.message", - }, - data={ - "message": { - "attributes": {"key": "value"}, - "data": "eyJ0ZXN0IjogInZhbHVlIn0=", - "message_id": "message-id-123", - "publish_time": "2023-03-11T13:25:37.403Z", - }, - "subscription": "my-subscription", - }, - ) + raw_event = _raw_event({"data": "eyJ0ZXN0IjogInZhbHVlIn0="}) _message_handler(func, raw_event) @@ -128,24 +126,7 @@ def init(): def test_datetime_without_mircroseconds_doesnt_throw(self): time = "2023-03-11T13:25:37Z" - raw_event = _CloudEvent( - attributes={ - "id": "test-message", - "source": "https://example.com/pubsub", - "specversion": "1.0", - "time": time, - "type": "com.example.pubsub.message", - }, - data={ - "message": { - "attributes": {"key": "value"}, - "data": "eyJ0ZXN0IjogInZhbHVlIn0=", - "message_id": "message-id-123", - "publish_time": time, - }, - "subscription": "my-subscription", - }, - ) + raw_event = _raw_event({"data": "eyJ0ZXN0IjogInZhbHVlIn0="}, time=time) try: _message_handler(lambda _: None, raw_event) # pylint: disable=broad-except @@ -153,33 +134,6 @@ def test_datetime_without_mircroseconds_doesnt_throw(self): self.fail("Datetime without microseconds should not throw an exception") -def _raw_event(message: dict) -> _CloudEvent: - return _CloudEvent( - attributes={ - "id": "test-message", - "source": "https://example.com/pubsub", - "specversion": "1.0", - "time": "2023-03-11T13:25:37.403Z", - "type": "com.example.pubsub.message", - }, - data={ - "message": { - "message_id": "message-id-123", - "publish_time": "2023-03-11T13:25:37.403Z", - **message, - }, - "subscription": "my-subscription", - }, - ) - - -def _handled_message(message: dict) -> Message: - func = MagicMock() - _message_handler(func, _raw_event(message)) - func.assert_called_once() - return func.call_args.args[0].data.message - - class TestPubSubMessageData(unittest.TestCase): def test_message_without_data_reaches_handler_with_none(self): message = _handled_message({"attributes": {"key": "value"}}) @@ -195,12 +149,21 @@ def test_message_with_data_decodes_json(self): self.assertEqual(message.json, {"test": "value"}) self.assertEqual(message.attributes, {}) - def test_message_with_empty_string_data_keeps_it_and_json_raises(self): + def test_message_with_empty_string_data_returns_none_json(self): message = _handled_message({"data": ""}) self.assertEqual(message.data, "") + self.assertIsNone(message.json) + + def test_message_with_invalid_data_json_raises(self): + # base64 of "not json" + message = _handled_message({"data": "bm90IGpzb24="}) with self.assertRaisesRegex(ValueError, "^Unable to parse Pub/Sub message data as JSON: "): _ = message.json + def test_message_with_ordering_key_keeps_it(self): + message = _handled_message({"data": "eyJ0ZXN0IjogInZhbHVlIn0=", "orderingKey": "abc"}) + self.assertEqual(message.ordering_key, "abc") + def test_message_built_directly_defaults_data_and_ordering_key_to_none(self): message = Message( message_id="message-id-123", From b061adfce2e4c4709159cfaeb432c295ad454ee2 Mon Sep 17 00:00:00 2001 From: Izaak Gough Date: Thu, 29 Oct 2026 11:36:59 +0000 Subject: [PATCH 4/4] fix(pubsub): treat empty message data as missing --- src/firebase_functions/pubsub_fn.py | 3 +++ tests/test_pubsub_fn.py | 4 ++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/firebase_functions/pubsub_fn.py b/src/firebase_functions/pubsub_fn.py index 1acedb04..8733822d 100644 --- a/src/firebase_functions/pubsub_fn.py +++ b/src/firebase_functions/pubsub_fn.py @@ -139,6 +139,9 @@ def _message_handler( # Include empty attributes property if missing message_dict["attributes"] = message_dict.get("attributes", {}) + # The emulator sends "" for an attribute-only message. + message_dict["data"] = message_dict.get("data") or None + message: MessagePublishedData = MessagePublishedData( message=Message( **message_dict, diff --git a/tests/test_pubsub_fn.py b/tests/test_pubsub_fn.py index 0543754a..d1c4c564 100644 --- a/tests/test_pubsub_fn.py +++ b/tests/test_pubsub_fn.py @@ -149,9 +149,9 @@ def test_message_with_data_decodes_json(self): self.assertEqual(message.json, {"test": "value"}) self.assertEqual(message.attributes, {}) - def test_message_with_empty_string_data_returns_none_json(self): + def test_message_with_empty_string_data_reaches_handler_with_none(self): message = _handled_message({"data": ""}) - self.assertEqual(message.data, "") + self.assertIsNone(message.data) self.assertIsNone(message.json) def test_message_with_invalid_data_json_raises(self):