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..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`: @@ -922,6 +925,130 @@ 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 +from ably.pubsub.server import PresenceAction + +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 +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/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/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/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/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/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() 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() 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')