Skip to content

Commit 42c0fd4

Browse files
committed
[AIT-316] feat: annotations in summary
1 parent 6120872 commit 42c0fd4

File tree

7 files changed

+138
-66
lines changed

7 files changed

+138
-66
lines changed

ably/realtime/annotations.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@
66
from ably.rest.annotations import RestAnnotations, construct_validate_annotation
77
from ably.transport.websockettransport import ProtocolMessageAction
88
from ably.types.annotation import Annotation, AnnotationAction
9+
from ably.types.channelmode import ChannelMode
910
from ably.types.channelstate import ChannelState
10-
from ably.types.flags import Flag
1111
from ably.util.eventemitter import EventEmitter
1212
from ably.util.exceptions import AblyException
1313
from ably.util.helper import is_callable_or_coroutine
@@ -164,7 +164,7 @@ async def subscribe(self, *args):
164164

165165
# Check if ANNOTATION_SUBSCRIBE mode is enabled
166166
if self.__channel.state == ChannelState.ATTACHED:
167-
if Flag.ANNOTATION_SUBSCRIBE not in self.__channel.modes:
167+
if ChannelMode.ANNOTATION_SUBSCRIBE not in self.__channel.modes:
168168
if annotation_type is not None:
169169
self.__subscriptions.off(annotation_type, listener)
170170
else:

ably/rest/annotations.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ def serial_from_msg_or_serial(msg_or_serial):
4141
if not message_serial or not isinstance(message_serial, str):
4242
raise AblyException(
4343
message='First argument of annotations.publish() must be either a Message '
44-
'(or at least an object with a string `serial` property) or a message serial (string)',
44+
'or a message serial (string)',
4545
status_code=400,
4646
code=40003,
4747
)
@@ -67,7 +67,7 @@ def construct_validate_annotation(msg_or_serial, annotation: Annotation) -> Anno
6767

6868
if not annotation or not isinstance(annotation, Annotation):
6969
raise AblyException(
70-
message='Second argument of annotations.publish() must be a dict or Annotation '
70+
message='Second argument of annotations.publish() must be an Annotation '
7171
'(the intended annotation to publish)',
7272
status_code=400,
7373
code=40003,

ably/types/channeloptions.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ def params(self) -> dict[str, str] | None:
4343

4444
@property
4545
def modes(self) -> list[ChannelMode] | None:
46-
"""Get channel parameters"""
46+
"""Get channel modes"""
4747
return self.__modes
4848

4949
def __eq__(self, other):

ably/types/message.py

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,42 @@
1111
log = logging.getLogger(__name__)
1212

1313

14+
class MessageAnnotations:
15+
"""
16+
Contains information about annotations associated with a particular message.
17+
"""
18+
19+
def __init__(self, summary=None):
20+
"""
21+
Args:
22+
summary: A dict mapping annotation types to their aggregated values.
23+
The keys are annotation types (e.g., "reaction:distinct.v1").
24+
The values depend on the aggregation method of the annotation type.
25+
"""
26+
# TM8a: Ensure summary exists
27+
self.__summary = summary if summary is not None else {}
28+
29+
@property
30+
def summary(self):
31+
"""A dict of annotation type to aggregated annotation values."""
32+
return self.__summary
33+
34+
def as_dict(self):
35+
"""Convert MessageAnnotations to dictionary format."""
36+
return {
37+
'summary': self.summary,
38+
}
39+
40+
@staticmethod
41+
def from_dict(obj):
42+
"""Create MessageAnnotations from dictionary."""
43+
if obj is None:
44+
return MessageAnnotations()
45+
return MessageAnnotations(
46+
summary=obj.get('summary'),
47+
)
48+
49+
1450
class MessageVersion:
1551
"""
1652
Contains the details regarding the current version of the message - including when it was updated and by whom.
@@ -111,6 +147,7 @@ def __init__(self,
111147
serial=None, # TM2r
112148
action=None, # TM2j
113149
version=None, # TM2s
150+
annotations=None, # TM2t
114151
):
115152

116153
super().__init__(encoding)
@@ -126,6 +163,7 @@ def __init__(self,
126163
self.__serial = serial
127164
self.__action = action
128165
self.__version = version
166+
self.__annotations = annotations
129167

130168
def __eq__(self, other):
131169
if isinstance(other, Message):
@@ -190,6 +228,10 @@ def serial(self):
190228
def action(self):
191229
return self.__action
192230

231+
@property
232+
def annotations(self):
233+
return self.__annotations
234+
193235
def encrypt(self, channel_cipher):
194236
if isinstance(self.data, CipherData):
195237
return
@@ -234,6 +276,7 @@ def as_dict(self, binary=False):
234276
'version': self.version.as_dict() if self.version else None,
235277
'serial': self.serial,
236278
'action': int(self.action) if self.action is not None else None,
279+
'annotations': self.annotations.as_dict() if self.annotations else None,
237280
**encode_data(self.data, self._encoding_array, binary),
238281
}
239282

@@ -278,6 +321,31 @@ def from_encoded(obj, cipher=None, context=None):
278321
# TM2s
279322
version = MessageVersion(serial=serial, timestamp=timestamp)
280323

324+
# Parse annotations from the wire format
325+
annotations_obj = obj.get('annotations')
326+
if annotations_obj is None:
327+
# TM2u: Always initialize annotations with empty summary
328+
annotations = MessageAnnotations()
329+
else:
330+
annotations = MessageAnnotations.from_dict(annotations_obj)
331+
332+
# Process annotation summary entries to ensure clipped fields are set
333+
if annotations and annotations.summary:
334+
for annotation_type, summary_entry in annotations.summary.items():
335+
# TM7c1c, TM7d1c: For distinct.v1, unique.v1, multiple.v1
336+
if (annotation_type.endswith(':distinct.v1') or
337+
annotation_type.endswith(':unique.v1') or
338+
annotation_type.endswith(':multiple.v1')):
339+
# These types have entries that need clipped field
340+
if isinstance(summary_entry, dict):
341+
for _entry_key, entry_value in summary_entry.items():
342+
if isinstance(entry_value, dict) and 'clipped' not in entry_value:
343+
entry_value['clipped'] = False
344+
# TM7c1c: For flag.v1
345+
elif annotation_type.endswith(':flag.v1'):
346+
if isinstance(summary_entry, dict) and 'clipped' not in summary_entry:
347+
summary_entry['clipped'] = False
348+
281349
return Message(
282350
id=id,
283351
name=name,
@@ -288,6 +356,7 @@ def from_encoded(obj, cipher=None, context=None):
288356
serial=serial,
289357
action=action,
290358
version=version,
359+
annotations=annotations,
291360
**decoded_data
292361
)
293362

test/ably/realtime/realtimeannotations_test.py

Lines changed: 34 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
import pytest
77

88
from ably import AblyException
9-
from ably.types.annotation import AnnotationAction
9+
from ably.types.annotation import Annotation, AnnotationAction
1010
from ably.types.channelmode import ChannelMode
1111
from ably.types.channeloptions import ChannelOptions
1212
from ably.types.message import MessageAction
@@ -71,10 +71,10 @@ def on_message(msg):
7171
await channel.subscribe('message', on_message)
7272

7373
# Publish annotation using realtime
74-
await channel.annotations.publish(publish_result.serials[0], {
75-
'type': 'reaction:distinct.v1',
76-
'name': '👍'
77-
})
74+
await channel.annotations.publish(publish_result.serials[0], Annotation(
75+
type='reaction:distinct.v1',
76+
name='👍'
77+
))
7878

7979
# Wait for annotation
8080
annotation = await annotation_future
@@ -88,6 +88,7 @@ def on_message(msg):
8888
summary = await message_summary
8989
assert summary.action == MessageAction.MESSAGE_SUMMARY
9090
assert summary.serial == publish_result.serials[0]
91+
assert summary.annotations.summary['reaction:distinct.v1']['👍']['total'] == 1
9192

9293
# Try again but with REST publish
9394
annotation_future2 = asyncio.Future()
@@ -98,10 +99,10 @@ async def on_annotation2(annotation):
9899

99100
await channel.annotations.subscribe(on_annotation2)
100101

101-
await rest_channel.annotations.publish(publish_result.serials[0], {
102-
'type': 'reaction:distinct.v1',
103-
'name': '😕'
104-
})
102+
await rest_channel.annotations.publish(publish_result.serials[0], Annotation(
103+
type='reaction:distinct.v1',
104+
name='😕'
105+
))
105106

106107
annotation = await annotation_future2
107108
assert annotation.action == AnnotationAction.ANNOTATION_CREATE
@@ -130,10 +131,10 @@ async def test_get_all_annotations_for_a_message(self):
130131
# Publish multiple annotations
131132
emojis = ['👍', '😕', '👎']
132133
for emoji in emojis:
133-
await channel.annotations.publish(publish_result.serials[0], {
134-
'type': 'reaction:distinct.v1',
135-
'name': emoji
136-
})
134+
await channel.annotations.publish(publish_result.serials[0], Annotation(
135+
type='reaction:distinct.v1',
136+
name=emoji
137+
))
137138

138139
# Wait for all annotations to appear
139140
annotations = []
@@ -191,10 +192,10 @@ async def on_reaction(annotation):
191192
# Publish message and annotation
192193
publish_result = await channel.publish('message', 'test')
193194

194-
await channel.annotations.publish(publish_result.serials[0], {
195-
'type': 'reaction:distinct.v1',
196-
'name': '👍'
197-
})
195+
await channel.annotations.publish(publish_result.serials[0], Annotation(
196+
type='reaction:distinct.v1',
197+
name='👍'
198+
))
198199

199200
# Should receive the annotation
200201
annotation = await reaction_future
@@ -227,10 +228,10 @@ async def on_annotation(annotation):
227228
# Publish message and first annotation
228229
publish_result = await channel.publish('message', 'test')
229230

230-
await channel.annotations.publish(publish_result.serials[0], {
231-
'type': 'reaction:distinct.v1',
232-
'name': '👍'
233-
})
231+
await channel.annotations.publish(publish_result.serials[0], Annotation(
232+
type='reaction:distinct.v1',
233+
name='👍'
234+
))
234235

235236
# Wait for the first annotation to appear
236237
await annotation_future.get()
@@ -242,10 +243,10 @@ async def on_annotation(annotation):
242243
await channel.annotations.subscribe(lambda annotation: annotation_future.set_result(annotation))
243244

244245
# Publish another annotation
245-
await channel.annotations.publish(publish_result.serials[0], {
246-
'type': 'reaction:distinct.v1',
247-
'name': '😕'
248-
})
246+
await channel.annotations.publish(publish_result.serials[0], Annotation(
247+
type='reaction:distinct.v1',
248+
name='😕'
249+
))
249250

250251
# Wait for the second annotation to appear in another listener
251252
await annotation_future.get()
@@ -287,10 +288,10 @@ async def on_annotation(annotation):
287288
await channel.publish('message', 'test')
288289
message = await message_future
289290

290-
await channel.annotations.publish(message.serial, {
291-
'type': 'reaction:distinct.v1',
292-
'name': '👍'
293-
})
291+
await channel.annotations.publish(message.serial, Annotation(
292+
type='reaction:distinct.v1',
293+
name='👍'
294+
))
294295

295296
await annotation_future.get()
296297

@@ -299,10 +300,10 @@ async def on_annotation(annotation):
299300
assert annotations_received[0].action == AnnotationAction.ANNOTATION_CREATE
300301

301302
# Delete the annotation
302-
await channel.annotations.delete(message.serial, {
303-
'type': 'reaction:distinct.v1',
304-
'name': '👍'
305-
})
303+
await channel.annotations.delete(message.serial, Annotation(
304+
type='reaction:distinct.v1',
305+
name='👍'
306+
))
306307

307308
# Wait for delete annotation
308309
await annotation_future.get()

0 commit comments

Comments
 (0)