Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
c1fe520
feat: allow tests to supply the callable which opens websocket connec…
owenpearson Sep 23, 2026
c7ca8b4
feat: allow tests to supply the callable which schedules delayed call…
owenpearson Sep 23, 2026
9769e0e
test: add the UTS mock websocket and derive the auto-connect spec
owenpearson Sep 23, 2026
ae049fb
test: derive the realtime client unit specs
owenpearson Sep 23, 2026
cf3eda8
test: derive the connection state, id and error reason unit specs
owenpearson Sep 23, 2026
3a06689
test: derive the connection failure and realtime auth unit specs
owenpearson Sep 23, 2026
cc49530
test: derive the channel attach and detach unit specs
owenpearson Sep 23, 2026
0a734b7
test: derive the channel publish unit specs
owenpearson Sep 23, 2026
c1e53a5
test: derive the channel subscribe and message field unit specs
owenpearson Sep 23, 2026
04533ac
test: derive the channel option, property and collection unit specs
owenpearson Sep 23, 2026
ba05129
test: derive the channel connection state and event unit specs
owenpearson Sep 23, 2026
a884e9e
test: derive the presence enter, subscribe and get unit specs
owenpearson Sep 23, 2026
3029fd9
test: derive the channel annotation, delta and message version specs
owenpearson Sep 23, 2026
6aa674d
test: derive the presence map and sync unit specs
owenpearson Sep 24, 2026
b6d266f
test: derive the presence channel state, reentry and history specs
owenpearson Sep 24, 2026
96b75ea
test: derive the heartbeat, ping, fallback and recovery unit specs
owenpearson Sep 24, 2026
8c6bf15
docs: record the realtime deviations and the specification faults found
owenpearson Sep 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
382 changes: 373 additions & 9 deletions .claude/skills/uts-to-python/SKILL.md

Large diffs are not rendered by default.

8 changes: 5 additions & 3 deletions ably/realtime/channel.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
from ably.types.presence import PresenceMessage
from ably.util.eventemitter import EventEmitter
from ably.util.exceptions import AblyException, IncompatibleClientIdException
from ably.util.helper import Timer, is_callable_or_coroutine, validate_message_size
from ably.util.helper import Timer, is_callable_or_coroutine, select_timer, validate_message_size

if TYPE_CHECKING:
from ably.realtime.realtime import AblyRealtime
Expand Down Expand Up @@ -58,6 +58,7 @@ def __init__(self, realtime: AblyRealtime, name: str, channel_options: ChannelOp
EventEmitter.__init__(self)
self.__name = name
self.__realtime = realtime
self.__timer_func = select_timer(realtime.options)
self.__state = ChannelState.INITIALIZED
self.__message_emitter = EventEmitter()
self.__state_timer: Timer | None = None
Expand Down Expand Up @@ -846,7 +847,7 @@ def on_timeout() -> None:
self.__state_timer = None
self.__timeout_pending_state()

self.__state_timer = Timer(self.__realtime.options.realtime_request_timeout, on_timeout)
self.__state_timer = self.__timer_func(self.__realtime.options.realtime_request_timeout, on_timeout)

def __clear_state_timer(self) -> None:
if self.__state_timer:
Expand All @@ -866,7 +867,8 @@ def __start_retry_timer(self) -> None:
if self.__retry_timer:
return

self.__retry_timer = Timer(self.ably.options.channel_retry_timeout, self.__on_retry_timer_expire)
self.__retry_timer = self.__timer_func(
self.ably.options.channel_retry_timeout, self.__on_retry_timer_expire)

def __cancel_retry_timer(self) -> None:
if self.__retry_timer:
Expand Down
9 changes: 5 additions & 4 deletions ably/realtime/connectionmanager.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
from ably.types.tokendetails import TokenDetails
from ably.util.eventemitter import EventEmitter
from ably.util.exceptions import AblyException, IncompatibleClientIdException
from ably.util.helper import Timer, get_random_id, is_token_error
from ably.util.helper import Timer, get_random_id, is_token_error, select_timer

if TYPE_CHECKING:
from ably.realtime.realtime import AblyRealtime
Expand Down Expand Up @@ -140,6 +140,7 @@ def response_time_ms(self) -> float:
class ConnectionManager(EventEmitter):
def __init__(self, realtime: AblyRealtime, initial_state):
self.options = realtime.options
self.timer_func = select_timer(self.options)
self.__ably = realtime
self.__state: ConnectionState = initial_state
self.__pending_pings: dict[str, PendingPing] = {}
Expand Down Expand Up @@ -718,7 +719,7 @@ def on_transition_timer_expire():

log.debug(f'ConnectionManager.start_transition_timer(): setting timer for {timeout}ms')

self.transition_timer = Timer(timeout, on_transition_timer_expire)
self.transition_timer = self.timer_func(timeout, on_transition_timer_expire)

def cancel_transition_timer(self):
log.debug('ConnectionManager.cancel_transition_timer()')
Expand All @@ -741,7 +742,7 @@ def on_suspend_timer_expire() -> None:
)
self.__fail_state = ConnectionState.SUSPENDED

self.suspend_timer = Timer(Defaults.connection_state_ttl, on_suspend_timer_expire)
self.suspend_timer = self.timer_func(Defaults.connection_state_ttl, on_suspend_timer_expire)

def check_suspend_timer(self, state: ConnectionState) -> None:
if state not in (
Expand All @@ -764,7 +765,7 @@ def on_retry_timeout():
self.retry_timer = None
self.request_state(ConnectionState.CONNECTING)

self.retry_timer = Timer(interval, on_retry_timeout)
self.retry_timer = self.timer_func(interval, on_retry_timeout)

def cancel_retry_timer(self) -> None:
if self.retry_timer:
Expand Down
17 changes: 13 additions & 4 deletions ably/transport/websockettransport.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
from ably.types.operations import PublishResult
from ably.util.eventemitter import EventEmitter
from ably.util.exceptions import AblyException
from ably.util.helper import Timer, unix_time_ms
from ably.util.helper import select_timer, unix_time_ms

try:
# websockets 15+ preferred imports
Expand Down Expand Up @@ -67,6 +67,8 @@ def __init__(self, connection_manager: ConnectionManager, host: str, params: dic
self.ws_connect_task: asyncio.Task | None = None
self.connection_manager = connection_manager
self.options = self.connection_manager.options
self.connect_func = self.__select_connect_func(self.options)
self.timer_func = select_timer(self.options)
self.is_connected = False
self.idle_timer = None
self.last_activity = None
Expand All @@ -77,6 +79,13 @@ def __init__(self, connection_manager: ConnectionManager, host: str, params: dic
self.format = params.get('format', 'json')
super().__init__()

@staticmethod
def __select_connect_func(options):
test_options = getattr(options, 'test_options', None)
if test_options is not None and test_options.websocket_connect is not None:
return test_options.websocket_connect
return ws_connect

def connect(self):
headers = HttpUtils.default_headers()
query_params = urllib.parse.urlencode(self.params)
Expand All @@ -101,11 +110,11 @@ async def ws_connect(self, ws_url, headers):
try:
# Use additional_headers for websockets 15+, fallback to extra_headers for older versions
try:
async with ws_connect(ws_url, additional_headers=headers) as websocket:
async with self.connect_func(ws_url, additional_headers=headers) as websocket:
await self._handle_websocket_connection(ws_url, websocket)
except TypeError:
# Fallback for websockets 14 and earlier
async with ws_connect(ws_url, extra_headers=headers) as websocket:
async with self.connect_func(ws_url, extra_headers=headers) as websocket:
await self._handle_websocket_connection(ws_url, websocket)
except (WebSocketException, socket.gaierror) as e:
exception = AblyException(f'Error opening websocket connection: {e}', 400, 40000)
Expand Down Expand Up @@ -292,7 +301,7 @@ async def send(self, message: dict):
def set_idle_timer(self, timeout: float):
if self.idle_timer:
self.idle_timer.cancel()
self.idle_timer = Timer(timeout, self.on_idle_timer_expire)
self.idle_timer = self.timer_func(timeout, self.on_idle_timer_expire)

async def on_idle_timer_expire(self):
self.idle_timer = None
Expand Down
15 changes: 14 additions & 1 deletion ably/types/testoptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,24 @@ class TestOptions:
:Parameters:
- `http_transport`: an `httpx.AsyncBaseTransport` which handles every
HTTP request the client makes, in place of the network.
- `websocket_connect`: a callable which opens every realtime websocket
connection the client makes, in place of `websockets.connect`. It is
called as `websocket_connect(url, additional_headers=headers)`, or with
`extra_headers=headers` if that raises `TypeError`, and returns an async
context manager yielding an object supporting `__aiter__`, `send` and
`close`.
- `timer`: a callable which schedules every delayed callback the realtime
client makes, in place of `ably.util.helper.Timer`. It is called as
`timer(timeout_ms, callback)`, where `callback` is either a coroutine
function or a plain callable, and returns an object with a `cancel()`
method.
"""

# Excludes the class from pytest collection, which would otherwise treat
# any module importing it as declaring a test suite.
__test__ = False

def __init__(self, http_transport=None):
def __init__(self, http_transport=None, websocket_connect=None, timer=None):
self.http_transport = http_transport
self.websocket_connect = websocket_connect
self.timer = timer
13 changes: 13 additions & 0 deletions ably/util/helper.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,19 @@ async def _job(self):
def cancel(self):
self._task.cancel()


def select_timer(options) -> Callable:
"""The callable a client schedules its delayed callbacks with.

`TestOptions.timer` substitutes for the real timer during tests, letting
them drive time-dependent behaviour without waiting for it. Clients which
supply none get `Timer`.
"""
test_options = getattr(options, 'test_options', None)
if test_options is not None and test_options.timer is not None:
return test_options.timer
return Timer

def validate_message_size(encoded_messages: list, use_binary_protocol: bool, max_message_size: int) -> None:
"""Validate that encoded messages don't exceed the maximum size limit.

Expand Down
95 changes: 84 additions & 11 deletions test/uts/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,25 +7,30 @@ carries a `# UTS: <id>` comment identifying the specification it came from.

Read `uts/docs/writing-derived-tests.md` in the specification repository before
adding or changing tests here, alongside `.claude/skills/uts-to-python/SKILL.md`,
which covers what is particular to this SDK. Record anything that departs from a
specification in [deviations.md](deviations.md), which also covers how the
specifications are adopted here and why.
which covers what is particular to this SDK and lists every helper below.

Record anything that departs from a specification in [deviations.md](deviations.md),
which also covers the faults found in the specifications themselves and raised
upstream, and the choices behind how the specifications are adopted here.

## Layout

```
helpers/ shared infrastructure the specifications assume
helpers/ shared infrastructure the specifications assume, and its own tests
rest/ specifications under uts/rest
realtime/ specifications under uts/realtime
```

Unit tests serve every request from a mock and reach no network. Integration
tests run against a sandbox app.
Every directory needs an `__init__.py`, because `test` is a package.

Unit tests serve every request from a mock and reach no network — neither the REST
suite nor the realtime one. Integration tests run against a sandbox app.

## Installing the mock
## Installing the mocks

The specifications express mock installation as a global `install_mock(mock_http)`.
Here a mock is passed to the client it serves:
Here a mock is passed to the client it serves, through `TestOptions`. There are three
seams, all client-scoped; [deviations.md](deviations.md) says why.

```python
mock_http = MockHttpClient(
Expand All @@ -35,11 +40,79 @@ mock_http = MockHttpClient(
ably = AblyRest(key=key, test_options=TestOptions(http_transport=mock_http.as_transport()))
```

The client builds its HTTP client once, so construct the mock first. Teardown is
`await ably.close()`, which stands in for `uninstall_mock()`.
A realtime client takes its websocket mock the same way, through
`TestOptions(websocket_connect=...)`, and its timers through `TestOptions(timer=...)`:

```python
mock_ws = MockWebSocket(
on_connection_attempt=lambda conn: conn.respond_with_success(CONNECTED_MESSAGE),
)
ably = AblyRealtime(key=key, auto_connect=False,
test_options=TestOptions(websocket_connect=mock_ws.as_connect(),
timer=FakeClock().timer))
```

`rest_client(mock_http, ...)` and `realtime_client(mock_ws, ...)` in
[helpers/client.py](helpers/client.py) wrap all three, defaulting the credentials
and registering the client for teardown:

```python
realtime_client(mock_websocket=None, mock_http=None, clock=None, **kwargs)
```

`mock_http=` gives a realtime client an HTTP mock for the specifications that drive
REST over a realtime client; `clock=` installs a `FakeClock`. `realtime_client`
defaults `auto_connect` to **false** and `fallback_hosts` to **empty** — both
deliberate, and both explained in [deviations.md](deviations.md).

The client builds its HTTP client once and reads its websocket hook once, so
construct the mocks first. Teardown is automatic: `conftest.py` closes every
registered client after each test, which stands in for `uninstall_mock()`. **Do not
close clients in a test** — the fixture survives every connection state, and a test
that closes its own leaves nothing to clean up if it fails first.

## What the helpers offer

| Module | Holds |
|---|---|
| [helpers/mock_http.py](helpers/mock_http.py) | `MockHttpClient`, matching `uts/rest/unit/helpers/mock_http.md`, including the superseded `queue_*` family |
| [helpers/mock_websocket.py](helpers/mock_websocket.py) | `MockWebSocket`, matching `uts/realtime/unit/helpers/mock_websocket.md`, plus the protocol-message templates and builders the specifications assume |
| [helpers/client.py](helpers/client.py) | client constructors, and the `AWAIT_STATE` / `AWAIT UNTIL` equivalents |
| [helpers/clock.py](helpers/clock.py) | `FakeClock`, `settle()` and `advance_to_connection_state()` — `enable_fake_timers()` and `ADVANCE_TIME(ms)` |
| [helpers/presence.py](helpers/presence.py) | the presence-map stubs and wire-message builders the presence specifications share |
| [helpers/deviations.py](helpers/deviations.py) | the `@deviation` and `@spec_error` gates |

`SKILL.md` lists every name in each. The helpers have their own tests
(`helpers/*_test.py`), which are not derived from a specification and are not counted
in the derived-test totals.

## Running

```
uv run --extra crypto pytest test/uts
uv run --frozen --extra crypto --extra dev pytest test/uts -q
```

`--frozen` is required: without it dependency resolution reaches past the
environment's cutoff. `--extra dev` carries pytest.

Tests that record a deviation or a specification fault are skipped by default and run
under an environment variable:

```
RUN_DEVIATIONS=1 uv run --frozen --extra crypto --extra dev pytest test/uts -q
```

Every gated test is confirmed to fail when enabled — none passes under both
behaviours — so the two runs are the check that the record in
[deviations.md](deviations.md) is still true. The counts either run should produce are
in that file's header.

Both seams are installed per client, so a test that forgets one, or that lets the host
fallback loop run, reaches the real internet; see the fallback host note in
[deviations.md](deviations.md).

Linting is `ruff`, line length 115:

```
uv run --frozen --extra crypto --extra dev ruff check ably/ test/
```
Loading
Loading