From b8d4cd11220a444004bb99585c027ef80b2dc966 Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Thu, 25 Sep 2025 17:15:44 -0300 Subject: [PATCH 1/3] feat(realtime): add presence enabled flag on join payload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add enabled field to RealtimeChannelPresenceConfig type - Add _has_callback_attached property to AsyncRealtimePresence - Set presence.enabled based on callback attachment or manual config - Add automatic resubscription when presence callbacks are added to joined channel - Add comprehensive tests for new functionality 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- src/realtime/src/realtime/_async/channel.py | 24 ++++- src/realtime/src/realtime/_async/presence.py | 10 ++ src/realtime/src/realtime/types.py | 1 + src/realtime/tests/test_presence.py | 104 +++++++++++++++++++ 4 files changed, 137 insertions(+), 2 deletions(-) diff --git a/src/realtime/src/realtime/_async/channel.py b/src/realtime/src/realtime/_async/channel.py index f217f7d0..3236199c 100644 --- a/src/realtime/src/realtime/_async/channel.py +++ b/src/realtime/src/realtime/_async/channel.py @@ -85,7 +85,7 @@ def __init__( else { "config": { "broadcast": {"ack": False, "self": False}, - "presence": {"key": ""}, + "presence": {"key": "", "enabled": False}, "private": False, } } @@ -191,9 +191,12 @@ async def subscribe( else: config: RealtimeChannelConfig = self.params["config"] broadcast = config.get("broadcast") - presence = config.get("presence") + presence = config.get("presence") or RealtimeChannelPresenceConfig(key="",enabled=False) private = config.get("private", False) + presence_enabled = self.presence._has_callback_attached or presence.get("enabled", False) + presence["enabled"] = presence_enabled + config_payload: Dict[str, Any] = { "config": { "broadcast": broadcast, @@ -429,6 +432,11 @@ def on_presence_sync(self, callback: Callable[[], None]) -> AsyncRealtimeChannel :return: The Channel instance for method chaining. """ self.presence.on_sync(callback) + + if self.is_joined: + logger.info(f"channel {self.topic} resubscribe due to change in presence callbacks on joined channel") + asyncio.create_task(self._resubscribe()) + return self def on_presence_join( @@ -441,6 +449,10 @@ def on_presence_join( :return: The Channel instance for method chaining. """ self.presence.on_join(callback) + if self.is_joined: + logger.info(f"channel {self.topic} resubscribe due to change in presence callbacks on joined channel") + asyncio.create_task(self._resubscribe()) + return self def on_presence_leave( @@ -453,6 +465,9 @@ def on_presence_leave( :return: The Channel instance for method chaining. """ self.presence.on_leave(callback) + if self.is_joined: + logger.info(f"channel {self.topic} resubscribe due to change in presence callbacks on joined channel") + asyncio.create_task(self._resubscribe()) return self # Broadcast methods @@ -469,6 +484,11 @@ async def send_broadcast(self, event: str, data: Any) -> None: ) # Internal methods + + async def _resubscribe(self) -> None: + await self.unsubscribe() + await self.subscribe() + def _broadcast_endpoint_url(self): return f"{http_endpoint_url(self.socket.http_endpoint)}/api/broadcast" diff --git a/src/realtime/src/realtime/_async/presence.py b/src/realtime/src/realtime/_async/presence.py index b33ae496..5716a102 100644 --- a/src/realtime/src/realtime/_async/presence.py +++ b/src/realtime/src/realtime/_async/presence.py @@ -21,6 +21,16 @@ class AsyncRealtimePresence: + + + @property + def _has_callback_attached(self) -> bool: + return ( + self.on_join_callback is not None + or self.on_leave_callback is not None + or self.on_sync_callback is not None + ) + def __init__(self): self.state: RealtimePresenceState = {} self.on_join_callback: Optional[PresenceOnJoinCallback] = None diff --git a/src/realtime/src/realtime/types.py b/src/realtime/src/realtime/types.py index 8274e240..75f8d4a6 100644 --- a/src/realtime/src/realtime/types.py +++ b/src/realtime/src/realtime/types.py @@ -179,6 +179,7 @@ class RealtimeChannelBroadcastConfig(TypedDict): class RealtimeChannelPresenceConfig(TypedDict): key: str + enabled: bool class RealtimeChannelConfig(TypedDict): diff --git a/src/realtime/tests/test_presence.py b/src/realtime/tests/test_presence.py index d7727316..ed835204 100644 --- a/src/realtime/tests/test_presence.py +++ b/src/realtime/tests/test_presence.py @@ -173,3 +173,107 @@ def test_transform_state_additional_fields(): result = AsyncRealtimePresence._transform_state(state_with_additional_fields) assert result == expected_output + + +def test_presence_has_callback_attached(): + """Test that _has_callback_attached property correctly detects presence callbacks.""" + presence = AsyncRealtimePresence() + + # Initially no callbacks should be attached + assert not presence._has_callback_attached + + # After setting sync callback + presence.on_sync(lambda: None) + assert presence._has_callback_attached + + # Reset and test with join callback + presence = AsyncRealtimePresence() + presence.on_join(lambda key, current, new: None) + assert presence._has_callback_attached + + # Reset and test with leave callback + presence = AsyncRealtimePresence() + presence.on_leave(lambda key, current, left: None) + assert presence._has_callback_attached + + +def test_presence_config_includes_enabled_field(): + """Test that presence config correctly includes enabled flag.""" + from realtime.types import RealtimeChannelPresenceConfig + + # Test creating presence config with enabled field + config: RealtimeChannelPresenceConfig = { + "key": "user123", + "enabled": True + } + assert config["key"] == "user123" + assert config["enabled"] == True + + # Test with enabled False + config_disabled: RealtimeChannelPresenceConfig = { + "key": "", + "enabled": False + } + assert config_disabled["key"] == "" + assert config_disabled["enabled"] == False + + +@pytest.mark.asyncio +async def test_presence_enabled_when_callbacks_attached(): + """Test that presence.enabled is set correctly based on callback attachment.""" + from unittest.mock import Mock, AsyncMock + + socket = AsyncRealtimeClient(f"{URL}/realtime/v1", ANON_KEY) + channel = socket.channel("test") + + # Mock the join_push to capture the payload + mock_join_push = Mock() + mock_join_push.receive = Mock(return_value=mock_join_push) + mock_join_push.update_payload = Mock() + mock_join_push.resend = AsyncMock() + channel.join_push = mock_join_push + + # Mock socket connection + socket.is_connected = True + socket._leave_open_topic = AsyncMock() + + # Add presence callback before subscription + channel.on_presence_sync(lambda: None) + + await channel.subscribe() + + # Verify that update_payload was called + assert mock_join_push.update_payload.called + + # Get the payload that was passed to update_payload + call_args = mock_join_push.update_payload.call_args + payload = call_args[0][0] + + # Verify presence.enabled is True because callback is attached + assert payload["config"]["presence"]["enabled"] == True + + +@pytest.mark.asyncio +async def test_resubscribe_on_presence_callback_addition(): + """Test that channel resubscribes when presence callbacks are added after joining.""" + from unittest.mock import AsyncMock + import asyncio + + socket = AsyncRealtimeClient(f"{URL}/realtime/v1", ANON_KEY) + channel = socket.channel("test") + + # Mock the channel as joined + channel.state = "joined" + channel._joined_once = True + + # Mock resubscribe method + channel._resubscribe = AsyncMock() + + # Add presence callbacks after joining + channel.on_presence_sync(lambda: None) + + # Wait a bit for async tasks to complete + await asyncio.sleep(0.1) + + # Verify resubscribe was called + assert channel._resubscribe.call_count == 1 From bc5175f4673511366b932e1861c37fbd089493e3 Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Thu, 25 Sep 2025 17:21:57 -0300 Subject: [PATCH 2/3] test(realtime): fix presence enabled flag test mocking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix AttributeError by mocking _ws_connection instead of trying to set is_connected property - Ensure test properly mocks socket connection for presence enabled flag validation 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- src/realtime/tests/test_presence.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/realtime/tests/test_presence.py b/src/realtime/tests/test_presence.py index ed835204..19c952f2 100644 --- a/src/realtime/tests/test_presence.py +++ b/src/realtime/tests/test_presence.py @@ -233,8 +233,9 @@ async def test_presence_enabled_when_callbacks_attached(): mock_join_push.resend = AsyncMock() channel.join_push = mock_join_push - # Mock socket connection - socket.is_connected = True + # Mock socket connection by setting _ws_connection + mock_ws = Mock() + socket._ws_connection = mock_ws socket._leave_open_topic = AsyncMock() # Add presence callback before subscription From 8abb1d1978526b32c29b62790c080600d41994a6 Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Thu, 25 Sep 2025 17:22:54 -0300 Subject: [PATCH 3/3] style: format code --- src/realtime/src/realtime/_async/channel.py | 22 ++++++++++++++------ src/realtime/src/realtime/_async/presence.py | 2 -- src/realtime/tests/test_presence.py | 14 ++++--------- 3 files changed, 20 insertions(+), 18 deletions(-) diff --git a/src/realtime/src/realtime/_async/channel.py b/src/realtime/src/realtime/_async/channel.py index 3236199c..b6171098 100644 --- a/src/realtime/src/realtime/_async/channel.py +++ b/src/realtime/src/realtime/_async/channel.py @@ -191,12 +191,16 @@ async def subscribe( else: config: RealtimeChannelConfig = self.params["config"] broadcast = config.get("broadcast") - presence = config.get("presence") or RealtimeChannelPresenceConfig(key="",enabled=False) + presence = config.get("presence") or RealtimeChannelPresenceConfig( + key="", enabled=False + ) private = config.get("private", False) - presence_enabled = self.presence._has_callback_attached or presence.get("enabled", False) + presence_enabled = self.presence._has_callback_attached or presence.get( + "enabled", False + ) presence["enabled"] = presence_enabled - + config_payload: Dict[str, Any] = { "config": { "broadcast": broadcast, @@ -434,7 +438,9 @@ def on_presence_sync(self, callback: Callable[[], None]) -> AsyncRealtimeChannel self.presence.on_sync(callback) if self.is_joined: - logger.info(f"channel {self.topic} resubscribe due to change in presence callbacks on joined channel") + logger.info( + f"channel {self.topic} resubscribe due to change in presence callbacks on joined channel" + ) asyncio.create_task(self._resubscribe()) return self @@ -450,7 +456,9 @@ def on_presence_join( """ self.presence.on_join(callback) if self.is_joined: - logger.info(f"channel {self.topic} resubscribe due to change in presence callbacks on joined channel") + logger.info( + f"channel {self.topic} resubscribe due to change in presence callbacks on joined channel" + ) asyncio.create_task(self._resubscribe()) return self @@ -466,7 +474,9 @@ def on_presence_leave( """ self.presence.on_leave(callback) if self.is_joined: - logger.info(f"channel {self.topic} resubscribe due to change in presence callbacks on joined channel") + logger.info( + f"channel {self.topic} resubscribe due to change in presence callbacks on joined channel" + ) asyncio.create_task(self._resubscribe()) return self diff --git a/src/realtime/src/realtime/_async/presence.py b/src/realtime/src/realtime/_async/presence.py index 5716a102..ad376702 100644 --- a/src/realtime/src/realtime/_async/presence.py +++ b/src/realtime/src/realtime/_async/presence.py @@ -21,8 +21,6 @@ class AsyncRealtimePresence: - - @property def _has_callback_attached(self) -> bool: return ( diff --git a/src/realtime/tests/test_presence.py b/src/realtime/tests/test_presence.py index 19c952f2..c0e8c62b 100644 --- a/src/realtime/tests/test_presence.py +++ b/src/realtime/tests/test_presence.py @@ -202,18 +202,12 @@ def test_presence_config_includes_enabled_field(): from realtime.types import RealtimeChannelPresenceConfig # Test creating presence config with enabled field - config: RealtimeChannelPresenceConfig = { - "key": "user123", - "enabled": True - } + config: RealtimeChannelPresenceConfig = {"key": "user123", "enabled": True} assert config["key"] == "user123" assert config["enabled"] == True # Test with enabled False - config_disabled: RealtimeChannelPresenceConfig = { - "key": "", - "enabled": False - } + config_disabled: RealtimeChannelPresenceConfig = {"key": "", "enabled": False} assert config_disabled["key"] == "" assert config_disabled["enabled"] == False @@ -221,7 +215,7 @@ def test_presence_config_includes_enabled_field(): @pytest.mark.asyncio async def test_presence_enabled_when_callbacks_attached(): """Test that presence.enabled is set correctly based on callback attachment.""" - from unittest.mock import Mock, AsyncMock + from unittest.mock import AsyncMock, Mock socket = AsyncRealtimeClient(f"{URL}/realtime/v1", ANON_KEY) channel = socket.channel("test") @@ -257,8 +251,8 @@ async def test_presence_enabled_when_callbacks_attached(): @pytest.mark.asyncio async def test_resubscribe_on_presence_callback_addition(): """Test that channel resubscribes when presence callbacks are added after joining.""" - from unittest.mock import AsyncMock import asyncio + from unittest.mock import AsyncMock socket = AsyncRealtimeClient(f"{URL}/realtime/v1", ANON_KEY) channel = socket.channel("test")