Skip to content

Commit 75e08e4

Browse files
owenpearsonclaude
andcommitted
objects: make compact_json the only snapshot
ably-java and ably-cocoa expose only compactJson, and RTTS3f and RTTS7d let a typed SDK leave compact out. In Python the two would return dicts that differ only once the data holds bytes or a shared map, and json.dumps raises on compact's result at exactly that point. LODR-061 records the choice. compact_json returns JsonValue, exported with JsonObject from ably.pubsub.server, and LiveMapInstance and LiveCounterInstance narrow it per RTTS7a. The UTS compact tests read through compact_json, recorded as adaptation S-6. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent 20760c3 commit 75e08e4

10 files changed

Lines changed: 146 additions & 84 deletions

File tree

‎ably/pubsub/objects/batch.py‎

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@
4545

4646
if TYPE_CHECKING:
4747
from ably.pubsub.objects.realtimeobject import RealtimeObject
48-
from ably.pubsub.objects.valuetypes import LiveMapValue, Primitive, T
48+
from ably.pubsub.objects.valuetypes import JsonValue, LiveMapValue, Primitive, T
4949

5050
# RTBC16b: builds the ObjectMessages of one queued write when the batch is flushed
5151
MessageConstructor = Callable[[], Awaitable[list[ObjectMessage]]]
@@ -200,13 +200,7 @@ def get(self, key: str) -> BatchContext | None:
200200
return None
201201
return self._root_context.wrap_instance(instance) # RTBC4e
202202

203-
def compact(self) -> Any:
204-
"""RTBC10: `Instance.compact` on the wrapped instance."""
205-
self._realtime_object._check_access_preconditions() # RTBC10a
206-
self._throw_if_closed() # RTBC10b
207-
return self._instance.compact() # RTBC10c
208-
209-
def compact_json(self) -> Any:
203+
def compact_json(self) -> JsonValue:
210204
"""RTBC11: `Instance.compact_json` on the wrapped instance."""
211205
self._realtime_object._check_access_preconditions() # RTBC11a
212206
self._throw_if_closed() # RTBC11b

‎ably/pubsub/objects/instance.py‎

Lines changed: 43 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@
2929
from ably.pubsub.objects.liveobject import LiveObjectUpdate
3030
from ably.pubsub.objects.realtimeobject import RealtimeObject
3131
from ably.pubsub.objects.subscription import Subscription
32-
from ably.pubsub.objects.valuetypes import LiveMapValue, Primitive, T
32+
from ably.pubsub.objects.valuetypes import JsonObject, JsonValue, LiveMapValue, Primitive, T
3333

3434
# The types `value(expected)` accepts as `expected`, and the category each one selects
3535
_EXPECTED_VALUE_TYPES: tuple[tuple[type, ValueType], ...] = (
@@ -103,36 +103,37 @@ def primitive_value(value: Any, value_type: ValueType | None = None) -> Any:
103103
return value
104104

105105

106-
def compact_value(value: Any, *, for_json: bool = False) -> Any:
107-
"""RTPO13, RTPO14: a plain snapshot of a resolved value.
106+
def compact_json_value(value: Any) -> JsonValue:
107+
"""RTPO14, RTINS11: a JSON-serializable snapshot of a resolved value, a map as
108+
`compact_json_map` builds it."""
109+
if isinstance(value, InternalLiveMap):
110+
return compact_json_map(value)
111+
return _compact_json_leaf(value)
108112

109-
A map becomes a dict of its entries that are not tombstoned (RTPO13c1), nested maps
110-
becoming nested dicts (RTPO13c2) and counters their values (RTPO13c3). Maps are visited
111-
depth first in entry order, and a map reached again in the same snapshot, through a
112-
cycle or a second reference, is the dict already built for it (RTPO13c5). With
113-
`for_json` it is `{'objectId': id}` instead (RTPO14b2), and binary is a base64 string
114-
(RTPO14b1), so that the result is JSON-serializable.
115-
"""
116-
if not isinstance(value, InternalLiveMap):
117-
return _compact_leaf(value, for_json)
118113

119-
result: dict[str, Any] = {}
120-
built: dict[str, dict[str, Any]] = {value.object_id: result}
114+
def compact_json_map(live_map: InternalLiveMap) -> JsonObject:
115+
"""RTPO13c, RTPO14b: a JSON object of a map's entries that are not tombstoned (RTPO13c1).
116+
117+
Nested maps become nested objects (RTPO13c2) and counters their values (RTPO13c3). Maps
118+
are visited depth first in entry order, and a map reached again in the same snapshot,
119+
through a cycle or a second reference, is `{'objectId': id}` (RTPO13c5, RTPO14b2).
120+
"""
121+
result: JsonObject = {}
122+
visited = {live_map.object_id}
121123
# A stack in place of recursion, so that the depth of the tree is not limited by the
122-
# interpreter's. Each item is a dict being built and the entries of its map still to add.
123-
stack = [(result, iter(value.entries()))]
124+
# interpreter's. Each item is an object being built and the entries of its map still to add.
125+
stack = [(result, iter(live_map.entries()))]
124126
while stack:
125127
target, entries = stack[-1]
126128
for key, entry_value in entries:
127129
if not isinstance(entry_value, InternalLiveMap):
128-
target[key] = _compact_leaf(entry_value, for_json)
130+
target[key] = _compact_json_leaf(entry_value)
129131
continue
130-
seen = built.get(entry_value.object_id)
131-
if seen is not None:
132-
target[key] = {'objectId': entry_value.object_id} if for_json else seen
132+
if entry_value.object_id in visited:
133+
target[key] = {'objectId': entry_value.object_id}
133134
continue
134-
child: dict[str, Any] = {}
135-
built[entry_value.object_id] = child
135+
child: JsonObject = {}
136+
visited.add(entry_value.object_id)
136137
target[key] = child
137138
stack.append((child, iter(entry_value.entries())))
138139
break
@@ -141,11 +142,14 @@ def compact_value(value: Any, *, for_json: bool = False) -> Any:
141142
return result
142143

143144

144-
def _compact_leaf(value: Any, for_json: bool) -> Any:
145-
"""RTPO13c3, RTPO13c4, RTPO13d, RTPO13e, RTPO14b1: the snapshot of a value that is not a map."""
145+
def _compact_json_leaf(value: Any) -> JsonValue:
146+
"""RTPO13c3, RTPO13c4, RTPO13d, RTPO13e, RTPO14b1: the snapshot of a value that is not a map.
147+
148+
A counter is its value, binary a base64 string, and a JSON object or array a copy.
149+
"""
146150
if isinstance(value, InternalLiveCounter):
147151
return value.value()
148-
if for_json and isinstance(value, (bytes, bytearray)):
152+
if isinstance(value, (bytes, bytearray)):
149153
return base64.b64encode(value).decode('ascii')
150154
if isinstance(value, (dict, list)):
151155
return copy.deepcopy(value)
@@ -208,15 +212,11 @@ def get(self, key: str) -> Instance | None:
208212
return None
209213
return Instance._wrap(self._realtime_object, value) # RTINS5c
210214

211-
def compact(self) -> Any:
212-
"""RTINS10: a plain snapshot of the wrapped value."""
213-
self._realtime_object._check_access_preconditions() # RTINS10a
214-
return compact_value(self._value) # RTINS10b
215-
216-
def compact_json(self) -> Any:
217-
"""RTINS11: `compact`, with binary as base64 and cycles as `{'objectId': ...}`. Never None."""
215+
def compact_json(self) -> JsonValue:
216+
"""RTINS11: a JSON-serializable snapshot of the wrapped value, as
217+
`PathObject.compact_json` builds it. Never None (RTINS11c)."""
218218
self._realtime_object._check_access_preconditions() # RTINS11a
219-
return compact_value(self._value, for_json=True) # RTINS11b
219+
return compact_json_value(self._value) # RTINS11b
220220

221221
def as_live_map(self) -> LiveMapInstance:
222222
"""RTTS9a: this instance as a map. Raises AblyException 92007 if it wraps something else."""
@@ -266,6 +266,11 @@ def batch(self) -> Batch[LiveMapBatchContext]:
266266

267267
return Batch(self._realtime_object, lambda: self._value, LiveMapBatchContext, f'object {self.id!r}')
268268

269+
def compact_json(self) -> JsonObject:
270+
"""RTINS11, RTTS7a1: a JSON-serializable snapshot of the wrapped map."""
271+
self._realtime_object._check_access_preconditions() # RTINS11a
272+
return compact_json_map(self._value) # RTINS11b
273+
269274
def entries(self) -> list[tuple[str, Instance]]:
270275
"""RTINS6: `(key, instance)` for each entry of the map.
271276
@@ -332,6 +337,11 @@ def value(self) -> float:
332337
self._realtime_object._check_access_preconditions() # RTINS4a
333338
return self._value.value()
334339

340+
def compact_json(self) -> float:
341+
"""RTINS11, RTTS7a3: the wrapped counter's value."""
342+
self._realtime_object._check_access_preconditions() # RTINS11a
343+
return self._value.value() # RTINS11b
344+
335345
async def increment(self, amount: float = 1) -> None:
336346
"""RTINS14: increments the wrapped counter by `amount`."""
337347
self._realtime_object._check_write_preconditions() # RTINS14b

‎ably/pubsub/objects/pathobject.py‎

Lines changed: 12 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222

2323
from ably.pubsub.objects.instance import (
2424
Instance,
25-
compact_value,
25+
compact_json_value,
2626
expected_value_type,
2727
primitive_value,
2828
value_type_of,
@@ -37,7 +37,7 @@
3737
from ably.pubsub.objects.enums import ValueType
3838
from ably.pubsub.objects.realtimeobject import RealtimeObject
3939
from ably.pubsub.objects.subscription import Subscription
40-
from ably.pubsub.objects.valuetypes import LiveMapValue, Primitive, T
40+
from ably.pubsub.objects.valuetypes import JsonValue, LiveMapValue, Primitive, T
4141

4242

4343
@dataclass
@@ -112,21 +112,19 @@ def instance(self) -> Instance | None:
112112
return None # RTPO8e
113113
return Instance._wrap(self._realtime_object, resolved) # RTPO8c, RTPO8f
114114

115-
def compact(self) -> Any:
116-
"""RTPO13: a plain snapshot of what the path resolves to, or None."""
117-
self._realtime_object._check_access_preconditions() # RTPO13a
118-
resolved = self._resolve() # RTPO13b
119-
if resolved is None:
120-
return None # RTPO13f
121-
return compact_value(resolved) # RTPO13c, RTPO13d, RTPO13e
115+
def compact_json(self) -> JsonValue:
116+
"""RTPO14: a JSON-serializable snapshot of what the path resolves to, or None.
122117
123-
def compact_json(self) -> Any:
124-
"""RTPO14: `compact`, with binary as base64 and cycles as `{'objectId': ...}`."""
118+
A map is a JSON object of its entries that are not tombstoned, nested maps becoming
119+
nested objects and counters their values. Binary is a base64 string, and a map
120+
reached again in the same snapshot, through a cycle or a second reference, is
121+
`{'objectId': id}`.
122+
"""
125123
self._realtime_object._check_access_preconditions() # RTPO14a
126-
resolved = self._resolve()
124+
resolved = self._resolve() # RTPO13b
127125
if resolved is None:
128-
return None
129-
return compact_value(resolved, for_json=True) # RTPO14b
126+
return None # RTPO13f
127+
return compact_json_value(resolved) # RTPO14b
130128

131129
def subscribe(self, listener: Callable[[PathObjectSubscriptionEvent], None], *,
132130
depth: int | None = None) -> Subscription:

‎ably/pubsub/objects/valuetypes.py‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
import json
1414
import math
1515
from dataclasses import dataclass
16-
from typing import Any, TypeVar, Union
16+
from typing import Any, Dict, List, TypeVar, Union
1717

1818
from ably.pubsub.objects.objectid import generate_nonce, generate_object_id
1919
from ably.pubsub.objects.objectmessage import (
@@ -37,6 +37,10 @@
3737

3838
T = TypeVar('T', bound=Primitive)
3939

40+
# A JSON value and a JSON object, as `compact_json` returns them (RTPO14, RTINS11)
41+
JsonValue = Union[str, float, bool, None, List['JsonValue'], Dict[str, 'JsonValue']]
42+
JsonObject = Dict[str, JsonValue]
43+
4044

4145
@dataclass(frozen=True)
4246
class LiveCounter:

‎ably/pubsub/server/__init__.py‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,14 @@
4848
from ably.pubsub.objects.publicmessage import ObjectMessage, ObjectOperation
4949
from ably.pubsub.objects.realtimeobject import RealtimeObject
5050
from ably.pubsub.objects.subscription import StatusSubscription, Subscription
51-
from ably.pubsub.objects.valuetypes import LiveCounter, LiveMap, LiveMapValue, Primitive
51+
from ably.pubsub.objects.valuetypes import (
52+
JsonObject,
53+
JsonValue,
54+
LiveCounter,
55+
LiveMap,
56+
LiveMapValue,
57+
Primitive,
58+
)
5259
from ably.pubsub.prototypes import PubSubHttpClient, PubSubRealtimeClient
5360
from ably.pubsub.realtime.channel import Channels as RealtimeChannels
5461
from ably.pubsub.realtime.connection import Connection
@@ -291,6 +298,8 @@ def create_realtime_client(**kwargs) -> PubSubRealtimeClient:
291298
'IncompatibleClientIdException',
292299
'Instance',
293300
'InstanceSubscriptionEvent',
301+
'JsonObject',
302+
'JsonValue',
294303
'LiveCounter',
295304
'LiveCounterBatchContext',
296305
'LiveCounterInstance',

‎test/unit/objects/batch_test.py‎

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -574,14 +574,13 @@ async def test_rtbc6_rtbc9_map_enumeration():
574574
assert entries[2][1].get('theme').as_primitive().value() == 'dark'
575575

576576

577-
async def test_rtbc10_rtbc11_compact():
578-
"""RTBC10, RTBC11: `compact` and `compact_json` snapshot the wrapped value as `Instance` does."""
577+
async def test_rtbc11_compact_json():
578+
"""RTBC11: `compact_json` snapshots the wrapped value as `Instance` does."""
579579
(client, channel, root, mock_ws), published = await _synced()
580580

581581
async with root.batch() as ctx:
582-
assert ctx.get('profile').compact() == {
582+
assert ctx.get('profile').compact_json() == {
583583
'email': 'alice@example.com', 'nested_counter': 5, 'prefs': {'theme': 'dark'}}
584-
assert ctx.compact()['avatar'] == b'\x01\x02\x03'
585584
assert ctx.compact_json()['avatar'] == 'AQID'
586585
assert ctx.get('score').compact_json() == 100
587586

@@ -635,7 +634,6 @@ class _Contexts(NamedTuple):
635634
# A call of each context method that reads the objects, and of each that writes
636635
_READS = {
637636
'get': lambda c: c.root.get('name'),
638-
'compact': lambda c: c.root.compact(),
639637
'compact_json': lambda c: c.root.compact_json(),
640638
'entries': lambda c: c.root.entries(),
641639
'keys': lambda c: c.root.keys(),

‎test/unit/objects/typed_views_test.py‎

Lines changed: 41 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
"""The typed views: `type()` and `exists()`, the unchecked path views and the checked instance views.
1+
"""The typed views: `type()` and `exists()`, the unchecked path views, the checked instance views
2+
and the snapshots `compact_json` takes through them.
23
34
Spec points: RTTS2-RTTS10 (ably/specification#491), as LODR-061 binds them: one
45
`PrimitivePathObject` and `PrimitiveInstance` whose `value(expected)` stands for the six
@@ -9,6 +10,7 @@
910
Each test drives the standard synced pool over the mock websocket.
1011
"""
1112

13+
import json
1214
import typing
1315

1416
import pytest
@@ -352,11 +354,45 @@ async def test_rtts10_instance_id_is_a_property():
352354
assert root.get('name').instance().id is None
353355

354356

357+
async def test_rtts7a_instance_compact_json_is_of_the_wrapped_type():
358+
"""RTINS11, RTTS7a1, RTTS7a3: a map instance's snapshot is a JSON object, a counter
359+
instance's is its value as a float, and a primitive instance's is the primitive."""
360+
root = await _synced_root()
361+
362+
profile = root.get('profile').instance().compact_json()
363+
score = root.get('score').instance().compact_json()
364+
365+
assert profile == {'email': 'alice@example.com', 'nested_counter': 5, 'prefs': {'theme': 'dark'}}
366+
assert type(score) is float
367+
assert score == 100
368+
assert root.get('name').instance().compact_json() == 'Alice'
369+
assert root.get('avatar').instance().compact_json() == 'AQID'
370+
371+
372+
async def test_rtpo14b_compact_json_is_json_serializable():
373+
"""RTPO14b: binary is a base64 string, and a map reached a second time in one snapshot is
374+
its object id, through a second reference as through a cycle, so `json.dumps` takes the
375+
snapshot."""
376+
client, channel, root, mock_ws = await setup_synced_channel('test')
377+
# root.alias references the same map as root.profile
378+
mock_ws.send_to_client(build_object_message('test', [
379+
build_map_set('root', 'alias', {'objectId': 'map:profile@1000'}, remote_serial(0), 'remote')]))
380+
await poll_until(lambda: 'alias' in root.keys(), description='the reference to be applied')
381+
382+
result = root.compact_json()
383+
384+
assert result['avatar'] == 'AQID'
385+
assert result['profile'] == {'email': 'alice@example.com', 'nested_counter': 5, 'prefs': {'theme': 'dark'}}
386+
assert result['alias'] == {'objectId': 'map:profile@1000'}
387+
assert json.loads(json.dumps(result)) == result
388+
389+
355390
@pytest.mark.parametrize('typed_class, present, absent', [
356391
(PathObject,
357-
{'path', 'type', 'exists', 'get', 'at', 'instance', 'compact', 'compact_json', 'subscribe',
392+
{'path', 'type', 'exists', 'get', 'at', 'instance', 'compact_json', 'subscribe',
358393
'as_live_map', 'as_live_counter', 'as_primitive'},
359-
{'value', 'entries', 'keys', 'values', 'size', 'set', 'remove', 'increment', 'decrement', 'batch'}),
394+
{'compact', 'value', 'entries', 'keys', 'values', 'size', 'set', 'remove', 'increment', 'decrement',
395+
'batch'}),
360396
(LiveMapPathObject,
361397
{'entries', 'keys', 'values', 'size', 'set', 'remove', 'batch'},
362398
{'value', 'increment', 'decrement'}),
@@ -367,8 +403,8 @@ async def test_rtts10_instance_id_is_a_property():
367403
{'value'},
368404
{'entries', 'keys', 'values', 'size', 'set', 'remove', 'increment', 'decrement', 'batch'}),
369405
(Instance,
370-
{'id', 'type', 'get', 'compact', 'compact_json', 'as_live_map', 'as_live_counter', 'as_primitive'},
371-
{'value', 'entries', 'size', 'set', 'remove', 'increment', 'decrement', 'subscribe', 'batch'}),
406+
{'id', 'type', 'get', 'compact_json', 'as_live_map', 'as_live_counter', 'as_primitive'},
407+
{'compact', 'value', 'entries', 'size', 'set', 'remove', 'increment', 'decrement', 'subscribe', 'batch'}),
372408
(LiveMapInstance,
373409
{'entries', 'keys', 'values', 'size', 'set', 'remove', 'subscribe', 'batch'},
374410
{'value', 'increment', 'decrement'}),

0 commit comments

Comments
 (0)