From 36020caa56de5efa41bf8db5f1ae7313dd603a09 Mon Sep 17 00:00:00 2001 From: owenpearson Date: Wed, 28 Oct 2026 17:41:40 +0000 Subject: [PATCH 1/3] pubsub: make PresenceAction an IntEnum TP2 makes PresenceAction an enum, and MessageAction and AnnotationAction already are IntEnums that decoding wraps wire ints into. PresenceAction was a plain class of int constants, decoded actions stayed bare ints, and the realtime presence code monkey-patched a name lookup onto the class. Exported as it was, an annotation of PresenceAction would reject PresenceAction.ENTER under a type checker, because that is an int. PresenceAction is now an IntEnum with the same members and values. PresenceMessage.from_encoded wraps a known wire int into its member, so presence received over HTTP, in a batch presence result, or over a realtime connection, and the members the presence map stores, hold PresenceAction members; the LEAVEs and PRESENTs the library builds already used them. An action this version does not know is kept as the value the server sent, as before, so the presence map still ignores it. to_encoded sends int(action), so the wire value stays a plain int in both JSON and MessagePack. The event name a presence message is emitted under now comes from the member's name, through a module function in realtime/presence.py rather than the monkey-patched _action_name. A member still compares and hashes equal to its int, so == 2 keeps working. New unit tests cover the members, decoding of every action over both trees, the HTTP and batch presence paths, encoding to JSON and MessagePack, and the realtime presence map, SYNC and synthesized LEAVEs. No UTS test changes outcome, so deviations.md does not change. The uts-to-python skill's note on presence event names, which described the monkey-patch, now says where the names come from. This is a breaking change: type(action) is int no longer holds, and repr() of an action, and str() before Python 3.11, give the member rather than the number, as UPDATING.md describes. Co-Authored-By: Claude Opus 5.5 --- .claude/skills/uts-to-python/SKILL.md | 5 +- UPDATING.md | 25 ++++ ably/pubsub/realtime/presence.py | 27 ++--- ably/pubsub/types/presence.py | 14 ++- test/unit/presence_action_test.py | 168 ++++++++++++++++++++++++++ test/uts/helpers/presence.py | 2 +- 6 files changed, 217 insertions(+), 24 deletions(-) create mode 100644 test/unit/presence_action_test.py diff --git a/.claude/skills/uts-to-python/SKILL.md b/.claude/skills/uts-to-python/SKILL.md index 8cd3a404..06971ca0 100644 --- a/.claude/skills/uts-to-python/SKILL.md +++ b/.claude/skills/uts-to-python/SKILL.md @@ -703,9 +703,8 @@ test that needs the connection to suspend before the channel does must set **21. `presence.subscribe()` and `annotations.subscribe()` are coroutines that attach.** They must be awaited, and they cannot run before `connect()`. `unsubscribe()` -is not a coroutine. Presence event names are the lowercase action names from -`PresenceAction._action_name`, which is monkey-patched onto `PresenceAction` at the -bottom of `presence.py` and exists only once that module is imported. +is not a coroutine. Presence event names are the lowercase names of the `PresenceAction` +members, an `IntEnum`, as `_action_name` in `realtime/presence.py` gives them. **22. `RecordedUrl` cannot survive a URL httpx rejects.** `httpx.URL` caps at 64 KiB and raises inside `PendingConnection.__init__` **before** the attempt is recorded, so diff --git a/UPDATING.md b/UPDATING.md index c089c4ef..98ba394a 100644 --- a/UPDATING.md +++ b/UPDATING.md @@ -922,6 +922,31 @@ milliseconds is sent as before. No change is needed. +### `PresenceAction` is an `IntEnum` + +`PresenceAction` is now an `IntEnum`, as `MessageAction` and `AnnotationAction` are, and the +`action` of a `PresenceMessage` received from Ably is one of its members, where 3.x gave a +bare `int` from a plain class of constants. A member is still an `int`: it compares equal to +its wire value, so `member.action == 2` and `member.action == PresenceAction.ENTER` both still +hold, it hashes like that value, and it is still sent to Ably as the number. Two things do +change. `type(member.action) is int` no longer holds, and `repr()` of an action is now +`` rather than `2`; before Python 3.11, `str()` and `%s` give +`PresenceAction.ENTER` too, though an f-string still gives `2`. Use `int()` where you need the +plain number, and `.name` for the name. An action this version does not know is left as the +`int` Ably sent. + +Example 3.x code: +```python +if type(member.action) is int: + log.info('presence action %s', member.action) +``` + +Example 4.0.0 code: +```python +if isinstance(member.action, PresenceAction): + log.info('presence action %s', member.action.name) +``` + ## Version 2.x to 3.0.0 The 3.0.0 version of ably-python introduces several breaking changes to improve the realtime experience and align the API with the Ably specification. These include: diff --git a/ably/pubsub/realtime/presence.py b/ably/pubsub/realtime/presence.py index 73edfa4f..70e69c94 100644 --- a/ably/pubsub/realtime/presence.py +++ b/ably/pubsub/realtime/presence.py @@ -215,12 +215,12 @@ async def _enter_or_update_client( ConnectionState.DISCONNECTED ]: raise AblyException( - f'Unable to {PresenceAction._action_name(action).lower()} presence channel; ' + f'Unable to {_action_name(action)} presence channel; ' f'connection state = {channel.ably.connection.state}', 400, 90001 ) - action_name = PresenceAction._action_name(action).lower() + action_name = _action_name(action) log.info( f'RealtimePresence.{action_name}(): ' @@ -590,7 +590,7 @@ def set_presence( # Broadcast messages to subscribers for presence in broadcast_messages: - action_name = PresenceAction._action_name(presence.action).lower() + action_name = _action_name(presence.action) self._subscriptions._emit(action_name, presence) def on_attached(self, has_presence: bool = False) -> None: @@ -774,18 +774,9 @@ def _fail_pending_presence(self, error: AblyException | None = None) -> None: item['future'].set_exception(exception) -# Helper for PresenceAction to convert action to string -def _action_name_impl(action: int) -> str: - """Convert presence action to string name.""" - names = { - PresenceAction.ABSENT: 'absent', - PresenceAction.PRESENT: 'present', - PresenceAction.ENTER: 'enter', - PresenceAction.LEAVE: 'leave', - PresenceAction.UPDATE: 'update', - } - return names.get(action, f'unknown({action})') - - -# Monkey-patch the helper onto PresenceAction -PresenceAction._action_name = staticmethod(_action_name_impl) +def _action_name(action) -> str: + """The lowercase name of a presence action, which is the event it is emitted as.""" + try: + return PresenceAction(action).name.lower() + except ValueError: + return f'unknown({action})' diff --git a/ably/pubsub/types/presence.py b/ably/pubsub/types/presence.py index 5084ff0e..3d771c47 100644 --- a/ably/pubsub/types/presence.py +++ b/ably/pubsub/types/presence.py @@ -1,4 +1,5 @@ from datetime import datetime, timedelta, timezone +from enum import IntEnum from urllib import parse from ably.pubsub.request.paginatedresult import PaginatedResult @@ -23,7 +24,8 @@ def _ms_since_epoch(t): return (t - _EPOCH) // timedelta(milliseconds=1) -class PresenceAction: +class PresenceAction(IntEnum): + """Presence action types (TP2), numbered as on the wire""" ABSENT = 0 PRESENT = 1 ENTER = 2 @@ -155,7 +157,7 @@ def to_encoded(self, binary=False): """ result = { - 'action': self.action, + 'action': int(self.action) if self.action is not None else None, **encode_data(self.data, self._encoding_array, binary), } @@ -186,6 +188,14 @@ def from_encoded(obj, cipher=None, context=None): decoded_data = PresenceMessage.decode(data, encoding, cipher) + if action is not None: + try: + action = PresenceAction(action) + except ValueError: + # An action this version does not know is kept as the server sent it, so + # the presence map ignores it rather than mistaking it for another action. + pass + return PresenceMessage( id=id, action=action, diff --git a/test/unit/presence_action_test.py b/test/unit/presence_action_test.py new file mode 100644 index 00000000..17f48b06 --- /dev/null +++ b/test/unit/presence_action_test.py @@ -0,0 +1,168 @@ +"""PresenceAction is an IntEnum (TP2), as MessageAction and AnnotationAction are. + +Tests cover: +- the members and their wire values, and that a member still equals its int +- decoding: a known wire int becomes the member, an unknown one is kept as it came +- encoding: the action reaches JSON and MessagePack as a plain int +- the realtime presence map and the LEAVEs it synthesizes, which hold members +""" + +import json +from enum import IntEnum +from types import SimpleNamespace + +import msgpack +import pytest + +import ably.pubsub.sync.types.presence as sync_presence +import ably.pubsub.types.presence as async_presence +from ably.pubsub.realtime.presence import RealtimePresence, _action_name +from ably.pubsub.types.batch import BatchPresenceSuccessResult +from ably.pubsub.types.presence import PresenceAction, PresenceMessage, make_presence_response_handler +from ably.pubsub.types.testoptions import TestOptions + +WIRE_VALUES = [ + ('ABSENT', 0), + ('PRESENT', 1), + ('ENTER', 2), + ('LEAVE', 3), + ('UPDATE', 4), +] + +# The generated synchronous tree has its own PresenceAction and PresenceMessage. +MODULES = [async_presence, sync_presence] + + +@pytest.mark.parametrize('module', MODULES) +def test_presence_action_is_an_int_enum(module): + assert issubclass(module.PresenceAction, IntEnum) + assert [(a.name, a.value) for a in module.PresenceAction] == WIRE_VALUES + + +@pytest.mark.parametrize('name,value', WIRE_VALUES) +def test_member_compares_equal_to_its_wire_int(name, value): + action = PresenceAction[name] + assert action == value + assert isinstance(action, int) + assert type(action) is not int + assert hash(action) == hash(value) + assert {value: name}[action] == name + + +@pytest.mark.parametrize('module', MODULES) +@pytest.mark.parametrize('name,value', WIRE_VALUES) +def test_decoding_wraps_the_wire_int(module, name, value): + msg = module.PresenceMessage.from_encoded({'action': value, 'clientId': 'c'}) + assert msg.action is module.PresenceAction[name] + + +@pytest.mark.parametrize('module', MODULES) +def test_decoding_a_missing_action_gives_enter(module): + msg = module.PresenceMessage.from_encoded({'clientId': 'c'}) + assert msg.action is module.PresenceAction.ENTER + + +@pytest.mark.parametrize('module', MODULES) +def test_decoding_an_unknown_action_keeps_the_wire_value(module): + msg = module.PresenceMessage.from_encoded({'action': 99, 'clientId': 'c'}) + assert msg.action == 99 + assert type(msg.action) is int + + +def test_decoding_an_array_wraps_every_action(): + msgs = PresenceMessage.from_encoded_array([{'action': 2}, {'action': 3}, {'action': 4}]) + assert [m.action for m in msgs] == [PresenceAction.ENTER, PresenceAction.LEAVE, PresenceAction.UPDATE] + assert all(isinstance(m.action, PresenceAction) for m in msgs) + + +def test_http_presence_response_wraps_actions(): + response = SimpleNamespace(to_native=lambda: [{'action': 1, 'clientId': 'a'}, {'action': 3, 'clientId': 'b'}]) + msgs = make_presence_response_handler(None)(response) + assert [m.action for m in msgs] == [PresenceAction.PRESENT, PresenceAction.LEAVE] + assert all(isinstance(m.action, PresenceAction) for m in msgs) + + +def test_batch_presence_result_wraps_actions(): + result = BatchPresenceSuccessResult.from_dict({'channel': 'ch', 'presence': [{'action': 1, 'clientId': 'a'}]}) + assert result.presence[0].action is PresenceAction.PRESENT + + +@pytest.mark.parametrize('module', MODULES) +@pytest.mark.parametrize('binary', [False, True]) +@pytest.mark.parametrize('name,value', WIRE_VALUES) +def test_encoding_sends_a_plain_int(module, binary, name, value): + msg = module.PresenceMessage(action=module.PresenceAction[name], client_id='c', data='d') + encoded = msg.to_encoded(binary=binary) + assert type(encoded['action']) is int + assert encoded['action'] == value + assert json.loads(json.dumps(encoded))['action'] == value + assert msgpack.unpackb(msgpack.packb(encoded))['action'] == value + + +def test_encoding_a_plain_int_action_is_unchanged(): + encoded = PresenceMessage(action=2, client_id='c').to_encoded() + assert encoded['action'] == 2 + assert type(encoded['action']) is int + + +def test_encoding_round_trips(): + sent = PresenceMessage(action=PresenceAction.UPDATE, client_id='c', data='d').to_encoded() + assert PresenceMessage.from_encoded(sent).action is PresenceAction.UPDATE + + +@pytest.mark.parametrize('name,value', WIRE_VALUES) +def test_action_name_is_the_event_name(name, value): + assert _action_name(PresenceAction[name]) == name.lower() + assert _action_name(value) == name.lower() + + +def test_action_name_of_an_unknown_action(): + assert _action_name(99) == 'unknown(99)' + assert _action_name(None) == 'unknown(None)' + + +def subscribed_presence(connection_id='conn-1'): + channel = SimpleNamespace( + name='presence-action', + ably=SimpleNamespace( + options=SimpleNamespace(_test_options=TestOptions()), + connection=SimpleNamespace(connection_manager=SimpleNamespace(connection_id=connection_id)), + ), + ) + presence = RealtimePresence(channel) + events = [] + for event_name in ('present', 'enter', 'leave', 'update', 'absent'): + presence._subscriptions.on(event_name, lambda m, e=event_name: events.append((e, m))) + return presence, events + + +def wire(action, client_id, connection_id, index, timestamp): + return {'action': action, 'clientId': client_id, 'connectionId': connection_id, + 'id': f'{connection_id}:0:{index}', 'timestamp': timestamp} + + +# RTP2, RTP18, RTP19: members a SYNC delivers, and the LEAVEs it synthesizes for those it +# leaves out, carry PresenceAction members and are emitted under their lowercase names. +def test_realtime_presence_map_and_synthesized_leaves_hold_members(): + presence, events = subscribed_presence() + + first = PresenceMessage.from_encoded_array([wire(1, 'alice', 'c1', 0, 100), wire(1, 'bob', 'c2', 0, 100)]) + presence.set_presence(first, is_sync=True, sync_channel_serial='seq1:') + assert [(e, m.action) for e, m in events] == [('present', PresenceAction.PRESENT), + ('present', PresenceAction.PRESENT)] + assert all(type(m.action) is PresenceAction for m in presence.members.values()) + + events.clear() + second = PresenceMessage.from_encoded_array([wire(1, 'alice', 'c1', 0, 100)]) + presence.set_presence([], is_sync=True, sync_channel_serial='seq2:cursor') + presence.set_presence(second, is_sync=True, sync_channel_serial='seq2:') + assert [(e, m.client_id, m.action) for e, m in events] == [('leave', 'bob', PresenceAction.LEAVE)] + assert type(events[0][1].action) is PresenceAction + + events.clear() + presence.set_presence(PresenceMessage.from_encoded_array([wire(2, 'carol', 'c3', 0, 200)]), is_sync=False) + presence.set_presence(PresenceMessage.from_encoded_array([wire(4, 'carol', 'c3', 1, 300)]), is_sync=False) + presence.set_presence(PresenceMessage.from_encoded_array([wire(3, 'carol', 'c3', 2, 400)]), is_sync=False) + assert [(e, m.action) for e, m in events] == [('enter', PresenceAction.ENTER), + ('update', PresenceAction.UPDATE), + ('leave', PresenceAction.LEAVE)] diff --git a/test/uts/helpers/presence.py b/test/uts/helpers/presence.py index 5218e902..0e0dbadd 100644 --- a/test/uts/helpers/presence.py +++ b/test/uts/helpers/presence.py @@ -15,7 +15,7 @@ from ably.pubsub.types.testoptions import TestOptions #: The lowercase action names `RealtimePresence` emits its events under, which come -#: from `PresenceAction._action_name` and exist only once `presence.py` is imported. +#: from the `PresenceAction` member names, lowercased. PRESENCE_EVENT_NAMES = ('absent', 'present', 'enter', 'leave', 'update') From c68eb85b244a8efecb530a905fdb8163d9b61262 Mon Sep 17 00:00:00 2001 From: owenpearson Date: Wed, 28 Oct 2026 17:43:30 +0000 Subject: [PATCH 2/3] pubsub: name the four remaining sync handle classes with a Sync suffix unasync's rename_classes gives the generated synchronous flavour of a class a Sync suffix, and covered the clients, Auth, Channels, Channel, Push, PushAdmin, Http and the paginated results. It left out the HTTP channel's Presence and HttpAnnotations and the push admin's PushDeviceRegistrations and PushChannelSubscriptions, so those four generated classes shared their __name__ with the async ones, and a sync client handed out ChannelSync, PushAdminSync and so on beside an unsuffixed Presence. The next commit exports the handle classes, and the sync names have to be settled before they become public. The four are added to rename_classes, so the sync tree now has PresenceSync, HttpAnnotationsSync, PushDeviceRegistrationsSync and PushChannelSubscriptionsSync, and every handle a sync client hands out ends in Sync. Data types, enums, exceptions and the PubSubHttpClient prototype keep their names in both trees. Renaming a class changes the name its private attributes are mangled under. Each of the four reads its own private attributes only inside its class body, and nothing in the library or the tests reaches one as _Presence__x, _HttpAnnotations__x, _PushDeviceRegistrations__x or _PushChannelSubscriptions__x, so no _TOKEN_REPLACE entry is needed like the existing _Channel__publish_request_body one; a comment on rename_classes now says when one is. The generated tree differs from before only in those four names and their uses, and the generated test/ably/sync/http/sync_httpchannels_test.py now imports and checks PresenceSync. New unit tests check that every handle class a sync client hands out has the suffix and is the generated class, that none shares a name with its async class, and that the renamed classes read their own private state. The UTS suite does not use the sync tree, so its counts and deviations.md do not change. This is a breaking change for code that names these four sync classes, as UPDATING.md describes. Co-Authored-By: Claude Opus 5.5 --- UPDATING.md | 25 ++++++++ ably/pubsub/scripts/unasync.py | 9 +++ test/unit/sync_class_names_test.py | 93 ++++++++++++++++++++++++++++++ 3 files changed, 127 insertions(+) create mode 100644 test/unit/sync_class_names_test.py diff --git a/UPDATING.md b/UPDATING.md index 98ba394a..e7ed6091 100644 --- a/UPDATING.md +++ b/UPDATING.md @@ -947,6 +947,31 @@ if isinstance(member.action, PresenceAction): log.info('presence action %s', member.action.name) ``` +### Every handle of the synchronous client ends in `Sync` + +The synchronous client's presence, annotations and push admin objects are now instances of +`PresenceSync`, `HttpAnnotationsSync`, `PushDeviceRegistrationsSync` and +`PushChannelSubscriptionsSync`, as its auth, channels, channel and push objects already were of +`AuthSync`, `ChannelsSync`, `ChannelSync`, `PushSync` and `PushAdminSync`. In 3.x these four +were named `Presence`, `RestAnnotations`, `PushDeviceRegistrations` and +`PushChannelSubscriptions`, the same names as the asynchronous classes, so a traceback, a +`repr()` or `type(x).__name__` could not say which flavour an object came from. Data types, +enums, exceptions and the `PubSubHttpClient` prototype keep their names in both flavours. + +Only code that names these classes, in an annotation or an `isinstance` check, needs a change: + +Example 3.x code: +```python +from ably.sync.types.presence import Presence + +def members(presence: Presence) -> list: ... +``` + +Example 4.0.0 code: +```python +def members(presence: PresenceSync) -> list: ... +``` + ## Version 2.x to 3.0.0 The 3.0.0 version of ably-python introduces several breaking changes to improve the realtime experience and align the API with the Ably specification. These include: diff --git a/ably/pubsub/scripts/unasync.py b/ably/pubsub/scripts/unasync.py index 4f6c8f16..17181bc5 100644 --- a/ably/pubsub/scripts/unasync.py +++ b/ably/pubsub/scripts/unasync.py @@ -4,12 +4,21 @@ import tokenize_rt +# Classes whose generated flavour takes a `Sync` suffix: the clients, the handles the HTTP +# surface hands out, and the result wrappers whose methods it awaits. Data types, enums and +# exceptions keep their names. A class here also changes the name its private attributes +# are mangled under, so a `___attr` written outside the class needs its own entry in +# _TOKEN_REPLACE below. rename_classes = [ "DefaultPubSubHttpClient", "Push", "PushAdmin", + "PushDeviceRegistrations", + "PushChannelSubscriptions", "Channel", "Channels", + "Presence", + "HttpAnnotations", "Auth", "Http", "PaginatedResult", diff --git a/test/unit/sync_class_names_test.py b/test/unit/sync_class_names_test.py new file mode 100644 index 00000000..01caa651 --- /dev/null +++ b/test/unit/sync_class_names_test.py @@ -0,0 +1,93 @@ +"""The generated synchronous classes a sync client hands out are named with a `Sync` suffix. + +unasync generates `ably/pubsub/sync/` from the async tree and renames the classes in its +`rename_classes`. The handles reached from a sync client — its auth, channels, channel, +presence, annotations and push admin objects — and the paginated results are all renamed, +so that no sync handle shares a `__name__` with the async class it was generated from. Data +types, enums and exceptions keep their names in both trees. +""" + +import pytest + +import ably.pubsub.server.sync as server_sync +from ably.pubsub.http import annotations, auth, channel, push +from ably.pubsub.request import paginatedresult +from ably.pubsub.scripts.unasync import rename_classes +from ably.pubsub.sync.http import annotations as sync_annotations +from ably.pubsub.sync.http import auth as sync_auth +from ably.pubsub.sync.http import channel as sync_channel +from ably.pubsub.sync.http import push as sync_push +from ably.pubsub.sync.request import paginatedresult as sync_paginatedresult +from ably.pubsub.sync.types import presence as sync_presence +from ably.pubsub.types import presence + +# (async module, sync module, async class name) for every handle class a sync client +# hands out, and the paginated results it returns. +HANDLES = [ + (auth, sync_auth, 'Auth'), + (channel, sync_channel, 'Channels'), + (channel, sync_channel, 'Channel'), + (presence, sync_presence, 'Presence'), + (annotations, sync_annotations, 'HttpAnnotations'), + (push, sync_push, 'Push'), + (push, sync_push, 'PushAdmin'), + (push, sync_push, 'PushDeviceRegistrations'), + (push, sync_push, 'PushChannelSubscriptions'), + (paginatedresult, sync_paginatedresult, 'PaginatedResult'), + (paginatedresult, sync_paginatedresult, 'HttpPaginatedResponse'), +] + + +@pytest.mark.parametrize('module,sync_module,name', HANDLES, ids=[h[2] for h in HANDLES]) +def test_sync_handle_class_has_the_sync_suffix(module, sync_module, name): + async_cls = getattr(module, name) + sync_cls = getattr(sync_module, f'{name}Sync') + assert async_cls.__name__ == name + assert sync_cls.__name__ == f'{name}Sync' + assert not hasattr(sync_module, name) + assert name in rename_classes + + +@pytest.mark.parametrize('name', ['PresenceSync', 'HttpAnnotationsSync', + 'PushDeviceRegistrationsSync', 'PushChannelSubscriptionsSync']) +def test_the_four_classes_renamed_in_4_0(name): + assert name[:-len('Sync')] in rename_classes + + +def test_sync_client_hands_out_the_sync_classes(): + client = server_sync.create_http_client(key='fake:key') + ch = client.channels.get('names') + assert type(client.auth) is sync_auth.AuthSync + assert type(client.channels) is sync_channel.ChannelsSync + assert type(ch) is sync_channel.ChannelSync + assert type(ch.presence) is sync_presence.PresenceSync + assert type(ch.annotations) is sync_annotations.HttpAnnotationsSync + assert type(client.push) is sync_push.PushSync + assert type(client.push.admin) is sync_push.PushAdminSync + assert type(client.push.admin.device_registrations) is sync_push.PushDeviceRegistrationsSync + assert type(client.push.admin.channel_subscriptions) is sync_push.PushChannelSubscriptionsSync + client.close() + + +def test_sync_handles_do_not_share_a_name_with_their_async_class(): + client = server_sync.create_http_client(key='fake:key') + ch = client.channels.get('names') + handles = [client.auth, client.channels, ch, ch.presence, ch.annotations, client.push, + client.push.admin, client.push.admin.device_registrations, + client.push.admin.channel_subscriptions] + for handle in handles: + assert type(handle).__name__.endswith('Sync'), type(handle) + client.close() + + +# Renaming a class changes the name its private attributes are mangled under. The renamed +# classes keep working because each reads its own attributes inside the class body; this +# exercises the four newly renamed ones through their private state. +def test_renamed_classes_read_their_own_private_attributes(): + client = server_sync.create_http_client(key='fake:key') + ch = client.channels.get('names') + assert ch.presence._PresenceSync__channel is ch + assert not hasattr(ch.presence, '_Presence__channel') + assert client.push.admin.device_registrations.ably is client + assert client.push.admin.channel_subscriptions.ably is client + client.close() From e45fd32edf30641ab66538a88c0d4d9604f79ecf Mon Sep 17 00:00:00 2001 From: owenpearson Date: Wed, 28 Oct 2026 17:46:50 +0000 Subject: [PATCH 3/3] pubsub: export the public types from ably.pubsub.server and its sync flavour ably.pubsub.server is the only import path 4.0 promises, and UPDATING.md told users to import from it rather than from submodules, but its __all__ had 42 names and left out most of the types a client hands out. A channel.on() listener gets a ChannelStateChange, a connection.on() listener a ConnectionStateChange, presence.get() returns PresenceMessages, channel.status() a ChannelDetails, and a user annotating a parameter as a RealtimeChannel had nowhere public to import it from. Every 3.x user rewrites their imports for 4.0, so whatever is missing that day gets written as an internal path. ably.pubsub.server now also exports ChannelState, ChannelStateChange, ConnectionState, ConnectionEvent and ConnectionStateChange; PresenceMessage, PresenceAction and Stats; TokenRequest, ChannelDetails, ChannelStatus, ChannelOccupancy and ChannelMetrics; the classes of the objects a client hands out, Channel, Presence, HttpAnnotations, RealtimeChannel, RealtimePresence, RealtimeAnnotations, PushAdmin, PushDeviceRegistrations and PushChannelSubscriptions, beside the Push it already exported; and InvalidArgumentException and DevicePushDetails. That is 66 names. The HTTP channel and presence classes keep their own names, Channel and Presence. ably.pubsub.server.sync exports the same set, 54 names, apart from the realtime client's: the five state types, RealtimeChannel, RealtimePresence and RealtimeAnnotations join the four realtime names it already left out. Each export is the generated class under its own name, so the handle classes are ChannelSync, PresenceSync, HttpAnnotationsSync, PushAdminSync, PushDeviceRegistrationsSync and PushChannelSubscriptionsSync, and the data types keep their plain names. Every export, in both modules, is the internal object itself rather than a copy or a subclass. ConnectionDetails, MessageAnnotations, an ErrorInfo alias and ResponseHeaders stay unexported for now. New unit tests pin both __all__ lists, star-import each module, check every export is the object its defining module holds, check every async export has a sync counterpart or is on an explicit realtime-only list, and check the handle classes are the types the clients hand out. UPDATING.md lists the new names with a before and after import, gives the earlier sections' examples their imports, and no longer implies that every type is exported. The UTS suite imports from the internal modules, so its counts and deviations.md do not change. Not a breaking change: names are only added. Co-Authored-By: Claude Opus 5.5 --- UPDATING.md | 79 +++++++++- ably/pubsub/server/__init__.py | 52 ++++++- ably/pubsub/server/sync.py | 44 +++++- test/unit/pubsub_server_test.py | 267 ++++++++++++++++++++++++++++++++ 4 files changed, 435 insertions(+), 7 deletions(-) diff --git a/UPDATING.md b/UPDATING.md index e7ed6091..6089447a 100644 --- a/UPDATING.md +++ b/UPDATING.md @@ -47,7 +47,10 @@ from ably.pubsub.server.sync import create_http_client Where 3.x left you reaching into submodules such as `ably.types.message` or `ably.http.paginatedresult`, import from `ably.pubsub.server` instead. It is the whole public API, and `ably.pubsub.server.sync` is its synchronous -counterpart. The packages beneath them are internal and free to move. +counterpart. The packages beneath them are internal and free to move. The +types a client returns, hands to a listener or accepts are exported there, +including those 3.x left in submodules only, such as `ConnectionState` and +`PresenceMessage`, as the section on exports below lists. For example, the encryption helpers move from `ably.util.crypto`: @@ -943,6 +946,8 @@ if type(member.action) is int: Example 4.0.0 code: ```python +from ably.pubsub.server import PresenceAction + if isinstance(member.action, PresenceAction): log.info('presence action %s', member.action.name) ``` @@ -969,9 +974,81 @@ def members(presence: Presence) -> list: ... Example 4.0.0 code: ```python +from ably.pubsub.server.sync import PresenceSync + def members(presence: PresenceSync) -> list: ... ``` +### Every public type is exported from `ably.pubsub.server` + +`ably.pubsub.server` now exports the types a client returns, hands to a listener or accepts, +so no import needs to reach into the packages beneath it. 3.x exported only some of them from +`ably`, and left the rest, such as `ConnectionState` and `PresenceMessage`, in submodules. These +names are new: + +| | Names | +|---|---| +| States | `ChannelState`, `ChannelStateChange`, `ConnectionState`, `ConnectionEvent`, `ConnectionStateChange` | +| Presence and stats | `PresenceMessage`, `PresenceAction`, `Stats` | +| Auth and channel status | `TokenRequest`, `ChannelDetails`, `ChannelStatus`, `ChannelOccupancy`, `ChannelMetrics` | +| Objects a client hands out | `Channel`, `Presence`, `HttpAnnotations`, `RealtimeChannel`, `RealtimePresence`, `RealtimeAnnotations`, `PushAdmin`, `PushDeviceRegistrations`, `PushChannelSubscriptions` | +| Errors and push | `InvalidArgumentException`, `DevicePushDetails` | + +`Channel`, `Presence` and `HttpAnnotations` are the HTTP client's channel and its presence +and annotations, and `RealtimeChannel` is a subclass of `Channel`. Like the clients, the +objects these classes describe come from a client: the classes are exported for annotations +and `isinstance` checks, not to be constructed. A few types reached only through an attribute +of another, such as `connection.connection_details`, `message.annotations` and +`response.headers`, are not exported yet; read their attributes without naming the type. + +Example 3.x code: +```python +from ably.realtime.channel import RealtimeChannel +from ably.types.connectionstate import ConnectionState, ConnectionStateChange +from ably.types.presence import PresenceMessage + +def on_state(change: ConnectionStateChange) -> None: + if change.current == ConnectionState.FAILED: + ... + +async def members(channel: RealtimeChannel) -> list[PresenceMessage]: + return await channel.presence.get() +``` + +Example 4.0.0 code: +```python +from ably.pubsub.server import ConnectionState, ConnectionStateChange, PresenceMessage, RealtimeChannel + +def on_state(change: ConnectionStateChange) -> None: + if change.current == ConnectionState.FAILED: + ... + +async def members(channel: RealtimeChannel) -> list[PresenceMessage]: + return await channel.presence.get() +``` + +`ably.pubsub.server.sync` exports the same names, apart from the realtime client's: +`ChannelState`, `ChannelStateChange`, `ConnectionState`, `ConnectionEvent`, +`ConnectionStateChange`, `RealtimeChannel`, `RealtimePresence` and `RealtimeAnnotations`, as +well as `Connection`, `RealtimeChannels`, `PubSubRealtimeClient` and `create_realtime_client`, +which it never had. The classes of the objects the synchronous client hands out end in +`Sync`, so its new names are `ChannelSync`, `PresenceSync`, `HttpAnnotationsSync`, +`PushAdminSync`, `PushDeviceRegistrationsSync` and `PushChannelSubscriptionsSync`, beside +`PresenceMessage`, `PresenceAction`, `Stats`, `TokenRequest`, `ChannelDetails`, +`ChannelStatus`, `ChannelOccupancy`, `ChannelMetrics`, `InvalidArgumentException` and +`DevicePushDetails` under their plain names: + +Example 3.x code: +```python +from ably.sync.rest.channel import ChannelSync +from ably.sync.types.presence import PresenceMessage +``` + +Example 4.0.0 code: +```python +from ably.pubsub.server.sync import ChannelSync, PresenceMessage +``` + ## Version 2.x to 3.0.0 The 3.0.0 version of ably-python introduces several breaking changes to improve the realtime experience and align the API with the Ably specification. These include: diff --git a/ably/pubsub/server/__init__.py b/ably/pubsub/server/__init__.py index a544c857..2e46a5c0 100644 --- a/ably/pubsub/server/__init__.py +++ b/ably/pubsub/server/__init__.py @@ -11,6 +11,12 @@ :class:`~ably.pubsub.prototypes.PubSubHttpClient` and :class:`~ably.pubsub.prototypes.PubSubRealtimeClient`. +The types a client hands out or accepts are exported here too: the messages, +states and results it returns, and the classes of the objects it hands out, +such as :class:`RealtimeChannel` and :class:`Presence`, for annotations and +``isinstance`` checks. Those objects come from the client; construct them +through it rather than directly. + This is the public API of the ``ably-pubsub-server`` distribution. Both ``ably`` and ``ably.pubsub`` are PEP 420 namespace packages shared with the other ``ably-*`` distributions, so neither carries an ``__init__.py`` of its @@ -19,13 +25,18 @@ from :mod:`ably.pubsub.server.liveobjects` for LiveObjects. """ +from ably.pubsub.http.annotations import HttpAnnotations from ably.pubsub.http.auth import Auth +from ably.pubsub.http.channel import Channel from ably.pubsub.http.channel import Channels as HttpChannels from ably.pubsub.http.http import DefaultPubSubHttpClient as _DefaultPubSubHttpClient -from ably.pubsub.http.push import Push +from ably.pubsub.http.push import Push, PushAdmin, PushChannelSubscriptions, PushDeviceRegistrations from ably.pubsub.prototypes import PubSubHttpClient, PubSubRealtimeClient +from ably.pubsub.realtime.annotations import RealtimeAnnotations from ably.pubsub.realtime.channel import Channels as RealtimeChannels +from ably.pubsub.realtime.channel import RealtimeChannel from ably.pubsub.realtime.connection import Connection +from ably.pubsub.realtime.presence import RealtimePresence from ably.pubsub.realtime.realtime import DefaultPubSubRealtimeClient as _DefaultPubSubRealtimeClient from ably.pubsub.request.paginatedresult import HttpPaginatedResponse, PaginatedResult from ably.pubsub.types.annotation import Annotation, AnnotationAction @@ -38,17 +49,28 @@ BatchResult, ) from ably.pubsub.types.capability import Capability +from ably.pubsub.types.channeldetails import ChannelDetails, ChannelMetrics, ChannelOccupancy, ChannelStatus from ably.pubsub.types.channelmode import ChannelMode from ably.pubsub.types.channeloptions import ChannelOptions +from ably.pubsub.types.channelstate import ChannelState, ChannelStateChange from ably.pubsub.types.channelsubscription import PushChannelSubscription -from ably.pubsub.types.device import DeviceDetails +from ably.pubsub.types.connectionstate import ConnectionEvent, ConnectionState, ConnectionStateChange +from ably.pubsub.types.device import DeviceDetails, DevicePushDetails from ably.pubsub.types.message import Message, MessageAction, MessageVersion from ably.pubsub.types.operations import MessageOperation, PublishResult, UpdateDeleteResult from ably.pubsub.types.options import Options, VCDiffDecoder +from ably.pubsub.types.presence import Presence, PresenceAction, PresenceMessage +from ably.pubsub.types.stats import Stats from ably.pubsub.types.tokendetails import TokenDetails +from ably.pubsub.types.tokenrequest import TokenRequest from ably.pubsub.util.construction import factory_construction from ably.pubsub.util.crypto import CipherParams, generate_random_key, get_default_params -from ably.pubsub.util.exceptions import AblyAuthException, AblyException, IncompatibleClientIdException +from ably.pubsub.util.exceptions import ( + AblyAuthException, + AblyException, + IncompatibleClientIdException, + InvalidArgumentException, +) from ably.pubsub.vcdiff.defaultvcdiffdecoder import AblyVCDiffDecoder from ably.pubsub.version import api_version, lib_version @@ -275,27 +297,51 @@ def create_realtime_client(**kwargs) -> PubSubRealtimeClient: 'BatchPublishSuccessResult', 'BatchResult', 'Capability', + 'Channel', + 'ChannelDetails', + 'ChannelMetrics', 'ChannelMode', + 'ChannelOccupancy', 'ChannelOptions', + 'ChannelState', + 'ChannelStateChange', + 'ChannelStatus', 'CipherParams', 'Connection', + 'ConnectionEvent', + 'ConnectionState', + 'ConnectionStateChange', 'DeviceDetails', + 'DevicePushDetails', + 'HttpAnnotations', 'HttpChannels', 'HttpPaginatedResponse', 'IncompatibleClientIdException', + 'InvalidArgumentException', 'Message', 'MessageAction', 'MessageOperation', 'MessageVersion', 'Options', 'PaginatedResult', + 'Presence', + 'PresenceAction', + 'PresenceMessage', 'PubSubHttpClient', 'PubSubRealtimeClient', 'PublishResult', 'Push', + 'PushAdmin', 'PushChannelSubscription', + 'PushChannelSubscriptions', + 'PushDeviceRegistrations', + 'RealtimeAnnotations', + 'RealtimeChannel', 'RealtimeChannels', + 'RealtimePresence', + 'Stats', 'TokenDetails', + 'TokenRequest', 'UpdateDeleteResult', 'VCDiffDecoder', 'api_version', diff --git a/ably/pubsub/server/sync.py b/ably/pubsub/server/sync.py index f4a76223..88bd7aaa 100644 --- a/ably/pubsub/server/sync.py +++ b/ably/pubsub/server/sync.py @@ -5,12 +5,25 @@ loop. There is no synchronous realtime client, so a server that needs to subscribe to channels should use :func:`ably.pubsub.server.create_realtime_client` instead. + +It exports every name :mod:`ably.pubsub.server` does except those of the realtime +client, its connection and channel states and its realtime channel, presence and +annotations. The classes of the objects the client hands out keep a ``Sync`` +suffix, as in :class:`ChannelSync` and :class:`PresenceSync`; data types, enums +and exceptions keep their plain names. """ +from ably.pubsub.sync.http.annotations import HttpAnnotationsSync from ably.pubsub.sync.http.auth import AuthSync from ably.pubsub.sync.http.channel import ChannelsSync as HttpChannelsSync +from ably.pubsub.sync.http.channel import ChannelSync from ably.pubsub.sync.http.http import DefaultPubSubHttpClientSync as _DefaultPubSubHttpClientSync -from ably.pubsub.sync.http.push import PushSync +from ably.pubsub.sync.http.push import ( + PushAdminSync, + PushChannelSubscriptionsSync, + PushDeviceRegistrationsSync, + PushSync, +) from ably.pubsub.sync.prototypes import PubSubHttpClient from ably.pubsub.sync.request.paginatedresult import HttpPaginatedResponseSync, PaginatedResultSync from ably.pubsub.sync.types.annotation import Annotation, AnnotationAction @@ -23,17 +36,26 @@ BatchResult, ) from ably.pubsub.sync.types.capability import Capability +from ably.pubsub.sync.types.channeldetails import ChannelDetails, ChannelMetrics, ChannelOccupancy, ChannelStatus from ably.pubsub.sync.types.channelmode import ChannelMode from ably.pubsub.sync.types.channeloptions import ChannelOptions from ably.pubsub.sync.types.channelsubscription import PushChannelSubscription -from ably.pubsub.sync.types.device import DeviceDetails +from ably.pubsub.sync.types.device import DeviceDetails, DevicePushDetails from ably.pubsub.sync.types.message import Message, MessageAction, MessageVersion from ably.pubsub.sync.types.operations import MessageOperation, PublishResult, UpdateDeleteResult from ably.pubsub.sync.types.options import Options, VCDiffDecoder +from ably.pubsub.sync.types.presence import PresenceAction, PresenceMessage, PresenceSync +from ably.pubsub.sync.types.stats import Stats from ably.pubsub.sync.types.tokendetails import TokenDetails +from ably.pubsub.sync.types.tokenrequest import TokenRequest from ably.pubsub.sync.util.construction import factory_construction from ably.pubsub.sync.util.crypto import CipherParams, generate_random_key, get_default_params -from ably.pubsub.sync.util.exceptions import AblyAuthException, AblyException, IncompatibleClientIdException +from ably.pubsub.sync.util.exceptions import ( + AblyAuthException, + AblyException, + IncompatibleClientIdException, + InvalidArgumentException, +) from ably.pubsub.sync.vcdiff.defaultvcdiffdecoder import AblyVCDiffDecoder from ably.pubsub.version import api_version, lib_version @@ -77,24 +99,40 @@ def create_http_client(**kwargs) -> PubSubHttpClient: 'BatchPublishSuccessResult', 'BatchResult', 'Capability', + 'ChannelDetails', + 'ChannelMetrics', 'ChannelMode', + 'ChannelOccupancy', 'ChannelOptions', + 'ChannelStatus', + 'ChannelSync', 'CipherParams', 'DeviceDetails', + 'DevicePushDetails', + 'HttpAnnotationsSync', 'HttpChannelsSync', 'HttpPaginatedResponseSync', 'IncompatibleClientIdException', + 'InvalidArgumentException', 'Message', 'MessageAction', 'MessageOperation', 'MessageVersion', 'Options', 'PaginatedResultSync', + 'PresenceAction', + 'PresenceMessage', + 'PresenceSync', 'PubSubHttpClient', 'PublishResult', + 'PushAdminSync', 'PushChannelSubscription', + 'PushChannelSubscriptionsSync', + 'PushDeviceRegistrationsSync', 'PushSync', + 'Stats', 'TokenDetails', + 'TokenRequest', 'UpdateDeleteResult', 'VCDiffDecoder', 'api_version', diff --git a/test/unit/pubsub_server_test.py b/test/unit/pubsub_server_test.py index 0da6f813..bfac982f 100644 --- a/test/unit/pubsub_server_test.py +++ b/test/unit/pubsub_server_test.py @@ -1,3 +1,4 @@ +import importlib import inspect import typing @@ -180,3 +181,269 @@ def test_liveobjects_names_are_exported_from_their_own_module(self, name): def test_liveobjects_names_are_all_importable(self): for name in server_liveobjects.__all__: assert getattr(server_liveobjects, name) is not None + + +# Every name ably.pubsub.server exports, as (name, defining module, name there). The list +# is the public API: a name added or removed here is a change to it. +ASYNC_EXPORTS = [ + ('AblyAuthException', 'ably.pubsub.util.exceptions', 'AblyAuthException'), + ('AblyException', 'ably.pubsub.util.exceptions', 'AblyException'), + ('AblyVCDiffDecoder', 'ably.pubsub.vcdiff.defaultvcdiffdecoder', 'AblyVCDiffDecoder'), + ('Annotation', 'ably.pubsub.types.annotation', 'Annotation'), + ('AnnotationAction', 'ably.pubsub.types.annotation', 'AnnotationAction'), + ('Auth', 'ably.pubsub.http.auth', 'Auth'), + ('BatchPresenceFailureResult', 'ably.pubsub.types.batch', 'BatchPresenceFailureResult'), + ('BatchPresenceSuccessResult', 'ably.pubsub.types.batch', 'BatchPresenceSuccessResult'), + ('BatchPublishFailureResult', 'ably.pubsub.types.batch', 'BatchPublishFailureResult'), + ('BatchPublishSpec', 'ably.pubsub.types.batch', 'BatchPublishSpec'), + ('BatchPublishSuccessResult', 'ably.pubsub.types.batch', 'BatchPublishSuccessResult'), + ('BatchResult', 'ably.pubsub.types.batch', 'BatchResult'), + ('Capability', 'ably.pubsub.types.capability', 'Capability'), + ('Channel', 'ably.pubsub.http.channel', 'Channel'), + ('ChannelDetails', 'ably.pubsub.types.channeldetails', 'ChannelDetails'), + ('ChannelMetrics', 'ably.pubsub.types.channeldetails', 'ChannelMetrics'), + ('ChannelMode', 'ably.pubsub.types.channelmode', 'ChannelMode'), + ('ChannelOccupancy', 'ably.pubsub.types.channeldetails', 'ChannelOccupancy'), + ('ChannelOptions', 'ably.pubsub.types.channeloptions', 'ChannelOptions'), + ('ChannelState', 'ably.pubsub.types.channelstate', 'ChannelState'), + ('ChannelStateChange', 'ably.pubsub.types.channelstate', 'ChannelStateChange'), + ('ChannelStatus', 'ably.pubsub.types.channeldetails', 'ChannelStatus'), + ('CipherParams', 'ably.pubsub.util.crypto', 'CipherParams'), + ('Connection', 'ably.pubsub.realtime.connection', 'Connection'), + ('ConnectionEvent', 'ably.pubsub.types.connectionstate', 'ConnectionEvent'), + ('ConnectionState', 'ably.pubsub.types.connectionstate', 'ConnectionState'), + ('ConnectionStateChange', 'ably.pubsub.types.connectionstate', 'ConnectionStateChange'), + ('DeviceDetails', 'ably.pubsub.types.device', 'DeviceDetails'), + ('DevicePushDetails', 'ably.pubsub.types.device', 'DevicePushDetails'), + ('HttpAnnotations', 'ably.pubsub.http.annotations', 'HttpAnnotations'), + ('HttpChannels', 'ably.pubsub.http.channel', 'Channels'), + ('HttpPaginatedResponse', 'ably.pubsub.request.paginatedresult', 'HttpPaginatedResponse'), + ('IncompatibleClientIdException', 'ably.pubsub.util.exceptions', 'IncompatibleClientIdException'), + ('InvalidArgumentException', 'ably.pubsub.util.exceptions', 'InvalidArgumentException'), + ('Message', 'ably.pubsub.types.message', 'Message'), + ('MessageAction', 'ably.pubsub.types.message', 'MessageAction'), + ('MessageOperation', 'ably.pubsub.types.operations', 'MessageOperation'), + ('MessageVersion', 'ably.pubsub.types.message', 'MessageVersion'), + ('Options', 'ably.pubsub.types.options', 'Options'), + ('PaginatedResult', 'ably.pubsub.request.paginatedresult', 'PaginatedResult'), + ('Presence', 'ably.pubsub.types.presence', 'Presence'), + ('PresenceAction', 'ably.pubsub.types.presence', 'PresenceAction'), + ('PresenceMessage', 'ably.pubsub.types.presence', 'PresenceMessage'), + ('PubSubHttpClient', 'ably.pubsub.prototypes', 'PubSubHttpClient'), + ('PubSubRealtimeClient', 'ably.pubsub.prototypes', 'PubSubRealtimeClient'), + ('PublishResult', 'ably.pubsub.types.operations', 'PublishResult'), + ('Push', 'ably.pubsub.http.push', 'Push'), + ('PushAdmin', 'ably.pubsub.http.push', 'PushAdmin'), + ('PushChannelSubscription', 'ably.pubsub.types.channelsubscription', 'PushChannelSubscription'), + ('PushChannelSubscriptions', 'ably.pubsub.http.push', 'PushChannelSubscriptions'), + ('PushDeviceRegistrations', 'ably.pubsub.http.push', 'PushDeviceRegistrations'), + ('RealtimeAnnotations', 'ably.pubsub.realtime.annotations', 'RealtimeAnnotations'), + ('RealtimeChannel', 'ably.pubsub.realtime.channel', 'RealtimeChannel'), + ('RealtimeChannels', 'ably.pubsub.realtime.channel', 'Channels'), + ('RealtimePresence', 'ably.pubsub.realtime.presence', 'RealtimePresence'), + ('Stats', 'ably.pubsub.types.stats', 'Stats'), + ('TokenDetails', 'ably.pubsub.types.tokendetails', 'TokenDetails'), + ('TokenRequest', 'ably.pubsub.types.tokenrequest', 'TokenRequest'), + ('UpdateDeleteResult', 'ably.pubsub.types.operations', 'UpdateDeleteResult'), + ('VCDiffDecoder', 'ably.pubsub.types.options', 'VCDiffDecoder'), + ('api_version', 'ably.pubsub.version', 'api_version'), + ('create_http_client', 'ably.pubsub.server', 'create_http_client'), + ('create_realtime_client', 'ably.pubsub.server', 'create_realtime_client'), + ('generate_random_key', 'ably.pubsub.util.crypto', 'generate_random_key'), + ('get_default_params', 'ably.pubsub.util.crypto', 'get_default_params'), + ('lib_version', 'ably.pubsub.version', 'lib_version'), +] + +# The names that belong to the realtime client. There is no synchronous realtime client, so +# ably.pubsub.server.sync exports none of them, though the generated tree has classes for some. +REALTIME_ONLY = { + 'ChannelState', + 'ChannelStateChange', + 'Connection', + 'ConnectionEvent', + 'ConnectionState', + 'ConnectionStateChange', + 'PubSubRealtimeClient', + 'RealtimeAnnotations', + 'RealtimeChannel', + 'RealtimeChannels', + 'RealtimePresence', + 'create_realtime_client', +} + +# The synchronous name of an export whose generated class takes a Sync suffix. Every other +# export keeps its name in ably.pubsub.server.sync. +SYNC_NAMES = { + 'Auth': 'AuthSync', + 'Channel': 'ChannelSync', + 'HttpAnnotations': 'HttpAnnotationsSync', + 'HttpChannels': 'HttpChannelsSync', + 'HttpPaginatedResponse': 'HttpPaginatedResponseSync', + 'PaginatedResult': 'PaginatedResultSync', + 'Presence': 'PresenceSync', + 'Push': 'PushSync', + 'PushAdmin': 'PushAdminSync', + 'PushChannelSubscriptions': 'PushChannelSubscriptionsSync', + 'PushDeviceRegistrations': 'PushDeviceRegistrationsSync', +} + +SYNC_ALL = [ + 'AblyAuthException', + 'AblyException', + 'AblyVCDiffDecoder', + 'Annotation', + 'AnnotationAction', + 'AuthSync', + 'BatchPresenceFailureResult', + 'BatchPresenceSuccessResult', + 'BatchPublishFailureResult', + 'BatchPublishSpec', + 'BatchPublishSuccessResult', + 'BatchResult', + 'Capability', + 'ChannelDetails', + 'ChannelMetrics', + 'ChannelMode', + 'ChannelOccupancy', + 'ChannelOptions', + 'ChannelStatus', + 'ChannelSync', + 'CipherParams', + 'DeviceDetails', + 'DevicePushDetails', + 'HttpAnnotationsSync', + 'HttpChannelsSync', + 'HttpPaginatedResponseSync', + 'IncompatibleClientIdException', + 'InvalidArgumentException', + 'Message', + 'MessageAction', + 'MessageOperation', + 'MessageVersion', + 'Options', + 'PaginatedResultSync', + 'PresenceAction', + 'PresenceMessage', + 'PresenceSync', + 'PubSubHttpClient', + 'PublishResult', + 'PushAdminSync', + 'PushChannelSubscription', + 'PushChannelSubscriptionsSync', + 'PushDeviceRegistrationsSync', + 'PushSync', + 'Stats', + 'TokenDetails', + 'TokenRequest', + 'UpdateDeleteResult', + 'VCDiffDecoder', + 'api_version', + 'create_http_client', + 'generate_random_key', + 'get_default_params', + 'lib_version', +] + + +def sync_export(name, module, attr): + """The sync export an async one maps to, as (name, defining module, name there).""" + if module == 'ably.pubsub.version': + return name, module, attr + if module == 'ably.pubsub.server': + return name, 'ably.pubsub.server.sync', attr + sync_module = module.replace('ably.pubsub.', 'ably.pubsub.sync.', 1) + if name in SYNC_NAMES: + return SYNC_NAMES[name], sync_module, attr + 'Sync' + return name, sync_module, attr + + +SYNC_EXPORTS = [sync_export(*e) for e in ASYNC_EXPORTS if e[0] not in REALTIME_ONLY] + + +def star_import(module_name): + namespace = {} + exec(f'from {module_name} import *', namespace) + namespace.pop('__builtins__') + return namespace + + +class TestExports: + def test_async_all_is_pinned(self): + assert server.__all__ == [name for name, _, _ in ASYNC_EXPORTS] + + def test_sync_all_is_pinned(self): + assert server_sync.__all__ == SYNC_ALL + + @pytest.mark.parametrize('module', [server, server_sync], ids=['async', 'sync']) + def test_all_is_sorted_and_unique(self, module): + assert len(set(module.__all__)) == len(module.__all__) + assert module.__all__ == sorted(module.__all__, key=lambda n: (n[0].islower(), n)) + + @pytest.mark.parametrize('module', [server, server_sync], ids=['async', 'sync']) + def test_star_import_gives_every_name(self, module): + assert sorted(star_import(module.__name__)) == sorted(module.__all__) + + @pytest.mark.parametrize('name,module,attr', ASYNC_EXPORTS, ids=[e[0] for e in ASYNC_EXPORTS]) + def test_async_export_is_the_internal_object(self, name, module, attr): + assert getattr(server, name) is getattr(importlib.import_module(module), attr) + + @pytest.mark.parametrize('name,module,attr', SYNC_EXPORTS, ids=[e[0] for e in SYNC_EXPORTS]) + def test_sync_export_is_the_generated_object(self, name, module, attr): + assert getattr(server_sync, name) is getattr(importlib.import_module(module), attr) + + @pytest.mark.parametrize('name,module,attr', SYNC_EXPORTS, ids=[e[0] for e in SYNC_EXPORTS]) + def test_sync_class_is_named_as_exported(self, name, module, attr): + obj = getattr(server_sync, name) + if isinstance(obj, type) and name != 'HttpChannelsSync': + assert obj.__name__ == name + + def test_every_async_export_has_a_sync_counterpart_or_is_realtime_only(self): + missing = [ + name for name, _, _ in ASYNC_EXPORTS + if name not in REALTIME_ONLY and SYNC_NAMES.get(name, name) not in server_sync.__all__ + ] + assert missing == [] + assert sorted(sync_export(*e)[0] for e in ASYNC_EXPORTS if e[0] not in REALTIME_ONLY) \ + == sorted(server_sync.__all__) + + @pytest.mark.parametrize('name', sorted(REALTIME_ONLY)) + def test_realtime_only_names_are_absent_from_sync(self, name): + assert name in server.__all__ + assert name not in server_sync.__all__ + assert not hasattr(server_sync, name) + + @pytest.mark.parametrize('name', ['ConnectionDetails', 'MessageAnnotations', 'ErrorInfo', 'ResponseHeaders']) + def test_deferred_names_are_not_exported(self, name): + assert name not in server.__all__ + assert name not in server_sync.__all__ + + def test_handle_classes_match_what_the_clients_hand_out(self): + http = create_http_client(key='fake:key') + channel = http.channels.get('exports') + assert type(channel) is server.Channel + assert type(channel.presence) is server.Presence + assert type(channel.annotations) is server.HttpAnnotations + assert type(http.push.admin) is server.PushAdmin + assert type(http.push.admin.device_registrations) is server.PushDeviceRegistrations + assert type(http.push.admin.channel_subscriptions) is server.PushChannelSubscriptions + + sync_http = server_sync.create_http_client(key='fake:key') + sync_channel = sync_http.channels.get('exports') + assert type(sync_channel) is server_sync.ChannelSync + assert type(sync_channel.presence) is server_sync.PresenceSync + assert type(sync_channel.annotations) is server_sync.HttpAnnotationsSync + assert type(sync_http.push.admin) is server_sync.PushAdminSync + assert type(sync_http.push.admin.device_registrations) is server_sync.PushDeviceRegistrationsSync + assert type(sync_http.push.admin.channel_subscriptions) is server_sync.PushChannelSubscriptionsSync + sync_http.close() + + async def test_realtime_handle_classes_match_what_the_client_hands_out(self): + realtime = create_realtime_client(key='foo:bar', auto_connect=False) + channel = realtime.channels.get('exports') + assert type(channel) is server.RealtimeChannel + assert isinstance(channel, server.Channel) + assert type(channel.presence) is server.RealtimePresence + assert type(channel.annotations) is server.RealtimeAnnotations + assert type(channel.state) is server.ChannelState + assert type(realtime.connection.state) is server.ConnectionState + await realtime.close()