Skip to content

Commit 1ddefac

Browse files
committed
[AIT-316] feat: introduce support for message annotations
- Added `RealtimeAnnotations` class to manage annotation creation, deletion, and subscription on realtime channels. - Introduced `Annotation` and `AnnotationAction` types to encapsulate annotation details and actions. - Extended flags to include `ANNOTATION_PUBLISH` and `ANNOTATION_SUBSCRIBE`. - Refactored data encoding logic into `ably.util.encoding`. - Integrated annotation handling into `RealtimeChannel` and `RestChannel`.
1 parent 3d2c3c4 commit 1ddefac

11 files changed

Lines changed: 212 additions & 230 deletions

File tree

ably/realtime/annotations.py

Lines changed: 16 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
from ably.rest.annotations import RestAnnotations, construct_validate_annotation
77
from ably.transport.websockettransport import ProtocolMessageAction
8-
from ably.types.annotation import Annotation, AnnotationAction
8+
from ably.types.annotation import AnnotationAction
99
from ably.types.channelstate import ChannelState
1010
from ably.types.flags import Flag
1111
from ably.util.eventemitter import EventEmitter
@@ -40,13 +40,13 @@ def __init__(self, channel: RealtimeChannel, connection_manager: ConnectionManag
4040
self.__subscriptions = EventEmitter()
4141
self.__rest_annotations = RestAnnotations(channel)
4242

43-
async def publish(self, msg_or_serial, annotation: dict | Annotation, params: dict=None):
43+
async def publish(self, msg_or_serial, annotation: dict, params: dict | None = None):
4444
"""
4545
Publish an annotation on a message via the realtime connection.
4646
4747
Args:
4848
msg_or_serial: Either a message serial (string) or a Message object
49-
annotation: Dict containing annotation properties (type, name, data, etc.) or Annotation object
49+
annotation: Dict containing annotation properties (type, name, data, etc.)
5050
params: Optional dict of query parameters
5151
5252
Returns:
@@ -84,7 +84,12 @@ async def publish(self, msg_or_serial, annotation: dict | Annotation, params: di
8484
# Send via WebSocket
8585
await self.__connection_manager.send_protocol_message(protocol_message)
8686

87-
async def delete(self, msg_or_serial, annotation: dict | Annotation, params=None, timeout=None):
87+
async def delete(
88+
self,
89+
msg_or_serial,
90+
annotation: dict,
91+
params: dict | None = None,
92+
):
8893
"""
8994
Delete an annotation on a message.
9095
@@ -93,20 +98,16 @@ async def delete(self, msg_or_serial, annotation: dict | Annotation, params=None
9398
9499
Args:
95100
msg_or_serial: Either a message serial (string) or a Message object
96-
annotation: Dict containing annotation properties or Annotation object
101+
annotation: Dict containing annotation properties
97102
params: Optional dict of query parameters
98-
timeout: Optional timeout (not used for realtime, kept for compatibility)
99103
100104
Returns:
101105
None
102106
103107
Raises:
104108
AblyException: If the request fails or inputs are invalid
105109
"""
106-
if isinstance(annotation, Annotation):
107-
annotation_values = annotation.as_dict()
108-
else:
109-
annotation_values = annotation.copy()
110+
annotation_values = annotation.copy()
110111
annotation_values['action'] = AnnotationAction.ANNOTATION_DELETE
111112
return await self.publish(msg_or_serial, annotation_values, params)
112113

@@ -161,13 +162,13 @@ async def subscribe(self, *args):
161162

162163
# Check if ANNOTATION_SUBSCRIBE mode is enabled
163164
if self.__channel.state == ChannelState.ATTACHED:
164-
if not Flag.ANNOTATION_SUBSCRIBE in self.__channel.modes:
165+
if Flag.ANNOTATION_SUBSCRIBE not in self.__channel.modes:
165166
raise AblyException(
166-
"You are trying to add an annotation listener, but you haven't requested the "
167+
message="You are trying to add an annotation listener, but you haven't requested the "
167168
"annotation_subscribe channel mode in ChannelOptions, so this won't do anything "
168169
"(we only deliver annotations to clients who have explicitly requested them)",
169-
93001,
170-
400
170+
code=93001,
171+
status_code=400,
171172
)
172173

173174
def unsubscribe(self, *args):
@@ -219,7 +220,7 @@ def _process_incoming(self, incoming_annotations):
219220
annotation_type = annotation.type or ''
220221
self.__subscriptions._emit(annotation_type, annotation)
221222

222-
async def get(self, msg_or_serial, params=None):
223+
async def get(self, msg_or_serial, params: dict | None = None):
223224
"""
224225
Retrieve annotations for a message with pagination support.
225226

ably/realtime/channel.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from ably.rest.channel import Channels as RestChannels
1212
from ably.transport.websockettransport import ProtocolMessageAction
1313
from ably.types.annotation import Annotation
14+
from ably.types.channelmode import ChannelMode, decode_channel_mode, encode_channel_mode
1415
from ably.types.channeloptions import ChannelOptions
1516
from ably.types.channelstate import ChannelState, ChannelStateChange
1617
from ably.types.flags import Flag, has_flag
@@ -21,7 +22,6 @@
2122
from ably.util.eventemitter import EventEmitter
2223
from ably.util.exceptions import AblyException, IncompatibleClientIdException
2324
from ably.util.helper import Timer, is_callable_or_coroutine, validate_message_size
24-
from ably.types.channelmode import ChannelMode, decode_channel_mode, encode_channel_mode
2525

2626
if TYPE_CHECKING:
2727
from ably.realtime.realtime import AblyRealtime
@@ -68,7 +68,7 @@ def __init__(self, realtime: AblyRealtime, name: str, channel_options: ChannelOp
6868
self.__error_reason: AblyException | None = None
6969
self.__channel_options = channel_options or ChannelOptions()
7070
self.__params: dict[str, str] | None = None
71-
self.__modes: list[ChannelMode] = list() # Channel mode flags from ATTACHED message
71+
self.__modes: list[ChannelMode] = [] # Channel mode flags from ATTACHED message
7272

7373
# Delta-specific fields for RTL19/RTL20 compliance
7474
vcdiff_decoder = self.__realtime.options.vcdiff_decoder if self.__realtime.options.vcdiff_decoder else None
@@ -911,6 +911,10 @@ def presence(self):
911911
"""Get the RealtimePresence object for this channel"""
912912
return self.__presence
913913

914+
@property
915+
def annotations(self) -> RealtimeAnnotations:
916+
return self._Channel__annotations
917+
914918
@property
915919
def modes(self):
916920
"""Get the list of channel modes"""

ably/rest/annotations.py

Lines changed: 27 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from ably.http.paginatedresult import PaginatedResult, format_params
1010
from ably.types.annotation import (
1111
Annotation,
12+
AnnotationAction,
1213
make_annotation_response_handler,
1314
)
1415
from ably.types.message import Message
@@ -48,7 +49,7 @@ def serial_from_msg_or_serial(msg_or_serial):
4849
return message_serial
4950

5051

51-
def construct_validate_annotation(msg_or_serial, annotation: dict | Annotation):
52+
def construct_validate_annotation(msg_or_serial, annotation: dict):
5253
"""
5354
Construct and validate an Annotation from input values.
5455
@@ -71,11 +72,8 @@ def construct_validate_annotation(msg_or_serial, annotation: dict | Annotation):
7172
status_code=400,
7273
code=40003,
7374
)
74-
elif isinstance(annotation, Annotation):
75-
annotation_values = annotation.as_dict()
76-
else:
77-
annotation_values = annotation
7875

76+
annotation_values = annotation.copy()
7977
annotation_values['message_serial'] = message_serial
8078

8179
return Annotation.from_values(annotation_values)
@@ -108,23 +106,27 @@ def __base_path_for_serial(self, serial):
108106
channel_path = '/channels/{}/'.format(parse.quote_plus(self.__channel.name, safe=':'))
109107
return channel_path + 'messages/' + parse.quote_plus(serial, safe=':') + '/annotations'
110108

111-
async def publish(self, msg_or_serial, annotation_values, params=None, timeout=None):
109+
async def publish(
110+
self,
111+
msg_or_serial,
112+
annotation: dict | Annotation,
113+
params: dict | None = None,
114+
):
112115
"""
113116
Publish an annotation on a message.
114117
115118
Args:
116119
msg_or_serial: Either a message serial (string) or a Message object
117-
annotation_values: Dict containing annotation properties (type, name, data, etc.)
120+
annotation: Dict containing annotation properties (type, name, data, etc.) or Annotation object
118121
params: Optional dict of query parameters
119-
timeout: Optional timeout for the HTTP request
120122
121123
Returns:
122124
None
123125
124126
Raises:
125127
AblyException: If the request fails or inputs are invalid
126128
"""
127-
annotation = construct_validate_annotation(msg_or_serial, annotation_values)
129+
annotation = construct_validate_annotation(msg_or_serial, annotation)
128130

129131
# Convert to wire format
130132
request_body = annotation.as_dict(binary=self.__channel.ably.options.use_binary_protocol)
@@ -145,9 +147,14 @@ async def publish(self, msg_or_serial, annotation_values, params=None, timeout=N
145147
path += '?' + parse.urlencode(params)
146148

147149
# Send request
148-
await self.__channel.ably.http.post(path, body=request_body, timeout=timeout)
149-
150-
async def delete(self, msg_or_serial, annotation_values, params=None, timeout=None):
150+
await self.__channel.ably.http.post(path, body=request_body)
151+
152+
async def delete(
153+
self,
154+
msg_or_serial,
155+
annotation: dict | Annotation,
156+
params: dict | None = None,
157+
):
151158
"""
152159
Delete an annotation on a message.
153160
@@ -156,9 +163,8 @@ async def delete(self, msg_or_serial, annotation_values, params=None, timeout=No
156163
157164
Args:
158165
msg_or_serial: Either a message serial (string) or a Message object
159-
annotation_values: Dict containing annotation properties
166+
annotation: Dict containing annotation properties or Annotation object
160167
params: Optional dict of query parameters
161-
timeout: Optional timeout for the HTTP request
162168
163169
Returns:
164170
None
@@ -167,11 +173,14 @@ async def delete(self, msg_or_serial, annotation_values, params=None, timeout=No
167173
AblyException: If the request fails or inputs are invalid
168174
"""
169175
# Set action to delete
170-
annotation_values = annotation_values.copy()
171-
annotation_values['action'] = 'annotation.delete'
172-
return await self.publish(msg_or_serial, annotation_values, params, timeout)
176+
if isinstance(annotation, Annotation):
177+
annotation_values = annotation.as_dict()
178+
else:
179+
annotation_values = annotation.copy()
180+
annotation_values['action'] = AnnotationAction.ANNOTATION_DELETE
181+
return await self.publish(msg_or_serial, annotation_values, params)
173182

174-
async def get(self, msg_or_serial, params=None):
183+
async def get(self, msg_or_serial, params: dict | None = None):
175184
"""
176185
Retrieve annotations for a message with pagination support.
177186

ably/rest/auth.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@ def __init__(self, ably: AblyRest | AblyRealtime, options: Options):
9090
async def get_auth_transport_param(self):
9191
auth_credentials = {}
9292
if self.auth_options.client_id:
93-
auth_credentials["client_id"] = self.auth_options.client_id
93+
auth_credentials["clientId"] = self.auth_options.client_id
9494
if self.__auth_mechanism == Auth.Method.BASIC:
9595
key_name = self.__auth_options.key_name
9696
key_secret = self.__auth_options.key_secret

ably/rest/channel.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@
3131

3232

3333
class Channel:
34+
__annotations: RestAnnotations
35+
3436
def __init__(self, ably, name, options):
3537
self.__ably = ably
3638
self.__name = name
@@ -366,7 +368,7 @@ def presence(self):
366368
return self.__presence
367369

368370
@property
369-
def annotations(self):
371+
def annotations(self) -> RestAnnotations:
370372
return self.__annotations
371373

372374
@options.setter

ably/transport/websockettransport.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,7 @@ async def on_protocol_message(self, msg):
189189
ProtocolMessageAction.DETACHED,
190190
ProtocolMessageAction.MESSAGE,
191191
ProtocolMessageAction.PRESENCE,
192+
ProtocolMessageAction.ANNOTATION,
192193
ProtocolMessageAction.SYNC
193194
):
194195
self.connection_manager.on_channel_message(msg)

ably/types/annotation.py

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -122,22 +122,17 @@ def as_dict(self, binary=False):
122122
123123
Note: Annotations are not encrypted as they need to be parsed by the server.
124124
"""
125-
# Encode data
126-
encoded = encode_data(self.data, self._encoding_array, binary)
127-
128125
request_body = {
129126
'action': int(self.action) if self.action is not None else None,
130127
'serial': self.serial,
131128
'messageSerial': self.message_serial,
132129
'type': self.type, # Annotation type (not data type)
133130
'name': self.name,
134131
'count': self.count,
135-
'data': encoded.get('data'),
136-
'encoding': encoded.get('encoding', ''),
137-
'dataType': encoded.get('type'), # Data type (not annotation type)
138132
'clientId': self.client_id or None,
139133
'timestamp': self.timestamp or None,
140134
'extras': self.extras,
135+
**encode_data(self.data, self._encoding_array, binary)
141136
}
142137

143138
# None values aren't included

ably/types/channeloptions.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,9 @@
22

33
from typing import Any
44

5+
from ably.types.channelmode import ChannelMode
56
from ably.util.crypto import CipherParams
67
from ably.util.exceptions import AblyException
7-
from ably.types.channelmode import ChannelMode
88

99

1010
class ChannelOptions:
@@ -18,7 +18,12 @@ class ChannelOptions:
1818
Channel parameters that configure the behavior of the channel.
1919
"""
2020

21-
def __init__(self, cipher: CipherParams | None = None, params: dict | None = None, modes: list[ChannelMode] | None = None):
21+
def __init__(
22+
self,
23+
cipher: CipherParams | None = None,
24+
params: dict | None = None,
25+
modes: list[ChannelMode] | None = None
26+
):
2227
self.__cipher = cipher
2328
self.__params = params
2429
self.__modes = modes

0 commit comments

Comments
 (0)