From a3a67d725665b0f627754d94632516a0dbfb7972 Mon Sep 17 00:00:00 2001 From: maczg Date: Thu, 26 Feb 2026 22:06:03 +0100 Subject: [PATCH 1/5] fix(ws): resolve auth deadlock and binary frame handling in WebSocket client - Start receive loop before _authenticate() so auth responses are processed instead of deadlocking - Skip binary WebSocket frames in _receive_loop and pass text messages directly without str() wrapping - Add 15 unit tests covering event emitter, auth flow, binary frame skipping, disconnect, and error paths --- etoropy/ws/client.py | 6 +- notebooks/gitkeep.ipynb | 15 ++ tests/unit/test_ws_client.py | 286 +++++++++++++++++++++++++++++++++++ 3 files changed, 305 insertions(+), 2 deletions(-) create mode 100644 notebooks/gitkeep.ipynb create mode 100644 tests/unit/test_ws_client.py diff --git a/etoropy/ws/client.py b/etoropy/ws/client.py index 471d132..79b99aa 100644 --- a/etoropy/ws/client.py +++ b/etoropy/ws/client.py @@ -159,8 +159,8 @@ async def connect(self) -> None: logger.info("WebSocket connected") self._emit("open") - await self._authenticate() self._receive_task = asyncio.create_task(self._receive_loop()) + await self._authenticate() async def _authenticate(self) -> None: auth_msg = { @@ -241,7 +241,9 @@ async def _receive_loop(self) -> None: assert self._ws is not None try: async for raw in self._ws: - self._handle_message(str(raw)) + if isinstance(raw, bytes): + continue + self._handle_message(raw) except websockets.ConnectionClosed as exc: logger.info("WebSocket closed: %d %s", exc.code, exc.reason) self._authenticated = False diff --git a/notebooks/gitkeep.ipynb b/notebooks/gitkeep.ipynb new file mode 100644 index 0000000..cfcbd90 --- /dev/null +++ b/notebooks/gitkeep.ipynb @@ -0,0 +1,15 @@ +{ + "cells": [ + { + "metadata": {}, + "cell_type": "code", + "outputs": [], + "execution_count": null, + "source": "", + "id": "7523e10f1a3cd53a" + } + ], + "metadata": {}, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/tests/unit/test_ws_client.py b/tests/unit/test_ws_client.py new file mode 100644 index 0000000..1750be8 --- /dev/null +++ b/tests/unit/test_ws_client.py @@ -0,0 +1,286 @@ +from __future__ import annotations + +import asyncio +import json +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from etoropy.errors.exceptions import EToroAuthError, EToroWebSocketError +from etoropy.ws.client import WsClient, WsClientOptions + + +def _make_client(**overrides: object) -> WsClient: + opts = WsClientOptions( + api_key="test-api-key", + user_key="test-user-key", + auth_timeout=0.5, + heartbeat_interval=0, + heartbeat_timeout=0, + reconnect_attempts=0, + reconnect_delay=0.01, + **overrides, # type: ignore[arg-type] + ) + return WsClient(opts) + + +# ── event emitter ──────────────────────────────────────────────────── + + +def test_on_and_emit() -> None: + ws = _make_client() + received: list[int] = [] + ws.on("ping", lambda v: received.append(v)) + ws._emit("ping", 42) + assert received == [42] + + +def test_off_removes_handler() -> None: + ws = _make_client() + received: list[int] = [] + handler = lambda v: received.append(v) + ws.on("ping", handler) + ws.off("ping", handler) + ws._emit("ping", 1) + assert received == [] + + +def test_once_fires_only_once() -> None: + ws = _make_client() + received: list[int] = [] + ws.once("ping", lambda v: received.append(v)) + ws._emit("ping", 1) + ws._emit("ping", 2) + assert received == [1] + + +def test_remove_all_listeners() -> None: + ws = _make_client() + received: list[int] = [] + ws.on("a", lambda: received.append(1)) + ws.on("b", lambda: received.append(2)) + ws.remove_all_listeners() + ws._emit("a") + ws._emit("b") + assert received == [] + + +def test_remove_listeners_for_event() -> None: + ws = _make_client() + received: list[str] = [] + ws.on("a", lambda: received.append("a")) + ws.on("b", lambda: received.append("b")) + ws.remove_all_listeners("a") + ws._emit("a") + ws._emit("b") + assert received == ["b"] + + +# ── _handle_message ────────────────────────────────────────────────── + + +def test_handle_auth_success() -> None: + ws = _make_client() + authenticated: list[bool] = [] + ws.on("authenticated", lambda: authenticated.append(True)) + + ws._handle_message(json.dumps({"operation": "Authenticate"})) + + assert ws.is_authenticated is True + assert authenticated == [True] + + +def test_handle_auth_error() -> None: + ws = _make_client() + errors: list[Exception] = [] + ws.on("error", lambda err: errors.append(err)) + + ws._handle_message(json.dumps({"operation": "Authenticate", "errorCode": "INVALID_KEY"})) + + assert ws.is_authenticated is False + assert len(errors) == 1 + assert isinstance(errors[0], EToroAuthError) + + +def test_handle_malformed_message_does_not_raise() -> None: + ws = _make_client() + ws._handle_message("not valid json {{{") + + +# ── connect / authenticate ─────────────────────────────────────────── + + +@pytest.mark.asyncio +async def test_connect_starts_receive_loop_before_auth() -> None: + """The receive loop must be running before _authenticate waits, + otherwise the auth response is never processed (deadlock).""" + ws = _make_client() + call_order: list[str] = [] + + original_authenticate = ws._authenticate + + async def fake_ws_connect(*_a: object, **_kw: object) -> AsyncMock: + mock_conn = AsyncMock() + mock_conn.state = MagicMock() + mock_conn.state.name = "OPEN" + return mock_conn + + async def tracking_authenticate() -> None: + # By the time authenticate is called, receive_task must already exist + assert ws._receive_task is not None, "receive loop must start before authenticate" + call_order.append("authenticate") + # Simulate successful auth + ws._authenticated = True + ws._emit("authenticated") + + with patch("websockets.asyncio.client.connect", side_effect=fake_ws_connect): + ws._authenticate = tracking_authenticate # type: ignore[assignment] + # _receive_loop will fail because mock ws doesn't iterate, + # but that's fine — we just need to verify ordering + ws._receive_loop = AsyncMock() # type: ignore[assignment] + await ws.connect() + + assert call_order == ["authenticate"] + + +@pytest.mark.asyncio +async def test_connect_authenticates_via_receive_loop() -> None: + """Full integration: connect sends auth, receive loop processes the + response, and connect() resolves.""" + ws = _make_client() + + auth_response = json.dumps({"operation": "Authenticate"}) + + async def fake_ws_iter(self: object) -> object: # noqa: ANN001 + yield auth_response + + mock_conn = AsyncMock() + mock_conn.state = MagicMock() + mock_conn.state.name = "OPEN" + mock_conn.send = AsyncMock() + mock_conn.__aiter__ = fake_ws_iter + mock_conn.close = AsyncMock() + + async def fake_connect(*_a: object, **_kw: object) -> AsyncMock: + return mock_conn + + with patch("websockets.asyncio.client.connect", side_effect=fake_connect): + await ws.connect() + + assert ws.is_authenticated is True + + +@pytest.mark.asyncio +async def test_auth_timeout_raises() -> None: + opts = WsClientOptions( + api_key="test-api-key", + user_key="test-user-key", + auth_timeout=0.1, + heartbeat_interval=0, + heartbeat_timeout=0, + reconnect_attempts=0, + ) + ws = WsClient(opts) + + async def fake_ws_iter(self: object) -> object: # noqa: ANN001 + # Never yield an auth response — simulate timeout + await asyncio.sleep(10) + return + yield # noqa: RET504 # make it an async generator + + mock_conn = AsyncMock() + mock_conn.state = MagicMock() + mock_conn.state.name = "OPEN" + mock_conn.send = AsyncMock() + mock_conn.__aiter__ = fake_ws_iter + mock_conn.close = AsyncMock() + + async def fake_connect(*_a: object, **_kw: object) -> AsyncMock: + return mock_conn + + with patch("websockets.asyncio.client.connect", side_effect=fake_connect): + with pytest.raises(EToroAuthError, match="timed out"): + await ws.connect() + + +# ── _receive_loop: binary message handling ─────────────────────────── + + +@pytest.mark.asyncio +async def test_receive_loop_skips_binary_messages() -> None: + ws = _make_client() + received_messages: list[str] = [] + original_handle = ws._handle_message + + def tracking_handle(data: str) -> None: + received_messages.append(data) + original_handle(data) + + ws._handle_message = tracking_handle # type: ignore[assignment] + + text_msg = json.dumps({"operation": "Authenticate"}) + + async def fake_ws_iter(self: object) -> object: # noqa: ANN001 + yield b"\x00" + yield text_msg + yield b"\x01\x02\x03" + + mock_conn = AsyncMock() + mock_conn.__aiter__ = fake_ws_iter + ws._ws = mock_conn + + await ws._receive_loop() + + assert received_messages == [text_msg] + + +@pytest.mark.asyncio +async def test_receive_loop_processes_text_messages() -> None: + ws = _make_client() + auth_events: list[bool] = [] + ws.on("authenticated", lambda: auth_events.append(True)) + + text_msg = json.dumps({"operation": "Authenticate"}) + + async def fake_ws_iter(self: object) -> object: # noqa: ANN001 + yield text_msg + + mock_conn = AsyncMock() + mock_conn.__aiter__ = fake_ws_iter + ws._ws = mock_conn + + await ws._receive_loop() + + assert auth_events == [True] + assert ws.is_authenticated is True + + +# ── disconnect ─────────────────────────────────────────────────────── + + +@pytest.mark.asyncio +async def test_disconnect_cleans_up() -> None: + ws = _make_client() + + mock_conn = AsyncMock() + mock_conn.close = AsyncMock() + ws._ws = mock_conn + ws._authenticated = True + ws._receive_task = asyncio.create_task(asyncio.sleep(10)) + + await ws.disconnect() + + assert ws._ws is None + assert ws.is_authenticated is False + assert ws._receive_task is None + mock_conn.close.assert_awaited_once() + + +# ── _send ──────────────────────────────────────────────────────────── + + +@pytest.mark.asyncio +async def test_send_raises_when_not_connected() -> None: + ws = _make_client() + with pytest.raises(EToroWebSocketError, match="not connected"): + await ws._send({"test": True}) \ No newline at end of file From 9a08b688a898ecad13b911776da3584b7070dcea Mon Sep 17 00:00:00 2001 From: maczg Date: Thu, 26 Feb 2026 22:06:23 +0100 Subject: [PATCH 2/5] chore: migrate command templates to Python and fix command substitution bug - Replace npm/package.json references with pytest/pyproject.toml across all Git Flow command files (hotfix, release, finish, flow-status) - Fix $() command substitution in !-backtick lines that broke command execution - Update .gitignore to exclude underscore-prefixed notebooks - Update IDE configuration --- .claude/commands/finish.md | 20 ++++++++--------- .claude/commands/flow-status.md | 8 +++---- .claude/commands/hotfix.md | 39 ++++++++++++--------------------- .claude/commands/release.md | 24 ++++++++++---------- .gitignore | 2 ++ .idea/misc.xml | 3 +++ etoropy.iml | 1 + 7 files changed, 46 insertions(+), 51 deletions(-) diff --git a/.claude/commands/finish.md b/.claude/commands/finish.md index 6158631..d99c299 100644 --- a/.claude/commands/finish.md +++ b/.claude/commands/finish.md @@ -15,7 +15,7 @@ Complete current Git Flow branch: **$ARGUMENTS** - Git status: !`git status --porcelain` - Unpushed commits: !`git log @{u}.. --oneline 2>/dev/null | wc -l | tr -d ' '` - Latest tag: !`git describe --tags --abbrev=0 2>/dev/null || echo "No tags"` -- Test status: !`npm test 2>/dev/null | tail -20 || echo "No test command available"` +- Test status: !`python -m pytest --co -q 2>/dev/null | tail -5 || echo "No test command available"` ## Task @@ -321,8 +321,8 @@ To finish this branch manually: ❌ Cannot finish: Uncommitted changes detected Modified files: -M src/file1.js -M src/file2.js +M etoropy/client.py +M etoropy/models.py Please commit or stash your changes first: 1. Commit: git add . && git commit @@ -349,13 +349,13 @@ Would you like to push now? [Y/n] ❌ Cannot finish: Tests are failing Failed tests: - ✗ UserService.test.js - - should authenticate user (expected 200, got 401) - ✗ PaymentController.test.js - - should process payment (timeout) + ✗ tests/test_client.py::test_authenticate + - AssertionError: expected 200, got 401 + ✗ tests/test_api.py::test_request_timeout + - TimeoutError Fix the failing tests before finishing: -1. Run tests: npm test +1. Run tests: pytest 2. Fix failures 3. Commit fixes 4. Try /finish again @@ -368,8 +368,8 @@ Skip tests? (NOT RECOMMENDED) [y/N] ❌ Merge conflict detected with develop Conflicting files: - src/config.js - package.json + etoropy/config.py + pyproject.toml Resolution steps: 1. Fetch latest develop: git fetch origin develop diff --git a/.claude/commands/flow-status.md b/.claude/commands/flow-status.md index 0a114b1..ad5f5ff 100644 --- a/.claude/commands/flow-status.md +++ b/.claude/commands/flow-status.md @@ -140,7 +140,7 @@ Status: Next steps: 1. Commit your changes - 2. Run tests: npm test + 2. Run tests: pytest 3. Push to remote: git push 4. When ready: /finish ``` @@ -168,12 +168,12 @@ Version analysis: Checklist: ✓ CHANGELOG.md updated - ✓ Version in package.json + ✓ Version in pyproject.toml ⚠️ Tests not run ✗ No tag created yet Next steps: - 1. Run final tests: npm test + 1. Run final tests: pytest 2. Review CHANGELOG.md 3. Create PR: gh pr create 4. Get approvals @@ -323,7 +323,7 @@ Next Steps: 1. Commit changes: git add . && git commit 2. Pull updates: git pull 3. Push commits: git push - 4. Run tests: npm test + 4. Run tests: pytest 5. Finish when ready: /finish ``` diff --git a/.claude/commands/hotfix.md b/.claude/commands/hotfix.md index fbf1181..59434ef 100644 --- a/.claude/commands/hotfix.md +++ b/.claude/commands/hotfix.md @@ -14,7 +14,7 @@ Create emergency hotfix branch: **$ARGUMENTS** - Git status: !`git status --porcelain` - Latest production tag: !`git describe --tags --abbrev=0 origin/main 2>/dev/null || echo "No tags on main"` - Main branch status: !`git log main..origin/main --oneline 2>/dev/null | head -3 || echo "No remote tracking for main"` -- Commits on main since last tag: !`git log $(git describe --tags --abbrev=0 origin/main 2>/dev/null)..origin/main --oneline 2>/dev/null | wc -l | tr -d ' '` +- Recent commits on main: !`git log origin/main --oneline -5 2>/dev/null || echo "No commits found"` ## Task @@ -118,7 +118,7 @@ This is an EMERGENCY production fix. Follow these steps: - Verify no side effects 4. 📝 Document the Fix - - Update version in package.json + - Update version in pyproject.toml - Add entry to CHANGELOG.md - Document the bug and fix - Include reproduction steps @@ -132,7 +132,7 @@ This is an EMERGENCY production fix. Follow these steps: 🎯 Next Steps: 1. Fix the critical issue (MINIMAL changes only) -2. Test thoroughly: npm test +2. Test thoroughly: pytest 3. Update version: v1.2.1 4. Create emergency PR: gh pr create --label "hotfix,critical" 5. Get fast-track approval @@ -187,8 +187,8 @@ Examples: **Uncommitted Changes:** ``` ⚠️ Uncommitted changes detected in working directory: -M src/file.js -A test.js +M etoropy/client.py +A tests/test_fix.py Hotfixes require a clean working directory. @@ -279,28 +279,17 @@ Post-Deployment: ### 7. Version Update Process -After implementing the fix, update the version: +After implementing the fix, update the version in `pyproject.toml`: -```bash -# Update package.json version (PATCH bump) -npm version patch --no-git-tag-version - -# Update CHANGELOG.md -cat >> CHANGELOG.md << EOF - -## [v1.2.1] - $(date +%Y-%m-%d) - HOTFIX - -### 🔥 Critical Fixes -- Fix $ARGUMENTS: [brief description] - - Root cause: [explanation] - - Impact: [who/what was affected] - - Resolution: [what was fixed] +- Read the current `version` field in `pyproject.toml` +- Increment the PATCH segment (e.g. `0.1.1` → `0.1.2`) +- Use the Edit tool to update the `version` line in `pyproject.toml` +- Update `CHANGELOG.md` with the hotfix entry +- Commit the version bump: -EOF - -# Commit version bump -git add package.json CHANGELOG.md -git commit -m "chore(hotfix): bump version to v1.2.1 +```bash +git add pyproject.toml CHANGELOG.md +git commit -m "chore(hotfix): bump version to vX.Y.Z Critical fix for $ARGUMENTS diff --git a/.claude/commands/release.md b/.claude/commands/release.md index 52a02f4..ab86e00 100644 --- a/.claude/commands/release.md +++ b/.claude/commands/release.md @@ -13,8 +13,8 @@ Create new release branch: **$ARGUMENTS** - Current branch: !`git branch --show-current` - Git status: !`git status --porcelain` - Latest tag: !`git describe --tags --abbrev=0 2>/dev/null || echo "No tags found"` -- Commits since last tag: !`git log $(git describe --tags --abbrev=0 2>/dev/null)..HEAD --oneline 2>/dev/null | wc -l | tr -d ' '` -- Package.json version: !`cat package.json 2>/dev/null | grep '"version"' | head -1 || echo "No package.json found"` +- Recent commits: !`git log --oneline -10 2>/dev/null || echo "No commits found"` +- pyproject.toml version: !`grep '^version' pyproject.toml 2>/dev/null || echo "No pyproject.toml found"` - Recent commits: !`git log --oneline -10` ## Task @@ -70,17 +70,17 @@ git pull origin develop # Create release branch git checkout -b release/$ARGUMENTS -# Update package.json version (if Node.js project) -npm version ${ARGUMENTS#v} --no-git-tag-version +# Update version in pyproject.toml +# Use Edit tool to update the `version` field in pyproject.toml # Generate CHANGELOG.md from commits # (analyze git log since last tag) # Commit version bump -git add package.json CHANGELOG.md +git add pyproject.toml CHANGELOG.md git commit -m "chore(release): bump version to ${ARGUMENTS#v} -- Updated package.json version +- Updated pyproject.toml version - Generated CHANGELOG.md from commits 🤖 Generated with Claude Code @@ -135,18 +135,18 @@ Display this checklist after creation: 🚀 Release Checklist for $ARGUMENTS Pre-Release Tasks: -- [ ] All tests passing (run: npm test) +- [ ] All tests passing (run: pytest) - [ ] Documentation updated - [ ] CHANGELOG.md reviewed and accurate - [ ] Version numbers consistent across files - [ ] No breaking changes (or properly documented) -- [ ] Dependencies updated (run: npm audit) +- [ ] Dependencies reviewed Testing Tasks: - [ ] Manual testing completed - [ ] Regression tests passed - [ ] Performance benchmarks acceptable -- [ ] Security scan clean (run: npm audit) +- [ ] Security scan clean - [ ] Cross-browser testing (if applicable) Deployment Preparation: @@ -163,7 +163,7 @@ Final Steps: 🎯 Next Commands: - Review CHANGELOG: cat CHANGELOG.md -- Run tests: npm test +- Run tests: pytest - Create PR: gh pr create --base main --head release/$ARGUMENTS - When ready: /finish ``` @@ -174,7 +174,7 @@ Final Steps: ✓ Switched to develop branch ✓ Pulled latest changes from origin/develop ✓ Created branch: release/$ARGUMENTS -✓ Updated package.json version to ${ARGUMENTS#v} +✓ Updated pyproject.toml version to ${ARGUMENTS#v} ✓ Generated CHANGELOG.md (15 commits analyzed) ✓ Committed version bump changes ✓ Set up remote tracking: origin/release/$ARGUMENTS @@ -201,7 +201,7 @@ Target: main (after review) 🎯 Next Steps: 1. Review CHANGELOG.md for accuracy -2. Run final tests: npm test +2. Run final tests: pytest 3. Test on staging environment 4. Create PR to main: gh pr create 5. Get team approvals diff --git a/.gitignore b/.gitignore index d560b7e..c755e3e 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,8 @@ # Project-specific trash/ +# Ignore notebook that start with an underscore, which are used for testing and development purposes +notebooks/_*.ipynb # JetBrains IDE .idea/** diff --git a/.idea/misc.xml b/.idea/misc.xml index ea5c012..234017d 100644 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -1,5 +1,8 @@ + + diff --git a/etoropy.iml b/etoropy.iml index b8da07f..2cf2eb1 100644 --- a/etoropy.iml +++ b/etoropy.iml @@ -5,6 +5,7 @@ + \ No newline at end of file From 03c4002da05489d0e6491daa7c50763a89abc3b0 Mon Sep 17 00:00:00 2001 From: maczg Date: Thu, 26 Feb 2026 22:13:14 +0100 Subject: [PATCH 3/5] fix: resolve ruff lint errors in ws client tests - E731: replace lambda assignment with def - F841: remove unused variable original_authenticate - SIM117: combine nested with statements --- tests/unit/test_ws_client.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/tests/unit/test_ws_client.py b/tests/unit/test_ws_client.py index 1750be8..ca79c94 100644 --- a/tests/unit/test_ws_client.py +++ b/tests/unit/test_ws_client.py @@ -38,7 +38,9 @@ def test_on_and_emit() -> None: def test_off_removes_handler() -> None: ws = _make_client() received: list[int] = [] - handler = lambda v: received.append(v) + def handler(v: int) -> None: + received.append(v) + ws.on("ping", handler) ws.off("ping", handler) ws._emit("ping", 1) @@ -117,8 +119,6 @@ async def test_connect_starts_receive_loop_before_auth() -> None: ws = _make_client() call_order: list[str] = [] - original_authenticate = ws._authenticate - async def fake_ws_connect(*_a: object, **_kw: object) -> AsyncMock: mock_conn = AsyncMock() mock_conn.state = MagicMock() @@ -198,9 +198,8 @@ async def fake_ws_iter(self: object) -> object: # noqa: ANN001 async def fake_connect(*_a: object, **_kw: object) -> AsyncMock: return mock_conn - with patch("websockets.asyncio.client.connect", side_effect=fake_connect): - with pytest.raises(EToroAuthError, match="timed out"): - await ws.connect() + with patch("websockets.asyncio.client.connect", side_effect=fake_connect), pytest.raises(EToroAuthError, match="timed out"): + await ws.connect() # ── _receive_loop: binary message handling ─────────────────────────── From 99c3f0cf0ed246bb7ade9446eabdcb4ba679baf8 Mon Sep 17 00:00:00 2001 From: maczg Date: Thu, 26 Feb 2026 22:15:42 +0100 Subject: [PATCH 4/5] fix: wrap long line to satisfy E501 (120 char limit) --- tests/unit/test_ws_client.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/unit/test_ws_client.py b/tests/unit/test_ws_client.py index ca79c94..afe8a2b 100644 --- a/tests/unit/test_ws_client.py +++ b/tests/unit/test_ws_client.py @@ -198,7 +198,10 @@ async def fake_ws_iter(self: object) -> object: # noqa: ANN001 async def fake_connect(*_a: object, **_kw: object) -> AsyncMock: return mock_conn - with patch("websockets.asyncio.client.connect", side_effect=fake_connect), pytest.raises(EToroAuthError, match="timed out"): + with ( + patch("websockets.asyncio.client.connect", side_effect=fake_connect), + pytest.raises(EToroAuthError, match="timed out"), + ): await ws.connect() From 72b1b4fecc3e1f73f06dad49b1d223d0639cd684 Mon Sep 17 00:00:00 2001 From: maczg Date: Thu, 26 Feb 2026 22:18:25 +0100 Subject: [PATCH 5/5] style: apply ruff format to ws client tests --- tests/unit/test_ws_client.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/unit/test_ws_client.py b/tests/unit/test_ws_client.py index afe8a2b..51140c6 100644 --- a/tests/unit/test_ws_client.py +++ b/tests/unit/test_ws_client.py @@ -38,6 +38,7 @@ def test_on_and_emit() -> None: def test_off_removes_handler() -> None: ws = _make_client() received: list[int] = [] + def handler(v: int) -> None: received.append(v) @@ -285,4 +286,4 @@ async def test_disconnect_cleans_up() -> None: async def test_send_raises_when_not_connected() -> None: ws = _make_client() with pytest.raises(EToroWebSocketError, match="not connected"): - await ws._send({"test": True}) \ No newline at end of file + await ws._send({"test": True})