From 3e88551cb054486854c42ed04e27c8dbd2637759 Mon Sep 17 00:00:00 2001 From: jguz-pubnub Date: Mon, 17 Nov 2025 11:44:12 +0100 Subject: [PATCH 1/4] Add FCM push type and remove MPNS support due to its end of life --- pubnub/enums.py | 4 +- pubnub/utils.py | 4 +- .../push/test_add_channels_to_push.py | 8 +-- .../push/test_list_push_provisions.py | 13 ---- .../push/test_remove_channels_from_push.py | 8 +-- .../push/test_remove_device_from_push.py | 6 +- .../mpns_basic_success.json | 64 ------------------- .../mpns_basic_success.json | 64 ------------------- .../mpns_basic_success.json | 64 ------------------- .../native_sync/test_list_push_channels.py | 19 ------ .../test_remove_channels_from_push.py | 20 ------ .../test_remove_device_from_push.py | 18 ------ tests/unit/test_add_channels_to_push.py | 18 +++++- tests/unit/test_list_push_channels.py | 16 ++++- tests/unit/test_remove_channels_from_push.py | 18 +++++- tests/unit/test_remove_device_from_push.py | 4 +- 16 files changed, 66 insertions(+), 282 deletions(-) delete mode 100644 tests/integrational/fixtures/native_sync/list_push_channels/mpns_basic_success.json delete mode 100644 tests/integrational/fixtures/native_sync/remove_channels_from_push/mpns_basic_success.json delete mode 100644 tests/integrational/fixtures/native_sync/remove_device_from_push/mpns_basic_success.json diff --git a/pubnub/enums.py b/pubnub/enums.py index 1e1c8a43..f3235a87 100644 --- a/pubnub/enums.py +++ b/pubnub/enums.py @@ -143,9 +143,9 @@ class PNReconnectionPolicy(object): class PNPushType(object): APNS = 1 - MPNS = 2 - GCM = 3 + GCM = 3 # Deprecated: Use FCM instead. GCM has been replaced by FCM (Firebase Cloud Messaging) APNS2 = 4 + FCM = 5 class PNResourceType(object): diff --git a/pubnub/utils.py b/pubnub/utils.py index 3b5d2976..99d62582 100644 --- a/pubnub/utils.py +++ b/pubnub/utils.py @@ -154,8 +154,8 @@ def push_type_to_string(push_type): return "apns" elif push_type == PNPushType.GCM: return "gcm" - elif push_type == PNPushType.MPNS: - return "mpns" + elif push_type == PNPushType.FCM: + return "fcm" else: return "" diff --git a/tests/functional/push/test_add_channels_to_push.py b/tests/functional/push/test_add_channels_to_push.py index 9dbd905b..19c87a61 100644 --- a/tests/functional/push/test_add_channels_to_push.py +++ b/tests/functional/push/test_add_channels_to_push.py @@ -43,7 +43,7 @@ def test_push_add_single_channel(self): self.assertEqual(self.add_channels._channels, ['ch']) def test_push_add_multiple_channels(self): - self.add_channels.channels(['ch1', 'ch2']).push_type(pubnub.enums.PNPushType.MPNS).device_id("coolDevice") + self.add_channels.channels(['ch1', 'ch2']).push_type(pubnub.enums.PNPushType.APNS).device_id("coolDevice") params = (pnconf.subscribe_key, "coolDevice") self.assertEqual(self.add_channels.build_path(), AddChannelsToPush.ADD_PATH % params) @@ -51,14 +51,14 @@ def test_push_add_multiple_channels(self): self.assertEqual(self.add_channels.build_params_callback()({}), { 'pnsdk': sdk_name, 'uuid': self.pubnub.uuid, - 'type': 'mpns', + 'type': 'apns', 'add': 'ch1,ch2' }) self.assertEqual(self.add_channels._channels, ['ch1', 'ch2']) def test_push_add_google(self): - self.add_channels.channels(['ch1', 'ch2', 'ch3']).push_type(pubnub.enums.PNPushType.GCM).device_id("coolDevice") + self.add_channels.channels(['ch1', 'ch2', 'ch3']).push_type(pubnub.enums.PNPushType.FCM).device_id("coolDevice") params = (pnconf.subscribe_key, "coolDevice") self.assertEqual(self.add_channels.build_path(), AddChannelsToPush.ADD_PATH % params) @@ -66,7 +66,7 @@ def test_push_add_google(self): self.assertEqual(self.add_channels.build_params_callback()({}), { 'pnsdk': sdk_name, 'uuid': self.pubnub.uuid, - 'type': 'gcm', + 'type': 'fcm', 'add': 'ch1,ch2,ch3' }) diff --git a/tests/functional/push/test_list_push_provisions.py b/tests/functional/push/test_list_push_provisions.py index 396bab88..d725514b 100644 --- a/tests/functional/push/test_list_push_provisions.py +++ b/tests/functional/push/test_list_push_provisions.py @@ -55,19 +55,6 @@ def test_list_channel_group_gcm(self): 'type': 'gcm' }) - def test_list_channel_group_mpns(self): - self.list_push.push_type(PNPushType.MPNS).device_id('coolDevice') - - self.assertEqual(self.list_push.build_path(), - ListPushProvisions.LIST_PATH % ( - pnconf.subscribe_key, "coolDevice")) - - self.assertEqual(self.list_push.build_params_callback()({}), { - 'pnsdk': sdk_name, - 'uuid': self.pubnub.uuid, - 'type': 'mpns' - }) - def test_list_channel_group_apns2(self): self.list_push.push_type(PNPushType.APNS2).device_id('coolDevice')\ .environment(pubnub.enums.PNPushEnvironment.PRODUCTION).topic("testTopic") diff --git a/tests/functional/push/test_remove_channels_from_push.py b/tests/functional/push/test_remove_channels_from_push.py index af0d6cca..1c0ea93d 100644 --- a/tests/functional/push/test_remove_channels_from_push.py +++ b/tests/functional/push/test_remove_channels_from_push.py @@ -36,7 +36,7 @@ def test_push_remove_single_channel(self): self.assertEqual(self.remove_channels._channels, ['ch']) def test_push_remove_multiple_channels(self): - self.remove_channels.channels(['ch1', 'ch2']).push_type(pubnub.enums.PNPushType.MPNS).device_id("coolDevice") + self.remove_channels.channels(['ch1', 'ch2']).push_type(pubnub.enums.PNPushType.APNS).device_id("coolDevice") params = (pnconf.subscribe_key, "coolDevice") self.assertEqual(self.remove_channels.build_path(), RemoveChannelsFromPush.REMOVE_PATH % params) @@ -44,14 +44,14 @@ def test_push_remove_multiple_channels(self): self.assertEqual(self.remove_channels.build_params_callback()({}), { 'pnsdk': sdk_name, 'uuid': self.pubnub.uuid, - 'type': 'mpns', + 'type': 'apns', 'remove': 'ch1,ch2' }) self.assertEqual(self.remove_channels._channels, ['ch1', 'ch2']) def test_push_remove_google(self): - self.remove_channels.channels(['ch1', 'ch2', 'ch3']).push_type(pubnub.enums.PNPushType.GCM)\ + self.remove_channels.channels(['ch1', 'ch2', 'ch3']).push_type(pubnub.enums.PNPushType.FCM)\ .device_id("coolDevice") params = (pnconf.subscribe_key, "coolDevice") @@ -60,7 +60,7 @@ def test_push_remove_google(self): self.assertEqual(self.remove_channels.build_params_callback()({}), { 'pnsdk': sdk_name, 'uuid': self.pubnub.uuid, - 'type': 'gcm', + 'type': 'fcm', 'remove': 'ch1,ch2,ch3' }) diff --git a/tests/functional/push/test_remove_device_from_push.py b/tests/functional/push/test_remove_device_from_push.py index cd8e8bb4..6a912c8a 100644 --- a/tests/functional/push/test_remove_device_from_push.py +++ b/tests/functional/push/test_remove_device_from_push.py @@ -50,8 +50,8 @@ def test_remove_push_gcm(self): 'type': 'gcm', }) - def test_remove_push_mpns(self): - self.remove_device.push_type(pubnub.enums.PNPushType.MPNS).device_id("coolDevice") + def test_remove_push_fcm(self): + self.remove_device.push_type(pubnub.enums.PNPushType.FCM).device_id("coolDevice") params = (pnconf.subscribe_key, "coolDevice") self.assertEqual(self.remove_device.build_path(), RemoveDeviceFromPush.REMOVE_PATH % params) @@ -59,7 +59,7 @@ def test_remove_push_mpns(self): self.assertEqual(self.remove_device.build_params_callback()({}), { 'pnsdk': sdk_name, 'uuid': self.pubnub.uuid, - 'type': 'mpns', + 'type': 'fcm', }) def test_remove_push_apns2(self): diff --git a/tests/integrational/fixtures/native_sync/list_push_channels/mpns_basic_success.json b/tests/integrational/fixtures/native_sync/list_push_channels/mpns_basic_success.json deleted file mode 100644 index 2ee627f0..00000000 --- a/tests/integrational/fixtures/native_sync/list_push_channels/mpns_basic_success.json +++ /dev/null @@ -1,64 +0,0 @@ -{ - "version": 1, - "interactions": [ - { - "request": { - "method": "GET", - "uri": "https://ps.pndsn.com/v1/push/sub-key/{PN_KEY_SUBSCRIBE}/devices/0000000000000000?type=mpns&uuid=test-uuid", - "body": "", - "headers": { - "host": [ - "ps.pndsn.com" - ], - "accept": [ - "*/*" - ], - "accept-encoding": [ - "gzip, deflate" - ], - "connection": [ - "keep-alive" - ], - "user-agent": [ - "PubNub-Python/10.4.0" - ] - } - }, - "response": { - "status": { - "code": 200, - "message": "OK" - }, - "headers": { - "Date": [ - "Thu, 05 Jun 2025 12:42:58 GMT" - ], - "Content-Type": [ - "text/javascript; charset=\"UTF-8\"" - ], - "Content-Length": [ - "2" - ], - "Connection": [ - "keep-alive" - ], - "Cache-Control": [ - "no-cache" - ], - "Access-Control-Allow-Methods": [ - "GET, POST, DELETE, OPTIONS" - ], - "Access-Control-Allow-Credentials": [ - "true" - ], - "Access-Control-Expose-Headers": [ - "*" - ] - }, - "body": { - "pickle": "gASVEgAAAAAAAAB9lIwGc3RyaW5nlIwCW12Ucy4=" - } - } - } - ] -} diff --git a/tests/integrational/fixtures/native_sync/remove_channels_from_push/mpns_basic_success.json b/tests/integrational/fixtures/native_sync/remove_channels_from_push/mpns_basic_success.json deleted file mode 100644 index 833b2970..00000000 --- a/tests/integrational/fixtures/native_sync/remove_channels_from_push/mpns_basic_success.json +++ /dev/null @@ -1,64 +0,0 @@ -{ - "version": 1, - "interactions": [ - { - "request": { - "method": "GET", - "uri": "https://ps.pndsn.com/v1/push/sub-key/{PN_KEY_SUBSCRIBE}/devices/0000000000000000?remove=mpns_remove_channel_1%2Cmpns_remove_channel_2&type=mpns&uuid=test-uuid", - "body": "", - "headers": { - "host": [ - "ps.pndsn.com" - ], - "accept": [ - "*/*" - ], - "accept-encoding": [ - "gzip, deflate" - ], - "connection": [ - "keep-alive" - ], - "user-agent": [ - "PubNub-Python/10.4.0" - ] - } - }, - "response": { - "status": { - "code": 200, - "message": "OK" - }, - "headers": { - "Date": [ - "Thu, 05 Jun 2025 13:11:14 GMT" - ], - "Content-Type": [ - "text/javascript; charset=\"UTF-8\"" - ], - "Content-Length": [ - "24" - ], - "Connection": [ - "keep-alive" - ], - "Cache-Control": [ - "no-cache" - ], - "Access-Control-Allow-Methods": [ - "GET, POST, DELETE, OPTIONS" - ], - "Access-Control-Allow-Credentials": [ - "true" - ], - "Access-Control-Expose-Headers": [ - "*" - ] - }, - "body": { - "pickle": "gASVKAAAAAAAAAB9lIwGc3RyaW5nlIwYWzEsICJNb2RpZmllZCBDaGFubmVscyJdlHMu" - } - } - } - ] -} diff --git a/tests/integrational/fixtures/native_sync/remove_device_from_push/mpns_basic_success.json b/tests/integrational/fixtures/native_sync/remove_device_from_push/mpns_basic_success.json deleted file mode 100644 index 8a58bc3f..00000000 --- a/tests/integrational/fixtures/native_sync/remove_device_from_push/mpns_basic_success.json +++ /dev/null @@ -1,64 +0,0 @@ -{ - "version": 1, - "interactions": [ - { - "request": { - "method": "GET", - "uri": "https://ps.pndsn.com/v1/push/sub-key/{PN_KEY_SUBSCRIBE}/devices/0000000000000000/remove?type=mpns&uuid=test-uuid", - "body": "", - "headers": { - "host": [ - "ps.pndsn.com" - ], - "accept": [ - "*/*" - ], - "accept-encoding": [ - "gzip, deflate" - ], - "connection": [ - "keep-alive" - ], - "user-agent": [ - "PubNub-Python/10.4.0" - ] - } - }, - "response": { - "status": { - "code": 200, - "message": "OK" - }, - "headers": { - "Date": [ - "Thu, 05 Jun 2025 13:17:39 GMT" - ], - "Content-Type": [ - "text/javascript; charset=\"UTF-8\"" - ], - "Content-Length": [ - "21" - ], - "Connection": [ - "keep-alive" - ], - "Cache-Control": [ - "no-cache" - ], - "Access-Control-Allow-Methods": [ - "GET, POST, DELETE, OPTIONS" - ], - "Access-Control-Allow-Credentials": [ - "true" - ], - "Access-Control-Expose-Headers": [ - "*" - ] - }, - "body": { - "pickle": "gASVJQAAAAAAAAB9lIwGc3RyaW5nlIwVWzEsICJSZW1vdmVkIERldmljZSJdlHMu" - } - } - } - ] -} diff --git a/tests/integrational/native_sync/test_list_push_channels.py b/tests/integrational/native_sync/test_list_push_channels.py index 075492bc..c99875e2 100644 --- a/tests/integrational/native_sync/test_list_push_channels.py +++ b/tests/integrational/native_sync/test_list_push_channels.py @@ -82,25 +82,6 @@ def test_list_push_channels_apns2_basic_success(self): self.assertTrue(envelope.status.is_error() is False) self.assertIsInstance(envelope.result.channels, list) - @pn_vcr.use_cassette( - 'tests/integrational/fixtures/native_sync/list_push_channels/mpns_basic_success.json', - serializer='pn_json', - filter_query_parameters=['seqn', 'pnsdk', 'l_sig'] - ) - def test_list_push_channels_mpns_basic_success(self): - """Test basic MPNS channel listing functionality.""" - device_id = "0000000000000000" - - envelope = self.pubnub.list_push_channels() \ - .device_id(device_id) \ - .push_type(PNPushType.MPNS) \ - .sync() - - self.assertIsNotNone(envelope) - self.assertIsNotNone(envelope.result) - self.assertTrue(envelope.status.is_error() is False) - self.assertIsInstance(envelope.result.channels, list) - @pn_vcr.use_cassette( 'tests/integrational/fixtures/native_sync/list_push_channels/empty_device.json', serializer='pn_json', diff --git a/tests/integrational/native_sync/test_remove_channels_from_push.py b/tests/integrational/native_sync/test_remove_channels_from_push.py index a60bb02c..dadaac1a 100644 --- a/tests/integrational/native_sync/test_remove_channels_from_push.py +++ b/tests/integrational/native_sync/test_remove_channels_from_push.py @@ -81,26 +81,6 @@ def test_remove_channels_from_push_apns2_basic_success(self): self.assertIsNotNone(envelope.result) self.assertTrue(envelope.status.is_error() is False) - @pn_vcr.use_cassette( - 'tests/integrational/fixtures/native_sync/remove_channels_from_push/mpns_basic_success.json', - serializer='pn_json', - filter_query_parameters=['seqn', 'pnsdk', 'l_sig'] - ) - def test_remove_channels_from_push_mpns_basic_success(self): - """Test basic MPNS channel removal functionality.""" - device_id = "0000000000000000" - channels = ["mpns_remove_channel_1", "mpns_remove_channel_2"] - - envelope = self.pubnub.remove_channels_from_push() \ - .channels(channels) \ - .device_id(device_id) \ - .push_type(PNPushType.MPNS) \ - .sync() - - self.assertIsNotNone(envelope) - self.assertIsNotNone(envelope.result) - self.assertTrue(envelope.status.is_error() is False) - @pn_vcr.use_cassette( 'tests/integrational/fixtures/native_sync/remove_channels_from_push/single_channel.json', serializer='pn_json', diff --git a/tests/integrational/native_sync/test_remove_device_from_push.py b/tests/integrational/native_sync/test_remove_device_from_push.py index 3e472e81..de48b04a 100644 --- a/tests/integrational/native_sync/test_remove_device_from_push.py +++ b/tests/integrational/native_sync/test_remove_device_from_push.py @@ -75,24 +75,6 @@ def test_remove_device_from_push_apns2_basic_success(self): self.assertIsNotNone(envelope.result) self.assertTrue(envelope.status.is_error() is False) - @pn_vcr.use_cassette( - 'tests/integrational/fixtures/native_sync/remove_device_from_push/mpns_basic_success.json', - serializer='pn_json', - filter_query_parameters=['seqn', 'pnsdk', 'l_sig'] - ) - def test_remove_device_from_push_mpns_basic_success(self): - """Test basic MPNS device removal functionality.""" - device_id = "0000000000000000" - - envelope = self.pubnub.remove_device_from_push() \ - .device_id(device_id) \ - .push_type(PNPushType.MPNS) \ - .sync() - - self.assertIsNotNone(envelope) - self.assertIsNotNone(envelope.result) - self.assertTrue(envelope.status.is_error() is False) - @pn_vcr.use_cassette( 'tests/integrational/fixtures/native_sync/remove_device_from_push/complete_unregistration.json', serializer='pn_json', diff --git a/tests/unit/test_add_channels_to_push.py b/tests/unit/test_add_channels_to_push.py index c1511b7d..d26bf862 100644 --- a/tests/unit/test_add_channels_to_push.py +++ b/tests/unit/test_add_channels_to_push.py @@ -34,7 +34,7 @@ def test_add_channels_to_push_with_named_parameters(self): self.assertEqual(endpoint._topic, topic) self.assertEqual(endpoint._environment, environment) - def test_add_channels_to_push_builder(self): + def test_add_channels_to_push_builder_gcm(self): """Test that the returned object supports method chaining.""" pubnub = PubNub(mocked_config) @@ -50,6 +50,22 @@ def test_add_channels_to_push_builder(self): self.assertEqual(endpoint._device_id, "test_device") self.assertEqual(endpoint._push_type, PNPushType.GCM) + def test_add_channels_to_push_builder_fcm(self): + """Test that the returned object supports method chaining.""" + pubnub = PubNub(mocked_config) + + endpoint = pubnub.add_channels_to_push() \ + .channels(["test_channel"]) \ + .device_id("test_device") \ + .push_type(PNPushType.FCM) \ + .topic("test_topic") \ + .environment(PNPushEnvironment.DEVELOPMENT) + + self.assertIsInstance(endpoint, AddChannelsToPush) + self.assertEqual(endpoint._channels, ["test_channel"]) + self.assertEqual(endpoint._device_id, "test_device") + self.assertEqual(endpoint._push_type, PNPushType.FCM) + def test_add_channels_to_push_apns2_fails_without_topic(self): """Test that APNS2 fails validation when no topic is provided.""" pubnub = PubNub(mocked_config) diff --git a/tests/unit/test_list_push_channels.py b/tests/unit/test_list_push_channels.py index c8e4ba67..5c41f38b 100644 --- a/tests/unit/test_list_push_channels.py +++ b/tests/unit/test_list_push_channels.py @@ -33,7 +33,7 @@ def test_list_push_channels_with_named_parameters(self): self.assertEqual(endpoint._topic, topic) self.assertEqual(endpoint._environment, environment) - def test_list_push_channels_builder(self): + def test_list_push_channels_builder_gcm(self): """Test that the returned object supports method chaining.""" pubnub = PubNub(mocked_config) @@ -47,6 +47,20 @@ def test_list_push_channels_builder(self): self.assertEqual(endpoint._device_id, "test_device") self.assertEqual(endpoint._push_type, PNPushType.GCM) + def test_list_push_channels_builder_fcm(self): + """Test that the returned object supports method chaining.""" + pubnub = PubNub(mocked_config) + + endpoint = pubnub.list_push_channels() \ + .device_id("test_device") \ + .push_type(PNPushType.FCM) \ + .topic("test_topic") \ + .environment(PNPushEnvironment.DEVELOPMENT) + + self.assertIsInstance(endpoint, ListPushProvisions) + self.assertEqual(endpoint._device_id, "test_device") + self.assertEqual(endpoint._push_type, PNPushType.FCM) + def test_list_push_channels_apns2_fails_without_topic(self): """Test that APNS2 fails validation when no topic is provided.""" pubnub = PubNub(mocked_config) diff --git a/tests/unit/test_remove_channels_from_push.py b/tests/unit/test_remove_channels_from_push.py index 01809070..38c7c5b4 100644 --- a/tests/unit/test_remove_channels_from_push.py +++ b/tests/unit/test_remove_channels_from_push.py @@ -34,7 +34,7 @@ def test_remove_channels_from_push_with_named_parameters(self): self.assertEqual(endpoint._topic, topic) self.assertEqual(endpoint._environment, environment) - def test_remove_channels_from_push_builder(self): + def test_remove_channels_from_push_builder_gcm(self): """Test that the returned object supports method chaining.""" pubnub = PubNub(mocked_config) @@ -50,6 +50,22 @@ def test_remove_channels_from_push_builder(self): self.assertEqual(endpoint._device_id, "test_device") self.assertEqual(endpoint._push_type, PNPushType.GCM) + def test_remove_channels_from_push_builder_fcm(self): + """Test that the returned object supports method chaining.""" + pubnub = PubNub(mocked_config) + + endpoint = pubnub.remove_channels_from_push() \ + .channels(["test_channel"]) \ + .device_id("test_device") \ + .push_type(PNPushType.FCM) \ + .topic("test_topic") \ + .environment(PNPushEnvironment.DEVELOPMENT) + + self.assertIsInstance(endpoint, RemoveChannelsFromPush) + self.assertEqual(endpoint._channels, ["test_channel"]) + self.assertEqual(endpoint._device_id, "test_device") + self.assertEqual(endpoint._push_type, PNPushType.FCM) + def test_remove_channels_from_push_apns2_fails_without_topic(self): """Test that APNS2 fails validation when no topic is provided.""" pubnub = PubNub(mocked_config) diff --git a/tests/unit/test_remove_device_from_push.py b/tests/unit/test_remove_device_from_push.py index 2aca152f..d33ef858 100644 --- a/tests/unit/test_remove_device_from_push.py +++ b/tests/unit/test_remove_device_from_push.py @@ -37,13 +37,13 @@ def test_remove_device_from_push_builder(self): endpoint = pubnub.remove_device_from_push() \ .device_id("test_device") \ - .push_type(PNPushType.GCM) \ + .push_type(PNPushType.FCM) \ .topic("test_topic") \ .environment(PNPushEnvironment.DEVELOPMENT) self.assertIsInstance(endpoint, RemoveDeviceFromPush) self.assertEqual(endpoint._device_id, "test_device") - self.assertEqual(endpoint._push_type, PNPushType.GCM) + self.assertEqual(endpoint._push_type, PNPushType.FCM) def test_remove_device_from_push_apns2_fails_without_topic(self): """Test that APNS2 fails validation when no topic is provided.""" From 663142f50e2fd7bfa9816d3ffd690b5f6fb4baff Mon Sep 17 00:00:00 2001 From: jguz-pubnub Date: Tue, 18 Nov 2025 12:55:55 +0100 Subject: [PATCH 2/4] Fix integration and unit tests --- pubnub/pubnub_asyncio.py | 65 ++++++++++++---- .../integrational/asyncio/test_change_uuid.py | 8 +- tests/integrational/asyncio/test_heartbeat.py | 77 ++++++++++--------- .../asyncio/test_message_count.py | 8 +- tests/integrational/vcr_asyncio_sleeper.py | 4 +- tests/pytest.ini | 4 +- 6 files changed, 107 insertions(+), 59 deletions(-) diff --git a/pubnub/pubnub_asyncio.py b/pubnub/pubnub_asyncio.py index df1cfda2..a0651575 100644 --- a/pubnub/pubnub_asyncio.py +++ b/pubnub/pubnub_asyncio.py @@ -761,25 +761,35 @@ def presence(self, pubnub, presence): """ self.presence_queue.put_nowait(presence) - async def _wait_for(self, coro): + async def _wait_for(self, coro, timeout=30): """Wait for a coroutine to complete. Args: coro: The coroutine to wait for + timeout: Maximum time to wait in seconds (default: 30) Returns: The result of the coroutine Raises: + asyncio.TimeoutError: If the operation times out Exception: If an error occurs while waiting """ scc_task = asyncio.ensure_future(coro) err_task = asyncio.ensure_future(self.error_queue.get()) - await asyncio.wait([ + done, pending = await asyncio.wait([ scc_task, err_task - ], return_when=asyncio.FIRST_COMPLETED) + ], return_when=asyncio.FIRST_COMPLETED, timeout=timeout) + + # Handle timeout + if not done: + if not scc_task.cancelled(): + scc_task.cancel() + if not err_task.cancelled(): + err_task.cancel() + raise asyncio.TimeoutError(f"Operation timed out after {timeout} seconds") if err_task.done() and not scc_task.done(): if not scc_task.cancelled(): @@ -790,32 +800,48 @@ async def _wait_for(self, coro): err_task.cancel() return scc_task.result() - async def wait_for_connect(self): - """Wait for a connection to be established.""" + async def wait_for_connect(self, timeout=30): + """Wait for a connection to be established. + + Args: + timeout: Maximum time to wait in seconds (default: 30) + + Raises: + asyncio.TimeoutError: If connection is not established within timeout + """ if not self.connected_event.is_set(): - await self._wait_for(self.connected_event.wait()) + await self._wait_for(self.connected_event.wait(), timeout=timeout) + + async def wait_for_disconnect(self, timeout=30): + """Wait for a disconnection to occur. + + Args: + timeout: Maximum time to wait in seconds (default: 30) - async def wait_for_disconnect(self): - """Wait for a disconnection to occur.""" + Raises: + asyncio.TimeoutError: If disconnection does not occur within timeout + """ if not self.disconnected_event.is_set(): - await self._wait_for(self.disconnected_event.wait()) + await self._wait_for(self.disconnected_event.wait(), timeout=timeout) - async def wait_for_message_on(self, *channel_names): + async def wait_for_message_on(self, *channel_names, timeout=30): """Wait for a message on specific channels. Args: *channel_names: Channel names to wait for + timeout: Maximum time to wait in seconds (default: 30) Returns: The message envelope when received Raises: + asyncio.TimeoutError: If no message is received within timeout Exception: If an error occurs while waiting """ channel_names = list(channel_names) while True: try: - env = await self._wait_for(self.message_queue.get()) + env = await self._wait_for(self.message_queue.get(), timeout=timeout) if env.channel in channel_names: return env else: @@ -823,11 +849,24 @@ async def wait_for_message_on(self, *channel_names): finally: self.message_queue.task_done() - async def wait_for_presence_on(self, *channel_names): + async def wait_for_presence_on(self, *channel_names, timeout=30): + """Wait for a presence event on specific channels. + + Args: + *channel_names: Channel names to wait for + timeout: Maximum time to wait in seconds (default: 30) + + Returns: + The presence envelope when received + + Raises: + asyncio.TimeoutError: If no presence event is received within timeout + Exception: If an error occurs while waiting + """ channel_names = list(channel_names) while True: try: - env = await self._wait_for(self.presence_queue.get()) + env = await self._wait_for(self.presence_queue.get(), timeout=timeout) if env.channel in channel_names: return env else: diff --git a/tests/integrational/asyncio/test_change_uuid.py b/tests/integrational/asyncio/test_change_uuid.py index 2fb5a0a9..0925916d 100644 --- a/tests/integrational/asyncio/test_change_uuid.py +++ b/tests/integrational/asyncio/test_change_uuid.py @@ -30,6 +30,8 @@ async def test_change_uuid(): assert isinstance(envelope.result, PNSignalResult) assert isinstance(envelope.status, PNStatus) + await pn.stop() + @pn_vcr.use_cassette('tests/integrational/fixtures/asyncio/signal/uuid_no_lock.json', filter_query_parameters=['seqn', 'pnsdk', 'l_sig'], serializer='pn_json') @@ -51,13 +53,15 @@ async def test_change_uuid_no_lock(): assert isinstance(envelope.result, PNSignalResult) assert isinstance(envelope.status, PNStatus) + await pn.stop() + -def test_uuid_validation_at_init(_function_event_loop): +def test_uuid_validation_at_init(): with pytest.raises(AssertionError) as exception: pnconf = PNConfiguration() pnconf.publish_key = "demo" pnconf.subscribe_key = "demo" - PubNubAsyncio(pnconf, custom_event_loop=_function_event_loop) + PubNubAsyncio(pnconf) assert str(exception.value) == 'UUID missing or invalid type' diff --git a/tests/integrational/asyncio/test_heartbeat.py b/tests/integrational/asyncio/test_heartbeat.py index ec03562e..f4d09384 100644 --- a/tests/integrational/asyncio/test_heartbeat.py +++ b/tests/integrational/asyncio/test_heartbeat.py @@ -11,6 +11,7 @@ @pytest.mark.asyncio +@pytest.mark.skip(reason="Needs to be reworked to use VCR.") async def test_timeout_event_on_broken_heartbeat(): ch = helper.gen_channel("heartbeat-test") @@ -21,54 +22,54 @@ async def test_timeout_event_on_broken_heartbeat(): listener_config = pnconf_env_copy(uuid=helper.gen_channel("listener"), enable_subscribe=True) pubnub_listener = PubNubAsyncio(listener_config) - # - connect to :ch-pnpres - callback_presence = SubscribeListener() - pubnub_listener.add_listener(callback_presence) - pubnub_listener.subscribe().channels(ch).with_presence().execute() - await callback_presence.wait_for_connect() + try: + # - connect to :ch-pnpres + callback_presence = SubscribeListener() + pubnub_listener.add_listener(callback_presence) + pubnub_listener.subscribe().channels(ch).with_presence().execute() + await callback_presence.wait_for_connect() - envelope = await callback_presence.wait_for_presence_on(ch) - assert ch == envelope.channel - assert 'join' == envelope.event - assert pubnub_listener.uuid == envelope.uuid + envelope = await callback_presence.wait_for_presence_on(ch) + assert ch == envelope.channel + assert 'join' == envelope.event + assert pubnub_listener.uuid == envelope.uuid - # # - connect to :ch - callback_messages = SubscribeListener() - pubnub.add_listener(callback_messages) - pubnub.subscribe().channels(ch).execute() + # # - connect to :ch + callback_messages = SubscribeListener() + pubnub.add_listener(callback_messages) + pubnub.subscribe().channels(ch).execute() - useless_connect_future = asyncio.ensure_future(callback_messages.wait_for_connect()) - presence_future = asyncio.ensure_future(callback_presence.wait_for_presence_on(ch)) + useless_connect_future = asyncio.ensure_future(callback_messages.wait_for_connect()) + presence_future = asyncio.ensure_future(callback_presence.wait_for_presence_on(ch)) - # - assert join event - await asyncio.wait([useless_connect_future, presence_future]) + # - assert join event + done, pending = await asyncio.wait([useless_connect_future, presence_future], return_when=asyncio.ALL_COMPLETED) - prs_envelope = presence_future.result() + prs_envelope = presence_future.result() - assert ch == prs_envelope.channel - assert 'join' == prs_envelope.event - assert pubnub.uuid == prs_envelope.uuid - # - break messenger heartbeat loop - pubnub._subscription_manager._stop_heartbeat_timer() + assert ch == prs_envelope.channel + assert 'join' == prs_envelope.event + assert pubnub.uuid == prs_envelope.uuid + # - break messenger heartbeat loop + pubnub._subscription_manager._stop_heartbeat_timer() - # wait for one heartbeat call - await asyncio.sleep(8) + # wait for one heartbeat call + await asyncio.sleep(8) - # - assert for timeout - envelope = await callback_presence.wait_for_presence_on(ch) - assert ch == envelope.channel - assert 'timeout' == envelope.event - assert pubnub.uuid == envelope.uuid + # - assert for timeout + envelope = await callback_presence.wait_for_presence_on(ch) + assert ch == envelope.channel + assert 'timeout' == envelope.event + assert pubnub.uuid == envelope.uuid - pubnub.unsubscribe().channels(ch).execute() - if isinstance(pubnub._subscription_manager, AsyncioSubscriptionManager): + pubnub.unsubscribe().channels(ch).execute() await callback_messages.wait_for_disconnect() - # - disconnect from :ch-pnpres - pubnub_listener.unsubscribe().channels(ch).execute() - if isinstance(pubnub._subscription_manager, AsyncioSubscriptionManager): + # - disconnect from :ch-pnpres + pubnub_listener.unsubscribe().channels(ch).execute() await callback_presence.wait_for_disconnect() - await pubnub.stop() - await pubnub_listener.stop() - await asyncio.sleep(0.5) + finally: + await pubnub.stop() + await pubnub_listener.stop() + await asyncio.sleep(0.5) diff --git a/tests/integrational/asyncio/test_message_count.py b/tests/integrational/asyncio/test_message_count.py index f2f547c2..ec65b4d7 100644 --- a/tests/integrational/asyncio/test_message_count.py +++ b/tests/integrational/asyncio/test_message_count.py @@ -1,4 +1,5 @@ import pytest +import pytest_asyncio from pubnub.pubnub_asyncio import PubNubAsyncio from pubnub.models.envelopes import AsyncioEnvelope @@ -8,12 +9,13 @@ from tests.integrational.vcr_helper import pn_vcr -@pytest.fixture -def pn(_function_event_loop): +@pytest_asyncio.fixture +async def pn(): config = pnconf_mc_copy() config.enable_subscribe = False - pn = PubNubAsyncio(config, custom_event_loop=_function_event_loop) + pn = PubNubAsyncio(config) yield pn + await pn.stop() @pn_vcr.use_cassette( diff --git a/tests/integrational/vcr_asyncio_sleeper.py b/tests/integrational/vcr_asyncio_sleeper.py index dd861b08..9f7c6bbb 100644 --- a/tests/integrational/vcr_asyncio_sleeper.py +++ b/tests/integrational/vcr_asyncio_sleeper.py @@ -48,9 +48,9 @@ def __init__(self, raise_times): super(VCR599Listener, self).__init__() - async def _wait_for(self, coro): + async def _wait_for(self, coro, timeout=30): try: - res = await super(VCR599Listener, self)._wait_for(coro) + res = await super(VCR599Listener, self)._wait_for(coro, timeout=timeout) return res except CannotOverwriteExistingCassetteException as e: if "Can't overwrite existing cassette" in str(e): diff --git a/tests/pytest.ini b/tests/pytest.ini index 2427aeeb..46573595 100644 --- a/tests/pytest.ini +++ b/tests/pytest.ini @@ -6,4 +6,6 @@ filterwarnings = ignore:The function .* is deprecated. Use.* Include Object instead:DeprecationWarning ignore:The function .* is deprecated. Use.* PNUserMember class instead:DeprecationWarning -asyncio_default_fixture_loop_scope = module \ No newline at end of file +asyncio_default_fixture_loop_scope = function +timeout = 60 +timeout_func_only = true \ No newline at end of file From c934bcc81753f82659c2ccc16e483e13d5c9442f Mon Sep 17 00:00:00 2001 From: jguz-pubnub Date: Wed, 19 Nov 2025 09:19:57 +0100 Subject: [PATCH 3/4] Fix acceptance tests --- .github/workflows/run-tests.yml | 2 +- pubnub/event_engine/models/states.py | 2 +- tests/acceptance/pam/steps/then_steps.py | 76 +++++++++++++++++++ .../acceptance/subscribe/steps/then_steps.py | 4 +- tests/integrational/asyncio/test_heartbeat.py | 2 +- 5 files changed, 81 insertions(+), 5 deletions(-) diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 32dbcd60..7070c6a9 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -87,7 +87,7 @@ jobs: pip3 install --user --ignore-installed -r requirements-dev.txt behave --junit tests/acceptance/pam - behave --junit tests/acceptance/encryption/cryptor-module.feature -t=~na=python -k + behave --junit tests/acceptance/encryption/cryptor-module.feature -t=~na=python behave --junit tests/acceptance/subscribe - name: Expose acceptance tests reports uses: actions/upload-artifact@v4 diff --git a/pubnub/event_engine/models/states.py b/pubnub/event_engine/models/states.py index d9873323..6d40b3e5 100644 --- a/pubnub/event_engine/models/states.py +++ b/pubnub/event_engine/models/states.py @@ -568,7 +568,7 @@ def reconnect_failure(self, event: events.ReceiveReconnectFailureEvent, context: return PNTransition( state=ReceiveReconnectingState, context=self._context, - invocation=invocations.EmitStatusInvocation(PNStatusCategory.UnexpectedDisconnectCategory, + invocation=invocations.EmitStatusInvocation(PNStatusCategory.PNUnexpectedDisconnectCategory, operation=PNOperationType.PNSubscribeOperation, context=self._context) ) diff --git a/tests/acceptance/pam/steps/then_steps.py b/tests/acceptance/pam/steps/then_steps.py index 6f3d4b8a..6a1a4ff1 100644 --- a/tests/acceptance/pam/steps/then_steps.py +++ b/tests/acceptance/pam/steps/then_steps.py @@ -1,5 +1,6 @@ import json from behave import then + from pubnub.exceptions import PubNubException @@ -19,6 +20,12 @@ def step_impl(context, channel): assert context.token_resource +@then("token {data_type} permission {permission}") +def step_impl(context, data_type, permission): + assert context.token_resource + assert context.token_resource[permission.lower()] + + @then("the token contains the authorized UUID {test_uuid}") def step_impl(context, test_uuid): assert context.parsed_token.get("authorized_uuid") == test_uuid.strip('"') @@ -80,6 +87,75 @@ def step_impl(context): context.pam_call_error = json.loads(context.pam_call_result._errormsg) +@then("the error status code is {error_code}") +def step_impl(context, error_code): + assert context.pam_call_error['status'] == int(error_code) + + +@then("the auth error message is '{error_message}'") +@then("the error message is '{error_message}'") +def step_impl(context, error_message): + if 'message' in context.pam_call_error: + assert context.pam_call_error['message'] == error_message + elif 'error' in context.pam_call_error and 'message' in context.pam_call_error['error']: + assert context.pam_call_error['error']['message'] == error_message + else: + raise AssertionError("Unexpected payload: {}".format(context.pam_call_error)) + + +@then("the error detail message is not empty") +def step_impl(context): + if 'error' in context.pam_call_error and 'details' in context.pam_call_error['error']: + assert len(context.pam_call_error['error']['details']) > 0 + assert 'message' in context.pam_call_error['error']['details'][0] + assert len(context.pam_call_error['error']['details'][0]['message']) > 0 + else: + raise AssertionError("Unexpected payload: {}".format(context.pam_call_error)) + + +@then("the error detail message is '{details_message}'") +def step_impl(context, details_message): + if 'error' in context.pam_call_error and 'details' in context.pam_call_error['error']: + assert len(context.pam_call_error['error']['details']) > 0 + assert 'message' in context.pam_call_error['error']['details'][0] + assert context.pam_call_error['error']['details'][0]['message'] == details_message + else: + raise AssertionError("Unexpected payload: {}".format(context.pam_call_error)) + + +@then("the error detail location is '{details_location}'") +def step_impl(context, details_location): + if 'error' in context.pam_call_error and 'details' in context.pam_call_error['error']: + assert len(context.pam_call_error['error']['details']) > 0 + assert 'location' in context.pam_call_error['error']['details'][0] + assert context.pam_call_error['error']['details'][0]['location'] == details_location + else: + raise AssertionError("Unexpected payload: {}".format(context.pam_call_error)) + + +@then("the error detail location type is '{details_location_type}'") +def step_impl(context, details_location_type): + if 'error' in context.pam_call_error and 'details' in context.pam_call_error['error']: + assert len(context.pam_call_error['error']['details']) > 0 + assert 'locationType' in context.pam_call_error['error']['details'][0] + assert context.pam_call_error['error']['details'][0]['locationType'] == details_location_type + else: + raise AssertionError("Unexpected payload: {}".format(context.pam_call_error)) + + +@then("the error service is '{error_service}'") +def step_impl(context, error_service): + assert context.pam_call_error['service'] == error_service + + +@then("the error source is '{error_source}'") +def step_impl(context, error_source): + if 'error' in context.pam_call_error and 'source' in context.pam_call_error['error']: + assert context.pam_call_error['error']['source'] == error_source + else: + raise AssertionError("Unexpected payload: {}".format(context.pam_call_error)) + + @then("the result is successful") def step_impl(context): assert context.publish_result.result.timetoken diff --git a/tests/acceptance/subscribe/steps/then_steps.py b/tests/acceptance/subscribe/steps/then_steps.py index b97d7940..08d9cec3 100644 --- a/tests/acceptance/subscribe/steps/then_steps.py +++ b/tests/acceptance/subscribe/steps/then_steps.py @@ -25,7 +25,7 @@ async def step_impl(ctx: PNContext): await ctx.pubnub.stop() -@then("I observe the following") +@then("I observe the following:") @async_run_until_complete async def step_impl(ctx): def parse_log_line(line: str): @@ -74,7 +74,7 @@ async def step_impl(ctx: PNContext, wait_time: str): await asyncio.sleep(int(wait_time)) -@then(u'I observe the following Events and Invocations of the Presence EE') +@then(u'I observe the following Events and Invocations of the Presence EE:') @async_run_until_complete async def step_impl(ctx): def parse_log_line(line: str): diff --git a/tests/integrational/asyncio/test_heartbeat.py b/tests/integrational/asyncio/test_heartbeat.py index f4d09384..7ec94a18 100644 --- a/tests/integrational/asyncio/test_heartbeat.py +++ b/tests/integrational/asyncio/test_heartbeat.py @@ -3,7 +3,7 @@ import pytest import pubnub as pn -from pubnub.pubnub_asyncio import AsyncioSubscriptionManager, PubNubAsyncio, SubscribeListener +from pubnub.pubnub_asyncio import PubNubAsyncio, SubscribeListener from tests import helper from tests.helper import pnconf_env_copy From a0c666aecb0b798388e10ae99cfee8f5baa6d0d6 Mon Sep 17 00:00:00 2001 From: jguz-pubnub Date: Wed, 19 Nov 2025 10:12:41 +0100 Subject: [PATCH 4/4] Remove timeout parameters --- pubnub/pubnub_asyncio.py | 65 +++++----------------- tests/integrational/vcr_asyncio_sleeper.py | 4 +- 2 files changed, 15 insertions(+), 54 deletions(-) diff --git a/pubnub/pubnub_asyncio.py b/pubnub/pubnub_asyncio.py index a0651575..df1cfda2 100644 --- a/pubnub/pubnub_asyncio.py +++ b/pubnub/pubnub_asyncio.py @@ -761,35 +761,25 @@ def presence(self, pubnub, presence): """ self.presence_queue.put_nowait(presence) - async def _wait_for(self, coro, timeout=30): + async def _wait_for(self, coro): """Wait for a coroutine to complete. Args: coro: The coroutine to wait for - timeout: Maximum time to wait in seconds (default: 30) Returns: The result of the coroutine Raises: - asyncio.TimeoutError: If the operation times out Exception: If an error occurs while waiting """ scc_task = asyncio.ensure_future(coro) err_task = asyncio.ensure_future(self.error_queue.get()) - done, pending = await asyncio.wait([ + await asyncio.wait([ scc_task, err_task - ], return_when=asyncio.FIRST_COMPLETED, timeout=timeout) - - # Handle timeout - if not done: - if not scc_task.cancelled(): - scc_task.cancel() - if not err_task.cancelled(): - err_task.cancel() - raise asyncio.TimeoutError(f"Operation timed out after {timeout} seconds") + ], return_when=asyncio.FIRST_COMPLETED) if err_task.done() and not scc_task.done(): if not scc_task.cancelled(): @@ -800,48 +790,32 @@ async def _wait_for(self, coro, timeout=30): err_task.cancel() return scc_task.result() - async def wait_for_connect(self, timeout=30): - """Wait for a connection to be established. - - Args: - timeout: Maximum time to wait in seconds (default: 30) - - Raises: - asyncio.TimeoutError: If connection is not established within timeout - """ + async def wait_for_connect(self): + """Wait for a connection to be established.""" if not self.connected_event.is_set(): - await self._wait_for(self.connected_event.wait(), timeout=timeout) - - async def wait_for_disconnect(self, timeout=30): - """Wait for a disconnection to occur. - - Args: - timeout: Maximum time to wait in seconds (default: 30) + await self._wait_for(self.connected_event.wait()) - Raises: - asyncio.TimeoutError: If disconnection does not occur within timeout - """ + async def wait_for_disconnect(self): + """Wait for a disconnection to occur.""" if not self.disconnected_event.is_set(): - await self._wait_for(self.disconnected_event.wait(), timeout=timeout) + await self._wait_for(self.disconnected_event.wait()) - async def wait_for_message_on(self, *channel_names, timeout=30): + async def wait_for_message_on(self, *channel_names): """Wait for a message on specific channels. Args: *channel_names: Channel names to wait for - timeout: Maximum time to wait in seconds (default: 30) Returns: The message envelope when received Raises: - asyncio.TimeoutError: If no message is received within timeout Exception: If an error occurs while waiting """ channel_names = list(channel_names) while True: try: - env = await self._wait_for(self.message_queue.get(), timeout=timeout) + env = await self._wait_for(self.message_queue.get()) if env.channel in channel_names: return env else: @@ -849,24 +823,11 @@ async def wait_for_message_on(self, *channel_names, timeout=30): finally: self.message_queue.task_done() - async def wait_for_presence_on(self, *channel_names, timeout=30): - """Wait for a presence event on specific channels. - - Args: - *channel_names: Channel names to wait for - timeout: Maximum time to wait in seconds (default: 30) - - Returns: - The presence envelope when received - - Raises: - asyncio.TimeoutError: If no presence event is received within timeout - Exception: If an error occurs while waiting - """ + async def wait_for_presence_on(self, *channel_names): channel_names = list(channel_names) while True: try: - env = await self._wait_for(self.presence_queue.get(), timeout=timeout) + env = await self._wait_for(self.presence_queue.get()) if env.channel in channel_names: return env else: diff --git a/tests/integrational/vcr_asyncio_sleeper.py b/tests/integrational/vcr_asyncio_sleeper.py index 9f7c6bbb..dd861b08 100644 --- a/tests/integrational/vcr_asyncio_sleeper.py +++ b/tests/integrational/vcr_asyncio_sleeper.py @@ -48,9 +48,9 @@ def __init__(self, raise_times): super(VCR599Listener, self).__init__() - async def _wait_for(self, coro, timeout=30): + async def _wait_for(self, coro): try: - res = await super(VCR599Listener, self)._wait_for(coro, timeout=timeout) + res = await super(VCR599Listener, self)._wait_for(coro) return res except CannotOverwriteExistingCassetteException as e: if "Can't overwrite existing cassette" in str(e):