diff --git a/.pubnub.yml b/.pubnub.yml index e87d22fd..3d09d2fa 100644 --- a/.pubnub.yml +++ b/.pubnub.yml @@ -1,5 +1,5 @@ name: python -version: 10.2.0 +version: 10.3.0 schema: 1 scm: github.com/pubnub/python sdks: @@ -18,7 +18,7 @@ sdks: distributions: - distribution-type: library distribution-repository: package - package-name: pubnub-10.2.0 + package-name: pubnub-10.3.0 location: https://pypi.org/project/pubnub/ supported-platforms: supported-operating-systems: @@ -91,8 +91,8 @@ sdks: - distribution-type: library distribution-repository: git release - package-name: pubnub-10.2.0 - location: https://github.com/pubnub/python/releases/download/10.2.0/pubnub-10.2.0.tar.gz + package-name: pubnub-10.3.0 + location: https://github.com/pubnub/python/releases/download/10.3.0/pubnub-10.3.0.tar.gz supported-platforms: supported-operating-systems: Linux: @@ -163,6 +163,11 @@ sdks: license-url: https://github.com/encode/httpx/blob/master/LICENSE.md is-required: Required changelog: + - date: 2025-04-10 + version: 10.3.0 + changes: + - type: feature + text: "Additional status emission during subscription." - date: 2025-02-07 version: 10.2.0 changes: diff --git a/CHANGELOG.md b/CHANGELOG.md index 22fe062a..d80b1bfe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## 10.3.0 +April 10 2025 + +#### Added +- Additional status emission during subscription. + ## 10.2.0 February 07 2025 diff --git a/pubnub/enums.py b/pubnub/enums.py index 7603fb68..1e1c8a43 100644 --- a/pubnub/enums.py +++ b/pubnub/enums.py @@ -37,6 +37,8 @@ class PNStatusCategory(Enum): PNTLSConnectionFailedCategory = 15 PNTLSUntrustedCertificateCategory = 16 PNInternalExceptionCategory = 17 + PNSubscriptionChangedCategory = 18 + PNConnectionErrorCategory = 19 class PNOperationType(object): diff --git a/pubnub/event_engine/effects.py b/pubnub/event_engine/effects.py index b475eea2..e14e7e86 100644 --- a/pubnub/event_engine/effects.py +++ b/pubnub/event_engine/effects.py @@ -128,6 +128,9 @@ async def receive_messages_async(self, channels, groups, timetoken, region): recieve_failure = events.ReceiveFailureEvent('Empty response', 1, timetoken=timetoken) self.event_engine.trigger(recieve_failure) elif response.status.error: + if self.stop_event.is_set(): + self.logger.debug(f'Recieve messages cancelled: {response.status.error_data.__dict__}') + return self.logger.warning(f'Recieve messages failed: {response.status.error_data.__dict__}') recieve_failure = events.ReceiveFailureEvent(response.status.error_data, 1, timetoken=timetoken) self.event_engine.trigger(recieve_failure) @@ -437,6 +440,9 @@ def set_pn(self, pubnub: PubNub): self.message_worker = BaseMessageWorker(pubnub) def emit(self, invocation: invocations.PNEmittableInvocation): + if isinstance(invocation, list): + for inv in invocation: + self.emit(inv) if isinstance(invocation, invocations.EmitMessagesInvocation): self.emit_message(invocation) if isinstance(invocation, invocations.EmitStatusInvocation): @@ -449,8 +455,15 @@ def emit_message(self, invocation: invocations.EmitMessagesInvocation): self.message_worker._process_incoming_payload(subscribe_message) def emit_status(self, invocation: invocations.EmitStatusInvocation): + if isinstance(invocation.status, PNStatus): + self.pubnub._subscription_manager._listener_manager.announce_status(invocation.status) + return pn_status = PNStatus() pn_status.category = invocation.status pn_status.operation = invocation.operation + if invocation.context and invocation.context.channels: + pn_status.affected_channels = invocation.context.channels + if invocation.context and invocation.context.groups: + pn_status.affected_groups = invocation.context.groups pn_status.error = False self.pubnub._subscription_manager._listener_manager.announce_status(pn_status) diff --git a/pubnub/event_engine/models/invocations.py b/pubnub/event_engine/models/invocations.py index 2b046f46..ffd2cb31 100644 --- a/pubnub/event_engine/models/invocations.py +++ b/pubnub/event_engine/models/invocations.py @@ -1,4 +1,4 @@ -from typing import List, Union +from typing import List, Optional, Union from pubnub.exceptions import PubNubException from pubnub.enums import PNOperationType, PNStatusCategory @@ -90,10 +90,16 @@ def __init__(self, messages: Union[None, List[str]]) -> None: class EmitStatusInvocation(PNEmittableInvocation): - def __init__(self, status: Union[None, PNStatusCategory], operation: Union[None, PNOperationType] = None) -> None: + def __init__( + self, + status: Optional[PNStatusCategory], + operation: Optional[PNOperationType] = None, + context=None, + ) -> None: super().__init__() self.status = status self.operation = operation + self.context = context """ diff --git a/pubnub/event_engine/models/states.py b/pubnub/event_engine/models/states.py index 01a489fc..d9873323 100644 --- a/pubnub/event_engine/models/states.py +++ b/pubnub/event_engine/models/states.py @@ -4,6 +4,7 @@ from pubnub.event_engine.models import events from pubnub.exceptions import PubNubException from typing import List, Union +from pubnub.models.consumer.pn_error_data import PNErrorData class PNContext(dict): @@ -122,7 +123,15 @@ def subscription_changed(self, event: events.SubscriptionChangedEvent, context: return PNTransition( state=HandshakingState, - context=self._context + context=self._context, + invocation=[ + invocations.EmitStatusInvocation(PNStatusCategory.PNSubscriptionChangedCategory, + operation=PNOperationType.PNSubscribeOperation, + context=self._context), + invocations.EmitStatusInvocation(PNStatusCategory.PNAcknowledgmentCategory, + operation=PNOperationType.PNSubscribeOperation, + context=self._context), + ] ) def subscription_restored(self, event: events.SubscriptionRestoredEvent, context: PNContext) -> PNTransition: @@ -148,7 +157,7 @@ def reconnecting(self, event: events.HandshakeFailureEvent, context: PNContext) return PNTransition( state=HandshakeReconnectingState, - context=self._context + context=self._context, ) def disconnect(self, event: events.DisconnectEvent, context: PNContext) -> PNTransition: @@ -183,8 +192,14 @@ def unsubscribe_all(self, event: events.UnsubscribeAllEvent, context: PNContext) return PNTransition( state=UnsubscribedState, context=self._context, - invocation=invocations.EmitStatusInvocation(PNStatusCategory.PNAcknowledgmentCategory, - operation=PNOperationType.PNUnsubscribeOperation) + invocation=[ + invocations.EmitStatusInvocation(PNStatusCategory.PNDisconnectedCategory, + operation=PNOperationType.PNSubscribeOperation, + context=self._context), + invocations.EmitStatusInvocation(PNStatusCategory.PNAcknowledgmentCategory, + operation=PNOperationType.PNSubscribeOperation, + context=self._context), + ] ) @@ -218,7 +233,10 @@ def disconnect(self, event: events.DisconnectEvent, context: PNContext) -> PNTra return PNTransition( state=HandshakeStoppedState, - context=self._context + context=self._context, + invocation=invocations.EmitStatusInvocation(PNStatusCategory.PNDisconnectedCategory, + operation=PNOperationType.PNSubscribeOperation, + context=self._context) ) def subscription_changed(self, event: events.SubscriptionChangedEvent, context: PNContext) -> PNTransition: @@ -230,7 +248,10 @@ def subscription_changed(self, event: events.SubscriptionChangedEvent, context: return PNTransition( state=HandshakeReconnectingState, - context=self._context + context=self._context, + invocation=invocations.EmitStatusInvocation(PNStatusCategory.PNSubscriptionChangedCategory, + operation=PNOperationType.PNSubscribeOperation, + context=self._context) ) def handshake_reconnect(self, event: events.HandshakeReconnectFailureEvent, context: PNContext) -> PNTransition: @@ -240,7 +261,7 @@ def handshake_reconnect(self, event: events.HandshakeReconnectFailureEvent, cont return PNTransition( state=HandshakeReconnectingState, - context=self._context + context=self._context, ) def give_up(self, event: events.HandshakeReconnectGiveupEvent, context: PNContext) -> PNTransition: @@ -252,8 +273,15 @@ def give_up(self, event: events.HandshakeReconnectGiveupEvent, context: PNContex if isinstance(event, Exception) and 'status' in event.reason: status_invocation = invocations.EmitStatusInvocation(status=event.reason.status.category, operation=PNOperationType.PNUnsubscribeOperation) + elif isinstance(context.reason, PNErrorData): + status_invocation = invocations.EmitStatusInvocation(PNStatusCategory.PNConnectionErrorCategory, + context=self._context) + elif isinstance(context.reason, PubNubException): + status = context.reason.status + status.category = PNStatusCategory.PNConnectionErrorCategory + status_invocation = invocations.EmitStatusInvocation(status) else: - status_invocation = invocations.EmitStatusInvocation(PNStatusCategory.PNDisconnectedCategory) + status_invocation = invocations.EmitStatusInvocation(PNStatusCategory.PNConnectionErrorCategory) return PNTransition( state=HandshakeFailedState, @@ -305,7 +333,10 @@ def subscription_changed(self, event: events.SubscriptionChangedEvent, context: return PNTransition( state=HandshakingState, - context=self._context + context=self._context, + invocation=invocations.EmitStatusInvocation(PNStatusCategory.PNSubscriptionChangedCategory, + operation=PNOperationType.PNSubscribeOperation, + context=self._context) ) def reconnect(self, event: events.ReconnectEvent, context: PNContext) -> PNTransition: @@ -340,8 +371,14 @@ def unsubscribe_all(self, event: events.UnsubscribeAllEvent, context: PNContext) return PNTransition( state=UnsubscribedState, context=self._context, - invocation=invocations.EmitStatusInvocation(PNStatusCategory.PNAcknowledgmentCategory, - operation=PNOperationType.PNUnsubscribeOperation) + invocation=[ + invocations.EmitStatusInvocation(PNStatusCategory.PNDisconnectedCategory, + operation=PNOperationType.PNSubscribeOperation, + context=self._context), + invocations.EmitStatusInvocation(PNStatusCategory.PNAcknowledgmentCategory, + operation=PNOperationType.PNSubscribeOperation, + context=self._context), + ] ) @@ -374,8 +411,14 @@ def unsubscribe_all(self, event: events.UnsubscribeAllEvent, context: PNContext) return PNTransition( state=UnsubscribedState, context=self._context, - invocation=invocations.EmitStatusInvocation(PNStatusCategory.PNAcknowledgmentCategory, - operation=PNOperationType.PNUnsubscribeOperation) + invocation=[ + invocations.EmitStatusInvocation(PNStatusCategory.PNDisconnectedCategory, + operation=PNOperationType.PNSubscribeOperation, + context=self._context), + invocations.EmitStatusInvocation(PNStatusCategory.PNAcknowledgmentCategory, + operation=PNOperationType.PNSubscribeOperation, + context=self._context), + ] ) @@ -412,7 +455,10 @@ def subscription_changed(self, event: events.SubscriptionChangedEvent, context: return PNTransition( state=self.__class__, - context=self._context + context=self._context, + invocation=invocations.EmitStatusInvocation(PNStatusCategory.PNSubscriptionChangedCategory, + operation=PNOperationType.PNSubscribeOperation, + context=self._context) ) def subscription_restored(self, event: events.SubscriptionRestoredEvent, context: PNContext) -> PNTransition: @@ -446,7 +492,7 @@ def receiving_failure(self, event: events.ReceiveFailureEvent, context: PNContex self._context.timetoken = event.timetoken return PNTransition( state=ReceiveReconnectingState, - context=self._context + context=self._context, ) def disconnect(self, event: events.DisconnectEvent, context: PNContext) -> PNTransition: @@ -477,8 +523,14 @@ def unsubscribe_all(self, event: events.UnsubscribeAllEvent, context: PNContext) return PNTransition( state=UnsubscribedState, context=self._context, - invocation=invocations.EmitStatusInvocation(PNStatusCategory.PNAcknowledgmentCategory, - operation=PNOperationType.PNUnsubscribeOperation) + invocation=[ + invocations.EmitStatusInvocation(PNStatusCategory.PNDisconnectedCategory, + operation=PNOperationType.PNSubscribeOperation, + context=self._context), + invocations.EmitStatusInvocation(PNStatusCategory.PNAcknowledgmentCategory, + operation=PNOperationType.PNSubscribeOperation, + context=self._context), + ] ) @@ -515,7 +567,10 @@ def reconnect_failure(self, event: events.ReceiveReconnectFailureEvent, context: return PNTransition( state=ReceiveReconnectingState, - context=self._context + context=self._context, + invocation=invocations.EmitStatusInvocation(PNStatusCategory.UnexpectedDisconnectCategory, + operation=PNOperationType.PNSubscribeOperation, + context=self._context) ) def subscription_changed(self, event: events.SubscriptionChangedEvent, context: PNContext) -> PNTransition: @@ -527,7 +582,10 @@ def subscription_changed(self, event: events.SubscriptionChangedEvent, context: return PNTransition( state=ReceiveReconnectingState, - context=self._context + context=self._context, + invocation=invocations.EmitStatusInvocation(PNStatusCategory.PNSubscriptionChangedCategory, + operation=PNOperationType.PNSubscribeOperation, + context=self._context) ) def disconnect(self, event: events.DisconnectEvent, context: PNContext) -> PNTransition: @@ -546,7 +604,9 @@ def give_up(self, event: events.ReceiveReconnectGiveupEvent, context: PNContext) return PNTransition( state=ReceiveFailedState, context=self._context, - invocation=invocations.EmitStatusInvocation(PNStatusCategory.PNDisconnectedCategory) + invocation=invocations.EmitStatusInvocation(PNStatusCategory.PNUnexpectedDisconnectCategory, + operation=PNOperationType.PNSubscribeOperation, + context=self._context) ) def reconnect_success(self, event: events.ReceiveReconnectSuccessEvent, context: PNContext) -> PNTransition: @@ -602,7 +662,10 @@ def subscription_changed(self, event: events.SubscriptionChangedEvent, context: return PNTransition( state=ReceivingState, - context=self._context + context=self._context, + invocation=invocations.EmitStatusInvocation(PNStatusCategory.PNSubscriptionChangedCategory, + operation=PNOperationType.PNSubscribeOperation, + context=self._context) ) def reconnect(self, event: events.ReconnectEvent, context: PNContext) -> PNTransition: @@ -637,8 +700,14 @@ def unsubscribe_all(self, event: events.UnsubscribeAllEvent, context: PNContext) return PNTransition( state=UnsubscribedState, context=self._context, - invocation=invocations.EmitStatusInvocation(PNStatusCategory.PNAcknowledgmentCategory, - operation=PNOperationType.PNUnsubscribeOperation) + invocation=[ + invocations.EmitStatusInvocation(PNStatusCategory.PNDisconnectedCategory, + operation=PNOperationType.PNSubscribeOperation, + context=self._context), + invocations.EmitStatusInvocation(PNStatusCategory.PNAcknowledgmentCategory, + operation=PNOperationType.PNSubscribeOperation, + context=self._context), + ] ) @@ -671,8 +740,14 @@ def unsubscribe_all(self, event: events.UnsubscribeAllEvent, context: PNContext) return PNTransition( state=UnsubscribedState, context=self._context, - invocation=invocations.EmitStatusInvocation(PNStatusCategory.PNAcknowledgmentCategory, - operation=PNOperationType.PNUnsubscribeOperation) + invocation=[ + invocations.EmitStatusInvocation(PNStatusCategory.PNDisconnectedCategory, + operation=PNOperationType.PNSubscribeOperation, + context=self._context), + invocations.EmitStatusInvocation(PNStatusCategory.PNAcknowledgmentCategory, + operation=PNOperationType.PNSubscribeOperation, + context=self._context), + ] ) diff --git a/pubnub/pubnub_asyncio.py b/pubnub/pubnub_asyncio.py index f0a7f6a6..54f7b221 100644 --- a/pubnub/pubnub_asyncio.py +++ b/pubnub/pubnub_asyncio.py @@ -559,6 +559,7 @@ def __init__(self): self.error_queue = Queue() def status(self, pubnub, status): + super().status(pubnub, status) if utils.is_subscribed_event(status) and not self.connected_event.is_set(): self.connected_event.set() elif utils.is_unsubscribed_event(status) and not self.disconnected_event.is_set(): diff --git a/pubnub/pubnub_core.py b/pubnub/pubnub_core.py index 412cdda3..74eafc43 100644 --- a/pubnub/pubnub_core.py +++ b/pubnub/pubnub_core.py @@ -96,7 +96,7 @@ class PubNubCore: """A base class for PubNub Python API implementations""" - SDK_VERSION = "10.2.0" + SDK_VERSION = "10.3.0" SDK_NAME = "PubNub-Python" TIMESTAMP_DIVIDER = 1000 diff --git a/pubnub/utils.py b/pubnub/utils.py index 42178bb1..3b5d2976 100644 --- a/pubnub/utils.py +++ b/pubnub/utils.py @@ -97,7 +97,10 @@ def is_subscribed_event(status): def is_unsubscribed_event(status): assert isinstance(status, PNStatus) - is_disconnect = status.category == PNStatusCategory.PNDisconnectedCategory + is_disconnect = status.category in [PNStatusCategory.PNDisconnectedCategory, + PNStatusCategory.PNUnexpectedDisconnectCategory, + PNStatusCategory.PNConnectionErrorCategory] + is_unsubscribe = status.category == PNStatusCategory.PNAcknowledgmentCategory \ and status.operation == PNOperationType.PNUnsubscribeOperation return is_disconnect or is_unsubscribe diff --git a/setup.py b/setup.py index 878a7d8b..f2296177 100644 --- a/setup.py +++ b/setup.py @@ -2,7 +2,7 @@ setup( name='pubnub', - version='10.2.0', + version='10.3.0', description='PubNub Real-time push service in the cloud', author='PubNub', author_email='support@pubnub.com', diff --git a/tests/acceptance/subscribe/steps/then_steps.py b/tests/acceptance/subscribe/steps/then_steps.py index 4d78ebcd..b97d7940 100644 --- a/tests/acceptance/subscribe/steps/then_steps.py +++ b/tests/acceptance/subscribe/steps/then_steps.py @@ -58,7 +58,8 @@ async def step_impl(ctx: PNContext): status = ctx.callback.status_result assert isinstance(status, PNStatus) - assert status.category == PNStatusCategory.PNDisconnectedCategory + assert status.category in [PNStatusCategory.PNConnectionErrorCategory, + PNStatusCategory.PNUnexpectedDisconnectCategory] await ctx.pubnub.stop() diff --git a/tests/functional/event_engine/test_state_machine.py b/tests/functional/event_engine/test_state_machine.py index 4c632ca2..f04649fd 100644 --- a/tests/functional/event_engine/test_state_machine.py +++ b/tests/functional/event_engine/test_state_machine.py @@ -2,6 +2,15 @@ from pubnub.event_engine.statemachine import StateMachine +class FakePN: + def __init__(self) -> None: + self._subscription_manager = self + self._listener_manager = self + + def announce_status(self, pn_status): + ... + + def test_initialize_with_state(): machine = StateMachine(states.UnsubscribedState) assert states.UnsubscribedState.__name__ == machine.get_state_name() @@ -9,6 +18,7 @@ def test_initialize_with_state(): def test_unsubscribe_state_trigger_sub_changed(): machine = StateMachine(states.UnsubscribedState) + machine.get_dispatcher().set_pn(FakePN()) machine.trigger(events.SubscriptionChangedEvent( channels=['test'], groups=[] )) @@ -17,6 +27,7 @@ def test_unsubscribe_state_trigger_sub_changed(): def test_unsubscribe_state_trigger_sub_restored(): machine = StateMachine(states.UnsubscribedState) + machine.get_dispatcher().set_pn(FakePN()) machine.trigger(events.SubscriptionChangedEvent( channels=['test'], groups=[] )) diff --git a/tests/integrational/asyncio/test_heartbeat.py b/tests/integrational/asyncio/test_heartbeat.py index b80351e5..ec03562e 100644 --- a/tests/integrational/asyncio/test_heartbeat.py +++ b/tests/integrational/asyncio/test_heartbeat.py @@ -71,3 +71,4 @@ async def test_timeout_event_on_broken_heartbeat(): await pubnub.stop() await pubnub_listener.stop() + await asyncio.sleep(0.5) diff --git a/tests/integrational/asyncio/test_subscribe.py b/tests/integrational/asyncio/test_subscribe.py index 54dce334..de4047f0 100644 --- a/tests/integrational/asyncio/test_subscribe.py +++ b/tests/integrational/asyncio/test_subscribe.py @@ -28,6 +28,7 @@ class TestCallback(SubscribeCallback): presence_result = None def status(self, pubnub, status): + super().status(pubnub, status) self.status_result = status def message(self, pubnub, message): @@ -129,7 +130,6 @@ async def test_subscribe_publish_unsubscribe(): # ) @pytest.mark.asyncio async def test_encrypted_subscribe_publish_unsubscribe(): - pubnub = PubNubAsyncio(pnconf_enc_env_copy(enable_subscribe=True)) pubnub.config.uuid = 'test-subscribe-asyncio-uuid' @@ -341,7 +341,6 @@ async def test_cg_join_leave(): pubnub.add_listener(callback_messages) pubnub.subscribe().channel_groups(gr).execute() - callback_messages_future = asyncio.ensure_future(callback_messages.wait_for_connect()) presence_messages_future = asyncio.ensure_future(callback_presence.wait_for_presence_on(ch)) await asyncio.wait([callback_messages_future, presence_messages_future]) @@ -441,7 +440,7 @@ async def test_subscribe_failing_reconnect_policy_none(): pubnub.subscribe().channels("my_channel").execute() while True: if isinstance(listener.status_result, PNStatus) \ - and listener.status_result.category == PNStatusCategory.PNDisconnectedCategory: + and listener.status_result.category == PNStatusCategory.PNConnectionErrorCategory: break await asyncio.sleep(1) @@ -459,7 +458,7 @@ async def test_subscribe_failing_reconnect_policy_none(): pubnub.subscribe().channels("my_channel_none").execute() while True: if isinstance(listener.status_result, PNStatus) \ - and listener.status_result.category == PNStatusCategory.PNDisconnectedCategory: + and listener.status_result.category == PNStatusCategory.PNConnectionErrorCategory: break await asyncio.sleep(0.5) @@ -482,7 +481,7 @@ def mock_calculate(*args, **kwargs): pubnub.subscribe().channels("my_channel_linear").execute() while True: if isinstance(listener.status_result, PNStatus) \ - and listener.status_result.category == PNStatusCategory.PNDisconnectedCategory: + and listener.status_result.category == PNStatusCategory.PNConnectionErrorCategory: break await asyncio.sleep(0.5) assert calculate_mock.call_count == LinearDelay.MAX_RETRIES @@ -506,7 +505,7 @@ def mock_calculate(*args, **kwargs): pubnub.subscribe().channels("my_channel_exponential").execute() while True: if isinstance(listener.status_result, PNStatus) \ - and listener.status_result.category == PNStatusCategory.PNDisconnectedCategory: + and listener.status_result.category == PNStatusCategory.PNConnectionErrorCategory: break await asyncio.sleep(0.5) assert calculate_mock.call_count == ExponentialDelay.MAX_RETRIES @@ -530,7 +529,7 @@ def mock_calculate(*args, **kwargs): pubnub.subscribe().channels("my_channel_linear").execute() while True: if isinstance(listener.status_result, PNStatus) \ - and listener.status_result.category == PNStatusCategory.PNDisconnectedCategory: + and listener.status_result.category == PNStatusCategory.PNConnectionErrorCategory: break await asyncio.sleep(0.5) assert calculate_mock.call_count == 3 @@ -554,7 +553,7 @@ def mock_calculate(*args, **kwargs): pubnub.subscribe().channels("my_channel_exponential").execute() while True: if isinstance(listener.status_result, PNStatus) \ - and listener.status_result.category == PNStatusCategory.PNDisconnectedCategory: + and listener.status_result.category == PNStatusCategory.PNConnectionErrorCategory: break await asyncio.sleep(0.5) assert calculate_mock.call_count == 3 @@ -578,7 +577,7 @@ def mock_calculate(*args, **kwargs): pubnub.subscribe().channels("my_channel_linear").execute() while True: if isinstance(listener.status_result, PNStatus) \ - and listener.status_result.category == PNStatusCategory.PNDisconnectedCategory: + and listener.status_result.category == PNStatusCategory.PNConnectionErrorCategory: break await asyncio.sleep(0.5) assert calculate_mock.call_count == 0