Skip to content

Conversation

ttypic
Copy link
Contributor

@ttypic ttypic commented Aug 22, 2025

Resolves #606

Summary by CodeRabbit

  • Refactor

    • Reorganized websocket connection handling for broader compatibility across websocket library versions; end-user behavior unchanged.
    • Improved import/path rewriting logic in the unasync script to handle full module paths and standalone sleep imports.
  • Chores

    • Updated dependency constraints to add support for newer websockets (enabling Python 3.9) while restricting Python 3.8 to patch releases.
    • CI/test runner now runs pytest with verbose, shortened tracebacks.
  • Tests

    • Stabilized an interoperability test by unifying message data handling, adding short delays, and adding a data-type assertion.

@ttypic ttypic requested a review from sacOO7 August 22, 2025 11:23
Copy link

coderabbitai bot commented Aug 22, 2025

Walkthrough

Refactors websocket import/connection handling to support websockets v15+ and older versions, centralizes post-connection logic into a helper, relaxes pyproject websockets constraints to permit 15.x on newer Python ranges, and introduces minor CI and test changes.

Changes

Cohort / File(s) Summary
WebSocket transport
ably/transport/websockettransport.py
Add version-aware imports for websockets; attempt additional_headers then fall back to extra_headers; extract post-connection logic into _handle_websocket_connection(ws_url, websocket); preserve read-loop, callbacks, and error handling.
Dependency constraints
pyproject.toml
Update websockets constraints: for ~3.8 use >=12.0,<15.0; for newer Python allow >=15.0,<16.0.
CI configuration
.github/workflows/check.yml
Run pytest with --verbose --tb=short flags.
Tests
test/ably/rest/restchannelpublish_test.py
Introduce sleep import; use msg_data variable; add 1s asyncio.sleep delays around publishes; tighten assertions including a data-type check.
Unasync script
ably/scripts/unasync.py
Expand import-path parsing and replacement logic; special-case from asyncio import sleep -> from time import sleep; emit path segments as NAME/OP tokens and apply replacements/class renames.

Sequence Diagram(s)

sequenceDiagram
  participant Transport as WebSocketTransport
  participant WSLib as websockets lib
  participant Task as ws_read_loop

  Note over Transport,WSLib: import resolution (v15+ top-level or v14- fallback)
  Transport->>WSLib: ws_connect(url, headers / additional_headers)
  WSLib-->>Transport: websocket
  Transport->>Transport: _handle_websocket_connection(ws_url, websocket)
  Transport->>Task: create task(ws_read_loop)
  Task-->>Transport: on_protocol_message / on_ws_connect_done
  alt WebSocketException
    Transport->>Transport: dispose() & deactivate() (if not disposed)
  end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Assessment against linked issues

Objective Addressed Explanation
Bump websockets to satisfy >=15.0.1 for Python >=3.9 [#606, ECO-5526] pyproject sets >=15.0,<16.0 (not >=15.0.1), so minimum patch-level requirement (15.0.1) is not enforced.

Out-of-scope changes

Code Change Explanation
Add pytest flags in CI (.github/workflows/check.yml) CI verbosity change unrelated to websockets dependency or compatibility objective.
Test timing and data adjustments (test/ably/rest/restchannelpublish_test.py) Test delays/type assertions are unrelated to the dependency bump or transport import compatibility objective.
Import-token parsing changes (ably/scripts/unasync.py) Extended import rewriting logic and special-casing for asyncio.sleep do not relate to websockets dependency/version objective.

I nibble headers, hop between two specs,
Top-level or client—both paths I check.
I sleep a beat, then read the stream,
A rabbit guard for every dream.
Hooray for sockets, hop-hop, beam! 🐇

Tip

🔌 Remote MCP (Model Context Protocol) integration is now available!

Pro plan users can now connect to remote MCP servers from the Integrations page. Connect with popular remote MCPs such as Notion and Linear to add more context to your reviews and chats.

✨ Finishing Touches
  • 📝 Generate Docstrings
🧪 Generate unit tests
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch ECO-5526/upgrade-websockets

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share
🪧 Tips

Chat

There are 3 ways to chat with CodeRabbit:

  • Review comments: Directly reply to a review comment made by CodeRabbit. Example:
    • I pushed a fix in commit <commit_id>, please review it.
    • Open a follow-up GitHub issue for this discussion.
  • Files and specific lines of code (under the "Files changed" tab): Tag @coderabbitai in a new review comment at the desired location with your query.
  • PR comments: Tag @coderabbitai in a new PR comment to ask questions about the PR branch. For the best results, please provide a very specific query, as very limited context is provided in this mode. Examples:
    • @coderabbitai gather interesting stats about this repository and render them as a table. Additionally, render a pie chart showing the language distribution in the codebase.
    • @coderabbitai read the files in the src/scheduler package and generate a class diagram using mermaid and a README in the markdown format.

Support

Need help? Create a ticket on our support page for assistance with any issues or questions.

CodeRabbit Commands (Invoked using PR/Issue comments)

Type @coderabbitai help to get the list of available commands.

Other keywords and placeholders

  • Add @coderabbitai ignore anywhere in the PR description to prevent this PR from being reviewed.
  • Add @coderabbitai summary to generate the high-level summary at a specific location in the PR description.
  • Add @coderabbitai anywhere in the PR title to generate the title automatically.

CodeRabbit Configuration File (.coderabbit.yaml)

  • You can programmatically configure CodeRabbit by adding a .coderabbit.yaml file to the root of your repository.
  • Please see the configuration documentation for more information.
  • If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: # yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json

Status, Documentation and Community

  • Visit our Status Page to check the current availability of CodeRabbit.
  • Visit our Documentation for detailed information on how to use CodeRabbit.
  • Join our Discord Community to get help, request features, and share feedback.
  • Follow us on X/Twitter for updates and announcements.

@github-actions github-actions bot temporarily deployed to staging/pull/612/features August 22, 2025 11:24 Inactive
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

🧹 Nitpick comments (2)
pyproject.toml (1)

43-43: Optional: tighten the 3.8 pin back to <14.0 for clarity.

websockets 14.0+ require Python ≥3.9. While your current constraint (>=12,<15) won’t actually install 14.x on 3.8, restoring “<14.0” documents intent and avoids resolver churn. Low-impact, but improves readability. (websockets.readthedocs.io)

Proposed tweak:

-  { version = ">= 12.0, < 15.0", python = "~3.8" },
+  { version = ">= 12.0, < 14.0", python = "~3.8" },
ably/transport/websockettransport.py (1)

14-14: Prefer explicit exception imports; avoid convenience re-exports

This change ensures that static analyzers and future versions of websockets correctly resolve exceptions from websockets.exceptions, while keeping connect at top-level (stable since v14.0). We’ve verified this works under both v12.0 and v15.0.1 of websockets.

File: ably/transport/websockettransport.py
Line: 14

Minimal import refactor:

-from websockets import WebSocketClientProtocol, connect as ws_connect, ConnectionClosedOK, WebSocketException
+from websockets import connect as ws_connect
+from websockets.exceptions import ConnectionClosedOK, WebSocketException

Optional: avoid a runtime dependency on the legacy WebSocketClientProtocol by leveraging from __future__ import annotations and a TYPE_CHECKING block:

if TYPE_CHECKING:
    try:
        from websockets.asyncio.client import ClientConnection as WSClient  # ≥14.2
    except ImportError:
        try:
            from websockets.legacy.client import WebSocketClientProtocol as WSClient  # ≤13.x
        except ImportError:
            from typing import Any as WSClient

# then update your annotation:
self.websocket: WSClient | None = None

✅ Verified under websockets v12.0 and v15.0.1:

  • connect remains at top-level
  • ConnectionClosedOK & WebSocketException live in websockets.exceptions
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between 7768acb and c209612.

⛔ Files ignored due to path filters (1)
  • poetry.lock is excluded by !**/*.lock
📒 Files selected for processing (2)
  • ably/transport/websockettransport.py (1 hunks)
  • pyproject.toml (1 hunks)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (7)
  • GitHub Check: check (3.8)
  • GitHub Check: check (3.11)
  • GitHub Check: check (3.7)
  • GitHub Check: check (3.12)
  • GitHub Check: check (3.13)
  • GitHub Check: check (3.9)
  • GitHub Check: check (3.10)
🔇 Additional comments (2)
pyproject.toml (2)

44-44: Align constraint with the stated goal: require websockets ≥ 15.0.1 (not 15.0.0).

The PR objective says “allow websockets ≥15.0.1”. Today 15.0.1 is the latest in the 15.x line (released March 5, 2025), and 15.0.0 had a small bugfix follow-up; let’s codify that floor. (websockets.readthedocs.io, pypi.org)

Apply this minimal diff:

-  { version = ">= 15.0, < 16.0", python = "^3.9" },
+  { version = ">= 15.0.1, < 16.0", python = "^3.9" },

41-45: Compatibility matrix looks good overall.

  • 3.7 → websockets 10–11 (ok; 12+ require 3.8+).
  • 3.8 → websockets 12–13/13.1 (or 14 excluded per optional nit).
  • 3.9+ → websockets 15.x (15 is last to support 3.9; 16 requires 3.10+). (websockets.readthedocs.io)

Copy link
Collaborator

@sacOO7 sacOO7 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems CI failing for python 3.7 and 3.8, can you check

@sacOO7
Copy link
Collaborator

sacOO7 commented Aug 22, 2025

CI is also red on main for all python versions, need to check what's going on 🤔

@ttypic ttypic force-pushed the ECO-5526/upgrade-websockets branch from c209612 to 0318ed4 Compare August 22, 2025 16:12
@github-actions github-actions bot temporarily deployed to staging/pull/612/features August 22, 2025 16:13 Inactive
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (2)
ably/transport/websockettransport.py (2)

82-89: Header-compat shim is correct; fix the linter error and consider a tiny cleanup.

  • The “try additional_headers → fallback to extra_headers” sequence matches the API change (extra_headers → additional_headers). (websockets.readthedocs.io)
  • Lint: GitHub Actions flagged W291 “trailing whitespace” on Line 82. Remove the trailing spaces at the end of the comment.

Apply:

-            # Use additional_headers for websockets 15+, fallback to extra_headers for older versions  
+            # Use additional_headers for websockets 15+, fallback to extra_headers for older versions

90-95: Preserve the original traceback cause when re-raising (Ruff B904).

Raise the wrapped AblyException with the original exception as the cause for clearer diagnostics and to satisfy B904.

         except (WebSocketException, socket.gaierror) as e:
             exception = AblyException(f'Error opening websocket connection: {e}', 400, 40000)
             log.exception(f'WebSocketTransport.ws_connect(): Error opening websocket connection: {exception}')
             self._emit('failed', exception)
-            raise exception
+            raise exception from e
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between c209612 and 0318ed4.

⛔ Files ignored due to path filters (1)
  • poetry.lock is excluded by !**/*.lock
📒 Files selected for processing (2)
  • ably/transport/websockettransport.py (2 hunks)
  • pyproject.toml (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • pyproject.toml
🧰 Additional context used
🧬 Code graph analysis (1)
ably/transport/websockettransport.py (4)
ably/util/exceptions.py (1)
  • AblyException (9-84)
ably/util/eventemitter.py (1)
  • _emit (183-185)
ably/types/options.py (1)
  • loop (226-227)
ably/realtime/connectionmanager.py (1)
  • deactivate_transport (244-246)
🪛 Ruff (0.12.2)
ably/transport/websockettransport.py

94-94: Within an except clause, raise exceptions with raise ... from err or raise ... from None to distinguish them from errors in exception handling

(B904)

🪛 GitHub Actions: Linting check
ably/transport/websockettransport.py

[error] 82-82: Flake8: W291 trailing whitespace detected on line 82. Command: 'poetry run flake8'.

⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (7)
  • GitHub Check: check (3.12)
  • GitHub Check: check (3.13)
  • GitHub Check: check (3.9)
  • GitHub Check: check (3.8)
  • GitHub Check: check (3.7)
  • GitHub Check: check (3.11)
  • GitHub Check: check (3.10)
🔇 Additional comments (1)
ably/transport/websockettransport.py (1)

14-20: Compat imports verified; no residual deprecated references found

  • A repository-wide search for websockets.client, WebSocketClientProtocol, and extra_headers= returned only the occurrences within ably/transport/websockettransport.py, confirming there are no lingering imports or deprecated API uses elsewhere.
  • The existing try/except block properly confines the compatibility logic to this file, and no additional clean-up is required.

Comment on lines +96 to +108
async def _handle_websocket_connection(self, ws_url, websocket):
log.info(f'ws_connect(): connection established to {ws_url}')
self._emit('connected')
self.websocket = websocket
self.read_loop = self.connection_manager.options.loop.create_task(self.ws_read_loop())
self.read_loop.add_done_callback(self.on_read_loop_done)
try:
await self.read_loop
except WebSocketException as err:
if not self.is_disposed:
await self.dispose()
self.connection_manager.deactivate_transport(err)

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

🧩 Analysis chain

Two fixes: emit order to avoid race, and pass AblyException to deactivate_transport.

  • Emit order: You emit “connected” before setting self.websocket. If any listener sends immediately on the “connected” event, send() will see self.websocket is None and raise. Set self.websocket first, then emit.
  • Reason type: ConnectionManager.deactivate_transport() expects an Optional[AblyException] (see type hints); passing a raw WebSocketException may break downstream handling. Wrap with AblyException.from_exception(err).

Apply:

     async def _handle_websocket_connection(self, ws_url, websocket):
         log.info(f'ws_connect(): connection established to {ws_url}')
-        self._emit('connected')
-        self.websocket = websocket
+        self.websocket = websocket
+        self._emit('connected')
         self.read_loop = self.connection_manager.options.loop.create_task(self.ws_read_loop())
         self.read_loop.add_done_callback(self.on_read_loop_done)
         try:
             await self.read_loop
         except WebSocketException as err:
             if not self.is_disposed:
                 await self.dispose()
-                self.connection_manager.deactivate_transport(err)
+                self.connection_manager.deactivate_transport(AblyException.from_exception(err))

If you want to be extra sure we don’t have other call sites passing non-Ably exceptions into deactivate_transport, run:


🏁 Script executed:

#!/usr/bin/env bash
# Inspect deactivate_transport call sites and show the argument expressions.
rg -nP --type=py -C2 '\bdeactivate_transport\s*\('

Length of output: 1116


Swap websocket assignment and emit, and wrap exceptions before deactivating transport

Please apply the following mandatory changes in ably/transport/websockettransport.py:

• In _handle_websocket_connection (around lines 96–108), set self.websocket before emitting “connected”, and wrap the caught WebSocketException in an AblyException:

     async def _handle_websocket_connection(self, ws_url, websocket):
         log.info(f'ws_connect(): connection established to {ws_url}')
-        self._emit('connected')
-        self.websocket = websocket
+        self.websocket = websocket
+        self._emit('connected')
         self.read_loop = self.connection_manager.options.loop.create_task(self.ws_read_loop())
         self.read_loop.add_done_callback(self.on_read_loop_done)
         try:
             await self.read_loop
         except WebSocketException as err:
             if not self.is_disposed:
                 await self.dispose()
-                self.connection_manager.deactivate_transport(err)
+                self.connection_manager.deactivate_transport(
+                    AblyException.from_exception(err)
+                )

• There is one other call site at your disconnect(self, reason=None) method (around lines 232–234) that does:

await self.dispose()
self.connection_manager.deactivate_transport(reason)

Since deactivate_transport expects an Optional[AblyException], ensure that any non-AblyException value passed in (e.g. a raw exception) is likewise wrapped via AblyException.from_exception(reason) or validate callers only pass AblyException.

🤖 Prompt for AI Agents
In ably/transport/websockettransport.py around lines 96 to 108, the websocket is
assigned after emitting 'connected' and a raw WebSocketException is passed into
deactivate_transport; swap the order so self.websocket = websocket happens
before self._emit('connected'), and in the except WebSocketException block wrap
the caught err in an AblyException (e.g. AblyException.from_exception(err))
before calling self.connection_manager.deactivate_transport; also update the
other call site in disconnect (around lines 232–234) to ensure any
non-AblyException reason is wrapped with AblyException.from_exception(reason)
(or validate callers) before passing to connection_manager.deactivate_transport.

@ttypic ttypic force-pushed the ECO-5526/upgrade-websockets branch from 0318ed4 to 202e0e8 Compare August 22, 2025 16:35
@github-actions github-actions bot temporarily deployed to staging/pull/612/features August 22, 2025 16:36 Inactive
@ttypic ttypic requested a review from sacOO7 August 22, 2025 16:38
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (2)
ably/transport/websockettransport.py (2)

232-234: Ensure deactivate_transport receives an AblyException (or None)

disconnect() may be called with arbitrary exceptions. Wrap non-Ably exceptions for type consistency and downstream handling.

-    async def disconnect(self, reason=None):
+    async def disconnect(self, reason=None):
         await self.dispose()
-        self.connection_manager.deactivate_transport(reason)
+        self.connection_manager.deactivate_transport(
+            reason if isinstance(reason, AblyException) else AblyException.from_exception(reason)
+        )

Quick scan for other call sites passing non-Ably exceptions:

#!/usr/bin/env bash
# Inspect deactivate_transport call sites and show argument expressions and context.
rg -nP -C3 --type=py '\bdeactivate_transport\s*\('

96-108: Fix emit order and exception type passed to deactivate_transport

Emit “connected” only after assigning self.websocket to avoid send() races; and pass an AblyException into deactivate_transport (it expects Optional[AblyException]).

     async def _handle_websocket_connection(self, ws_url, websocket):
         log.info(f'ws_connect(): connection established to {ws_url}')
-        self._emit('connected')
-        self.websocket = websocket
+        self.websocket = websocket
+        self._emit('connected')
         self.read_loop = self.connection_manager.options.loop.create_task(self.ws_read_loop())
         self.read_loop.add_done_callback(self.on_read_loop_done)
         try:
             await self.read_loop
         except WebSocketException as err:
             if not self.is_disposed:
                 await self.dispose()
-                self.connection_manager.deactivate_transport(err)
+                self.connection_manager.deactivate_transport(
+                    AblyException.from_exception(err)
+                )
🧹 Nitpick comments (7)
test/ably/rest/restchannelpublish_test.py (1)

406-408: Replace fixed sleeps with polling to reduce flakiness and test time

Hard-coded sleeps make the suite slow and still flaky under variable load. Poll until the condition is satisfied or a short timeout elapses.

-                # temporary added delay, we need to investigate why messages don't appear immediately
-                await asyncio.sleep(1)
+                # Wait for message visibility with bounded polling to avoid flakiness
+                for _ in range(10):  # ~1s worst case
+                    async with httpx.AsyncClient(http2=True) as client:
+                        r = await client.get(url, auth=auth)
+                    if r.is_success and r.json():
+                        break
+                    await asyncio.sleep(0.1)
...
-                # temporary added delay, we need to investigate why messages don't appear immediately
-                await asyncio.sleep(1)
+                # Wait for history to reflect the message (bounded polling)
+                for _ in range(10):
+                    history = await channel.history()
+                    if history.items:
+                        break
+                    await asyncio.sleep(0.1)

If you prefer a reusable helper instead of inlined loops, I can add a local async utility (kept in this test file) to poll with a timeout—say, wait_for(predicate, timeout=1.5, interval=0.1). Want me to push that as a follow-up?

Also applies to: 418-421

ably/transport/websockettransport.py (6)

90-95: Preserve original exception context when re-raising

Use exception chaining to aid debugging and satisfy linters (ruff B904).

-        except (WebSocketException, socket.gaierror) as e:
+        except (WebSocketException, socket.gaierror) as e:
             exception = AblyException(f'Error opening websocket connection: {e}', 400, 40000)
             log.exception(f'WebSocketTransport.ws_connect(): Error opening websocket connection: {exception}')
             self._emit('failed', exception)
-            raise exception
+            raise exception from e

61-68: Create tasks on the configured loop for consistency

connect() uses asyncio.create_task while ws_read_loop uses self.connection_manager.options.loop.create_task. Mixing loops can lead to subtle lifecycle issues if a non-default loop is configured.

-        self.ws_connect_task = asyncio.create_task(self.ws_connect(ws_url, headers))
+        self.ws_connect_task = self.connection_manager.options.loop.create_task(
+            self.ws_connect(ws_url, headers)
+        )

205-206: Avoid raising a bare Exception when websocket is not set

Raise a typed exception to aid upstream handling and diagnostics.

-        if self.websocket is None:
-            raise Exception()
+        if self.websocket is None:
+            raise AblyException('send() called with no active websocket', 500, 50000)

171-177: Typo in callback name (protcol) — consider renaming for clarity

Minor but noisy for maintenance and searchability.

-                task.add_done_callback(self.on_protcol_message_handled)
+                task.add_done_callback(self.on_protocol_message_handled)
...
-    def on_protcol_message_handled(self, task):
+    def on_protocol_message_handled(self, task):

Note: Adjust all references accordingly.

Also applies to: 167-168


80-90: Header-arg fallback is good; optionally pre-detect to avoid try/except cost on every connect

Your TypeError fallback is pragmatic. If you want to avoid relying on exceptions for flow control, detect the parameter name once via inspect.signature and choose the appropriate kwarg. Not critical.

Example (outside hot path, e.g., module import):

import inspect
_SIG = inspect.signature(ws_connect)
_HAS_ADDITIONAL = 'additional_headers' in _SIG.parameters

Then:

kwargs = {'additional_headers' if _HAS_ADDITIONAL else 'extra_headers': headers}
async with ws_connect(ws_url, **kwargs) as websocket:
    await self._handle_websocket_connection(ws_url, websocket)

14-20: Prefer top-level websockets.connect and use a typing-only import for the protocol

We’ve confirmed that in websockets v15,

  • websockets.connect remains available at the top level and supports the additional_headers parameter, and
  • there’s no official need to import a separate client type at runtime.

You can simplify the import logic to only pull in connect, falling back for older versions, and defer the WebSocketClientProtocol import to type‐checking time:

 try:
     # Top-level connect is stable in v15+
-    from websockets import ClientConnection as WebSocketClientProtocol, connect as ws_connect
+    from websockets import connect as ws_connect
 except ImportError:
     # Fallback for websockets ≤14
-    from websockets.client import WebSocketClientProtocol, connect as ws_connect
+    from websockets.client import connect as ws_connect  # type: ignore[assignment]

+from typing import TYPE_CHECKING
+if TYPE_CHECKING:
+    # Only for type annotations; no runtime dependency on websockets internals
+    from websockets.client import WebSocketClientProtocol
+else:
+    WebSocketClientProtocol = object  # placeholder at runtime

• This eliminates version-sensitive imports at module load, relying only on connect (stable across versions).
• Use TYPE_CHECKING so your runtime bundle isn’t tied to the internal protocol class.
• You can still annotate functions/methods that return or accept WebSocketClientProtocol.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between 0318ed4 and 202e0e8.

⛔ Files ignored due to path filters (1)
  • poetry.lock is excluded by !**/*.lock
📒 Files selected for processing (4)
  • .github/workflows/check.yml (1 hunks)
  • ably/transport/websockettransport.py (2 hunks)
  • pyproject.toml (1 hunks)
  • test/ably/rest/restchannelpublish_test.py (3 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • pyproject.toml
🧰 Additional context used
🧬 Code graph analysis (2)
test/ably/rest/restchannelpublish_test.py (2)
ably/rest/channel.py (2)
  • get (182-193)
  • publish (106-132)
ably/types/message.py (1)
  • Message (24-226)
ably/transport/websockettransport.py (4)
ably/util/exceptions.py (1)
  • AblyException (9-84)
ably/util/eventemitter.py (1)
  • _emit (183-185)
ably/types/options.py (1)
  • loop (226-227)
ably/realtime/connectionmanager.py (1)
  • deactivate_transport (244-246)
🪛 Ruff (0.12.2)
ably/transport/websockettransport.py

94-94: Within an except clause, raise exceptions with raise ... from err or raise ... from None to distinguish them from errors in exception handling

(B904)

🔇 Additional comments (2)
.github/workflows/check.yml (2)

39-39: Pytest verbosity change is fine

This will help triage the currently red CI with clearer, shorter tracebacks. No concerns.


21-21: Matrix includes Python 3.13; manual verification of actions/Poetry support required

The GitHub CLI dispatch script failed with “HTTP 403: Resource not accessible by integration,” so we can’t automatically confirm that setup-python@v2/abatilo/actions-poetry@v2.0.0 with Poetry 1.3.2 will pull in proper Python 3.13 runners on ubuntu-22.04. To avoid CI breakage unrelated to this PR’s intent, please:

• Bump your actions versions and Poetry, then either
– Manually trigger a workflow run for Python 3.13 (via the GitHub UI) and inspect the logs, or
– Temporarily exclude 3.13 from the matrix until support is confirmed.

• Suggested safe-modernization tweaks:

-    - uses: actions/checkout@v2
+    - uses: actions/checkout@v4
...
-    - name: Set up Python ${{ matrix.python-version }}
-      uses: actions/setup-python@v2
+    - name: Set up Python ${{ matrix.python-version }}
+      uses: actions/setup-python@v5
...
-    - name: Setup poetry
-      uses: abatilo/actions-poetry@v2.0.0
+    - name: Setup poetry
+      uses: abatilo/actions-poetry@v2
       with:
-        poetry-version: 1.3.2
+        poetry-version: 1.7.1

• To gate 3.13 until you’ve confirmed support, adjust:

-        python-version: ['3.7','3.8','3.9','3.10','3.11','3.12','3.13']
+        python-version: ['3.7','3.8','3.9','3.10','3.11','3.12']

Please verify the upgraded actions support 3.13 in your environment before re-adding it to the matrix.

Comment on lines 412 to 416
if encoding == 'json':
assert json.loads(item['data']) == json.loads(data)
assert json.loads(item['data']) == json.loads(msg_data)
else:
assert item['data'] == data
assert item['data'] == msg_data

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Handle composite encodings (e.g., 'json/utf-8') instead of only 'json'

The test assumes an exact 'json' encoding. Wire encodings can be composite (e.g., 'json/utf-8'). Use a prefix or token check to avoid false negatives.

-                if encoding == 'json':
-                    assert json.loads(item['data']) == json.loads(msg_data)
+                if encoding.startswith('json'):
+                    assert json.loads(item['data']) == json.loads(msg_data)
                 else:
                     assert item['data'] == msg_data
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if encoding == 'json':
assert json.loads(item['data']) == json.loads(data)
assert json.loads(item['data']) == json.loads(msg_data)
else:
assert item['data'] == data
assert item['data'] == msg_data
if encoding.startswith('json'):
assert json.loads(item['data']) == json.loads(msg_data)
else:
assert item['data'] == msg_data
🤖 Prompt for AI Agents
In test/ably/rest/restchannelpublish_test.py around lines 412 to 416, the test
currently checks encoding == 'json' which fails for composite encodings like
'json/utf-8'; update the check to inspect the media type prefix (e.g.,
encoding.split('/', 1)[0].lower() == 'json' or
encoding.lower().startswith('json/')) and treat any such composite encoding as
JSON so the assertion uses json.loads for those cases.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (1)
ably/transport/websockettransport.py (1)

96-108: Fix race: assign websocket before emitting 'connected'; and pass AblyException to deactivate_transport

Emitting before assignment can trigger send() on a None websocket if listeners send immediately. Also, deactivate_transport expects an Optional[AblyException]; passing WebSocketException may break downstream expectations. This mirrors a prior review comment—still applies.

Apply:

-        log.info(f'ws_connect(): connection established to {ws_url}')
-        self._emit('connected')
-        self.websocket = websocket
+        log.info(f'ws_connect(): connection established to {ws_url}')
+        self.websocket = websocket
+        self._emit('connected')
@@
-        except WebSocketException as err:
+        except WebSocketException as err:
             if not self.is_disposed:
                 await self.dispose()
-                self.connection_manager.deactivate_transport(err)
+                self.connection_manager.deactivate_transport(
+                    AblyException.from_exception(err)
+                )

Follow-up: Review other call sites of deactivate_transport to ensure they pass AblyException consistently (e.g., disconnect(reason)).

Run this to inspect call sites and arguments:

#!/usr/bin/env bash
rg -nP --type=py -C2 '\bdeactivate_transport\s*\('
🧹 Nitpick comments (3)
test/ably/rest/restchannelpublish_test.py (1)

12-12: Import asyncio: OK, but keep it local to the test if only used there

Import is only needed for the sleeps introduced in test_interoperability. Keeping it at module level is fine; alternatively, import inside the test to emphasize scope.

ably/transport/websockettransport.py (2)

14-20: Prefer canonical import paths to reduce aliasing ambiguity across versions

Top-level aliases exist in newer websockets (ClientConnection since 14.2; connect since 14.0), but relying on them can be brittle. Import from websockets.asyncio.client when available, and fall back to legacy/older paths only if needed. This avoids surprises if aliases change in future majors. According to upstream docs, additional_headers is the asyncio argument in 15+, while extra_headers is used in older APIs. (websockets.readthedocs.io)

Apply:

-try:
-    # websockets 15+ preferred imports
-    from websockets import ClientConnection as WebSocketClientProtocol, connect as ws_connect
-except ImportError:
-    # websockets 14 and earlier fallback
-    from websockets.client import WebSocketClientProtocol, connect as ws_connect
+try:
+    # websockets 14.0+ (new asyncio implementation / canonical path)
+    from websockets.asyncio.client import ClientConnection as WebSocketClientProtocol, connect as ws_connect
+except Exception:
+    # websockets 13.x and earlier (legacy / older paths)
+    from websockets.client import WebSocketClientProtocol, connect as ws_connect

If you want to support 14.x where aliases exist, this remains compatible while preferring the definitive module.


91-95: Preserve exception chaining when re-raising

Raise the wrapped AblyException with cause to improve diagnostics and satisfy Ruff B904.

Apply:

-        except (WebSocketException, socket.gaierror) as e:
+        except (WebSocketException, socket.gaierror) as e:
             exception = AblyException(f'Error opening websocket connection: {e}', 400, 40000)
             log.exception(f'WebSocketTransport.ws_connect(): Error opening websocket connection: {exception}')
             self._emit('failed', exception)
-            raise exception
+            raise exception from e
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between 0318ed4 and 202e0e8.

⛔ Files ignored due to path filters (1)
  • poetry.lock is excluded by !**/*.lock
📒 Files selected for processing (4)
  • .github/workflows/check.yml (1 hunks)
  • ably/transport/websockettransport.py (2 hunks)
  • pyproject.toml (1 hunks)
  • test/ably/rest/restchannelpublish_test.py (3 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • pyproject.toml
🧰 Additional context used
🧬 Code graph analysis (1)
ably/transport/websockettransport.py (4)
ably/util/exceptions.py (1)
  • AblyException (9-84)
ably/util/eventemitter.py (1)
  • _emit (183-185)
ably/types/options.py (1)
  • loop (226-227)
ably/realtime/connectionmanager.py (1)
  • deactivate_transport (244-246)
🪛 Ruff (0.12.2)
ably/transport/websockettransport.py

94-94: Within an except clause, raise exceptions with raise ... from err or raise ... from None to distinguish them from errors in exception handling

(B904)

🔇 Additional comments (5)
.github/workflows/check.yml (1)

39-39: Pytest verbosity tweak looks good; helps triage CI failures faster

Adding --verbose with --tb=short improves signal-to-noise in logs without flooding CI. No functional risk.

test/ably/rest/restchannelpublish_test.py (3)

394-396: Variable rename to msg_data improves clarity and avoids shadowing

Good rename; avoids confusion with the outer data variable from the JSON file and matches its usage in later assertions.


413-416: Assertion changes: confirm binary case expectations from fixture

Switching to json.loads(msg_data) for encoding == 'json' and comparing item['data'] to msg_data otherwise looks correct. Please confirm messages-encoding.json provides msg_data in the server’s wire format for non-JSON types (e.g., base64 for binary), otherwise this may mask encoding mismatches.

Would you like a small helper to normalize msg_data per encoding before assertion?


418-419: Publishing with explicit Message(data=msg_data, encoding=encoding) is clearer

This matches the preceding assertions and keeps the test self-consistent. Good change.

ably/transport/websockettransport.py (1)

82-90: Header compatibility shim is correct for 15+ vs older versions

Trying additional_headers first and falling back to extra_headers is aligned with v15 docs and earlier versions. This guards the handshake across versions. (websockets.readthedocs.io)

Comment on lines +406 to +407
# temporary added delay, we need to investigate why messages don't appear immediately
await asyncio.sleep(1)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Replace fixed sleeps with bounded polling to deflake the test

Hard sleeps introduce flakiness and add unnecessary latency. Prefer polling until the condition holds (with a timeout) to accommodate eventual consistency without slowing the happy path.

Apply this refactor within the loop:

-                # temporary added delay, we need to investigate why messages don't appear immediately
-                await asyncio.sleep(1)
+                # Wait (up to 5s) for message to appear to reduce flakiness
+                for _ in range(50):
+                    async with httpx.AsyncClient(http2=True) as client:
+                        r = await client.get(url, auth=auth)
+                    if r.status_code == 200 and r.json():
+                        break
+                    await asyncio.sleep(0.1)

And after the second publish:

-                # temporary added delay, we need to investigate why messages don't appear immediately
-                await asyncio.sleep(1)
+                # Wait (up to 5s) for message to appear in history
+                for _ in range(50):
+                    history = await channel.history()
+                    if history.items:
+                        break
+                    await asyncio.sleep(0.1)

If you prefer not to duplicate client creation, hoist a single AsyncClient outside the loop and reuse it. I can propose that variant if you want.

Also applies to: 419-420

🤖 Prompt for AI Agents
In test/ably/rest/restchannelpublish_test.py around lines 406-407 (and similarly
at 419-420), replace the hard-coded asyncio.sleep(1) calls with a bounded
polling loop that repeatedly checks the expected condition until it becomes true
or a timeout is reached; implement a small interval (e.g. 50-200ms) between
checks, raise/assert a failure if the timeout elapses, and reuse a single
AsyncClient created outside the loop (or pass the existing client) to avoid
duplicating client creation — this removes flakiness and unnecessary delay while
preserving a maximum wait time.

Copy link
Contributor

@VeskeR VeskeR left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM,
Let’s create a separate issue for fixing the rest request problem if not already

@github-actions github-actions bot temporarily deployed to staging/pull/612/features August 22, 2025 17:24 Inactive
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

♻️ Duplicate comments (2)
test/ably/rest/restchannelpublish_test.py (2)

412-415: Handle composite JSON encodings (e.g., 'json/utf-8') instead of only equality with 'json'

The check if encoding == 'json' misses cases like json/utf-8. Treat any encoding whose primary token is json as JSON.

Apply:

-                if encoding == 'json':
+                if encoding and encoding.lower().split('/', 1)[0] == 'json':
                     assert json.loads(item['data']) == json.loads(msg_data)
                 else:
                     assert item['data'] == msg_data

406-407: Deflake: replace fixed sleeps with bounded polling

Hard sleeps cause flakiness and slow tests. Poll until the message appears, with a short timeout. This also keeps the happy path fast.

A targeted refactor for the two delays:

-                # temporary added delay, we need to investigate why messages don't appear immediately
-                await sleep(1)
+                # Wait (up to 5s) for message to appear, polling every 100ms
+                for _ in range(50):
+                    async with httpx.AsyncClient(http2=True) as client:
+                        r = await client.get(url, auth=auth)
+                    if r.status_code == 200 and r.json():
+                        break
+                    await sleep(0.1)
-                # temporary added delay, we need to investigate why messages don't appear immediately
-                await sleep(1)
+                # Wait (up to 5s) for message to appear in history
+                for _ in range(50):
+                    history = await channel.history()
+                    if history.items:
+                        break
+                    await sleep(0.1)

If you prefer, I can propose a small helper to avoid duplicating the polling loop.

Also applies to: 419-420

🧹 Nitpick comments (2)
test/ably/rest/restchannelpublish_test.py (1)

424-425: Use isinstance for type checks

Slightly more idiomatic and resilient to subclasses.

-                assert type(message.data) == type_mapping[expected_type]
+                assert isinstance(message.data, type_mapping[expected_type])
ably/scripts/unasync.py (1)

186-223: Optional: support aliasing in the special-case (safe when only 'sleep' is imported)

Currently, from asyncio import sleep as s won’t be rewritten even though it’s safe. Consider accepting as after sleep while still rejecting any additional import items.

Here’s a focused tweak:

-                # Check if next is exactly "sleep"
-                if (check_counter < len(tokens) and tokens[check_counter].src == "sleep"):
+                # Check if next is exactly "sleep"
+                if check_counter < len(tokens) and tokens[check_counter].src == "sleep":
                     # Check what comes after "sleep" to determine if this is a standalone sleep import
                     next_check = check_counter + 1
                     is_sleep_only = True
@@
-                    if next_check < len(tokens):
+                    if next_check < len(tokens):
                         next_token = tokens[next_check]
-                        # If there's a comma, "as", or other import items, it's not a standalone sleep import
-                        if next_token.src in [",", "as"] or next_token.name == "NAME":
-                            is_sleep_only = False
+                        # Allow aliasing with "as", but still reject additional import items (comma or another NAME after alias)
+                        if next_token.src == ",":
+                            is_sleep_only = False
+                        elif next_token.src == "as":
+                            # Allow alias: ensure no additional imports after alias target
+                            alias_check = next_check + 1
+                            while alias_check < len(tokens) and tokens[alias_check].src == " ":
+                                alias_check += 1
+                            # If there's a comma or another NAME beyond alias, reject
+                            if alias_check < len(tokens) and tokens[alias_check].src in [","] :
+                                is_sleep_only = False
+                        elif next_token.name == "NAME":
+                            # Another NAME immediately after sleep implies more imports
+                            is_sleep_only = False

If you want, I can add a small unit test to lock this behavior in.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between 202e0e8 and b4a3a9d.

📒 Files selected for processing (2)
  • ably/scripts/unasync.py (2 hunks)
  • test/ably/rest/restchannelpublish_test.py (3 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
test/ably/rest/restchannelpublish_test.py (2)
ably/rest/channel.py (2)
  • get (182-193)
  • publish (106-132)
ably/types/message.py (1)
  • Message (24-226)
🪛 GitHub Actions: Linting check
ably/scripts/unasync.py

[error] 185-185: flake8 (poetry run flake8): W293 blank line contains whitespace.


[error] 188-188: flake8 (poetry run flake8): W291 trailing whitespace.


[error] 189-189: flake8 (poetry run flake8): E129 visually indented line with same indent as next logical line.


[error] 190-190: flake8 (poetry run flake8): W293 blank line contains whitespace.


[error] 193-193: flake8 (poetry run flake8): W291 trailing whitespace.


[error] 207-207: flake8 (poetry run flake8): W293 blank line contains whitespace.


[error] 211-211: flake8 (poetry run flake8): W293 blank line contains whitespace.


[error] 217-217: flake8 (poetry run flake8): W293 blank line contains whitespace.


[error] 222-222: flake8 (poetry run flake8): W293 blank line contains whitespace.


[error] 227-227: flake8 (poetry run flake8): W293 blank line contains whitespace.


[error] 246-246: flake8 (poetry run flake8): W293 blank line contains whitespace.

test/ably/rest/restchannelpublish_test.py

[error] 420-420: flake8 (poetry run flake8): F821 undefined name 'asyncio'.

🪛 Ruff (0.12.2)
test/ably/rest/restchannelpublish_test.py

420-420: Undefined name asyncio

(F821)

⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (7)
  • GitHub Check: check (3.11)
  • GitHub Check: check (3.13)
  • GitHub Check: check (3.12)
  • GitHub Check: check (3.7)
  • GitHub Check: check (3.9)
  • GitHub Check: check (3.10)
  • GitHub Check: check (3.8)
🔇 Additional comments (3)
test/ably/rest/restchannelpublish_test.py (2)

394-396: LGTM: local alias clarifies intent

Using msg_data = input_msg['data'] improves readability below, especially where both JSON and non-JSON flows are asserted.


419-420: Use the imported sleep instead of the undefined asyncio module in restchannelpublish_test.py

The test file imports sleep (from asyncio) but does not import the asyncio module, so await asyncio.sleep(1) is undefined and breaks both CI (F821) and the unasync rewrite.

• File needing change: test/ably/rest/restchannelpublish_test.py at line 420

Suggested minimal diff:

-                # temporary added delay, we need to investigate why messages don't appear immediately
-                await asyncio.sleep(1)
+                # temporary added delay, we need to investigate why messages don't appear immediately
+                await sleep(1)
ably/scripts/unasync.py (1)

185-246: Fix flake8 W291/W293/E129 in ably/scripts/unasync.py

The following changes remove trailing/blank-line whitespace and simplify the visually indented if to a single line, preserving the special-case logic for “from asyncio import sleep” → “from time import sleep”:

--- a/ably/scripts/unasync.py
+++ b/ably/scripts/unasync.py
@@ def some_method(...):
-        full_lib_name = ''
-        lib_name_counter = token_counter + 2
-        
+        full_lib_name = ''
+        lib_name_counter = token_counter + 2

-        # Handle special case for "from asyncio import sleep" -> "from time import sleep"
-        # First check if this is an asyncio import with just sleep
-        if (lib_name_counter < len(tokens) and 
-            tokens[lib_name_counter].src == "asyncio"):
-            
+        # Handle special case for "from asyncio import sleep" -> "from time import sleep"
+        # First check if this is an asyncio import with just sleep
+        if lib_name_counter < len(tokens) and tokens[lib_name_counter].src == "asyncio":

-            # Skip whitespace  
+            # Skip whitespace
             while check_counter < len(tokens) and tokens[check_counter].src == " ":
                 check_counter += 1

@@
-                # Skip whitespace after "import"
+                # Skip whitespace after "import"
                 while check_counter < len(tokens) and tokens[check_counter].src == " ":
                     check_counter += 1
@@
-                    # Check what comes after "sleep" to determine if this is a standalone sleep import
+                    # Check what comes after "sleep" to determine if this is a standalone sleep import
                     next_check = check_counter + 1
                     is_sleep_only = True

@@
-        
+        
@@
-        while lib_name_counter < len(tokens) and tokens[lib_name_counter].src != " ":
+        while lib_name_counter < len(tokens) and tokens[lib_name_counter].src != " ":
             full_lib_name += tokens[lib_name_counter].src
             lib_name_counter += 1
@@
-        # For all other cases, add the original module name tokens
-        for lib_name_part in full_lib_name.split("."):
-            lib_name_part = self._class_rename(lib_name_part)
-            new_tokens.append(tokenize_rt.Token("NAME", lib_name_part))
-            new_tokens.append(tokenize_rt.Token("OP", "."))
-        if full_lib_name:  # Only remove the last dot if we added tokens
-            new_tokens.pop()
-        
+        # For all other cases, add the original module name tokens
+        for lib_name_part in full_lib_name.split("."):
+            lib_name_part = self._class_rename(lib_name_part)
+            new_tokens.append(tokenize_rt.Token("NAME", lib_name_part))
+            new_tokens.append(tokenize_rt.Token("OP", "."))
+        if full_lib_name:  # Only remove the last dot if we added tokens
+            new_tokens.pop()
+
         return lib_name_counter

• Removed trailing spaces from blank lines at 185, 190, 207, 211, 217, 222, 227, and 246.
• Consolidated the two-line if (and): into one line to eliminate E129.
• Normalized comment whitespace to eliminate W291/W293.

With these adjustments, all flake8 violations (W291/W293/E129) should be resolved without altering runtime behavior.

@ttypic ttypic force-pushed the ECO-5526/upgrade-websockets branch from b4a3a9d to 202e0e8 Compare August 22, 2025 18:15
@ttypic ttypic merged commit b165eb2 into main Aug 22, 2025
5 of 19 checks passed
@ttypic ttypic deleted the ECO-5526/upgrade-websockets branch August 22, 2025 18:16
@ttypic ttypic mentioned this pull request Aug 22, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Development

Successfully merging this pull request may close these issues.

Bump websockets Version >=15.0.1 needed
3 participants