ctrader-api-client 0.5.0__tar.gz → 0.5.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of ctrader-api-client might be problematic. Click here for more details.

Files changed (93) hide show
  1. {ctrader_api_client-0.5.0 → ctrader_api_client-0.5.1}/PKG-INFO +1 -1
  2. {ctrader_api_client-0.5.0 → ctrader_api_client-0.5.1}/pyproject.toml +1 -1
  3. {ctrader_api_client-0.5.0 → ctrader_api_client-0.5.1}/src/ctrader_api_client/connection/heartbeat.py +12 -1
  4. {ctrader_api_client-0.5.0 → ctrader_api_client-0.5.1}/src/ctrader_api_client/connection/protocol.py +70 -15
  5. {ctrader_api_client-0.5.0 → ctrader_api_client-0.5.1}/tests/unit/connection/test_protocol.py +83 -0
  6. {ctrader_api_client-0.5.0 → ctrader_api_client-0.5.1}/uv.lock +1 -1
  7. ctrader_api_client-0.5.0/.claude/settings.local.json +0 -7
  8. {ctrader_api_client-0.5.0 → ctrader_api_client-0.5.1}/.github/workflows/docs.yml +0 -0
  9. {ctrader_api_client-0.5.0 → ctrader_api_client-0.5.1}/.gitignore +0 -0
  10. {ctrader_api_client-0.5.0 → ctrader_api_client-0.5.1}/.pre-commit-config.yaml +0 -0
  11. {ctrader_api_client-0.5.0 → ctrader_api_client-0.5.1}/.python-version +0 -0
  12. {ctrader_api_client-0.5.0 → ctrader_api_client-0.5.1}/Justfile +0 -0
  13. {ctrader_api_client-0.5.0 → ctrader_api_client-0.5.1}/LICENSE +0 -0
  14. {ctrader_api_client-0.5.0 → ctrader_api_client-0.5.1}/README.md +0 -0
  15. {ctrader_api_client-0.5.0 → ctrader_api_client-0.5.1}/docs/api/accounts.md +0 -0
  16. {ctrader_api_client-0.5.0 → ctrader_api_client-0.5.1}/docs/api/client.md +0 -0
  17. {ctrader_api_client-0.5.0 → ctrader_api_client-0.5.1}/docs/api/enums.md +0 -0
  18. {ctrader_api_client-0.5.0 → ctrader_api_client-0.5.1}/docs/api/events.md +0 -0
  19. {ctrader_api_client-0.5.0 → ctrader_api_client-0.5.1}/docs/api/market-data.md +0 -0
  20. {ctrader_api_client-0.5.0 → ctrader_api_client-0.5.1}/docs/api/models.md +0 -0
  21. {ctrader_api_client-0.5.0 → ctrader_api_client-0.5.1}/docs/api/symbols.md +0 -0
  22. {ctrader_api_client-0.5.0 → ctrader_api_client-0.5.1}/docs/api/trading.md +0 -0
  23. {ctrader_api_client-0.5.0 → ctrader_api_client-0.5.1}/docs/getting-started.md +0 -0
  24. {ctrader_api_client-0.5.0 → ctrader_api_client-0.5.1}/docs/index.md +0 -0
  25. {ctrader_api_client-0.5.0 → ctrader_api_client-0.5.1}/mkdocs.yml +0 -0
  26. {ctrader_api_client-0.5.0 → ctrader_api_client-0.5.1}/protos/SOURCE +0 -0
  27. {ctrader_api_client-0.5.0 → ctrader_api_client-0.5.1}/protos/VERSION +0 -0
  28. {ctrader_api_client-0.5.0 → ctrader_api_client-0.5.1}/protos/update.sh +0 -0
  29. {ctrader_api_client-0.5.0 → ctrader_api_client-0.5.1}/protos/vendor/OpenApiCommonMessages.proto +0 -0
  30. {ctrader_api_client-0.5.0 → ctrader_api_client-0.5.1}/protos/vendor/OpenApiCommonModelMessages.proto +0 -0
  31. {ctrader_api_client-0.5.0 → ctrader_api_client-0.5.1}/protos/vendor/OpenApiMessages.proto +0 -0
  32. {ctrader_api_client-0.5.0 → ctrader_api_client-0.5.1}/protos/vendor/OpenApiModelMessages.proto +0 -0
  33. {ctrader_api_client-0.5.0 → ctrader_api_client-0.5.1}/scripts/fix_proto_imports.py +0 -0
  34. {ctrader_api_client-0.5.0 → ctrader_api_client-0.5.1}/src/ctrader_api_client/__init__.py +0 -0
  35. {ctrader_api_client-0.5.0 → ctrader_api_client-0.5.1}/src/ctrader_api_client/_internal/__init__.py +0 -0
  36. {ctrader_api_client-0.5.0 → ctrader_api_client-0.5.1}/src/ctrader_api_client/_internal/messages.py +0 -0
  37. {ctrader_api_client-0.5.0 → ctrader_api_client-0.5.1}/src/ctrader_api_client/_internal/proto/OpenApiCommonMessages.py +0 -0
  38. {ctrader_api_client-0.5.0 → ctrader_api_client-0.5.1}/src/ctrader_api_client/_internal/proto/OpenApiCommonModelMessages.py +0 -0
  39. {ctrader_api_client-0.5.0 → ctrader_api_client-0.5.1}/src/ctrader_api_client/_internal/proto/OpenApiMessages.py +0 -0
  40. {ctrader_api_client-0.5.0 → ctrader_api_client-0.5.1}/src/ctrader_api_client/_internal/proto/OpenApiModelMessages.py +0 -0
  41. {ctrader_api_client-0.5.0 → ctrader_api_client-0.5.1}/src/ctrader_api_client/_internal/proto/__init__.py +0 -0
  42. {ctrader_api_client-0.5.0 → ctrader_api_client-0.5.1}/src/ctrader_api_client/_internal/serialization.py +0 -0
  43. {ctrader_api_client-0.5.0 → ctrader_api_client-0.5.1}/src/ctrader_api_client/api/__init__.py +0 -0
  44. {ctrader_api_client-0.5.0 → ctrader_api_client-0.5.1}/src/ctrader_api_client/api/accounts.py +0 -0
  45. {ctrader_api_client-0.5.0 → ctrader_api_client-0.5.1}/src/ctrader_api_client/api/market_data.py +0 -0
  46. {ctrader_api_client-0.5.0 → ctrader_api_client-0.5.1}/src/ctrader_api_client/api/symbols.py +0 -0
  47. {ctrader_api_client-0.5.0 → ctrader_api_client-0.5.1}/src/ctrader_api_client/api/trading.py +0 -0
  48. {ctrader_api_client-0.5.0 → ctrader_api_client-0.5.1}/src/ctrader_api_client/auth/__init__.py +0 -0
  49. {ctrader_api_client-0.5.0 → ctrader_api_client-0.5.1}/src/ctrader_api_client/auth/credentials.py +0 -0
  50. {ctrader_api_client-0.5.0 → ctrader_api_client-0.5.1}/src/ctrader_api_client/auth/manager.py +0 -0
  51. {ctrader_api_client-0.5.0 → ctrader_api_client-0.5.1}/src/ctrader_api_client/auth/trigger.py +0 -0
  52. {ctrader_api_client-0.5.0 → ctrader_api_client-0.5.1}/src/ctrader_api_client/client.py +0 -0
  53. {ctrader_api_client-0.5.0 → ctrader_api_client-0.5.1}/src/ctrader_api_client/config.py +0 -0
  54. {ctrader_api_client-0.5.0 → ctrader_api_client-0.5.1}/src/ctrader_api_client/connection/__init__.py +0 -0
  55. {ctrader_api_client-0.5.0 → ctrader_api_client-0.5.1}/src/ctrader_api_client/connection/transport.py +0 -0
  56. {ctrader_api_client-0.5.0 → ctrader_api_client-0.5.1}/src/ctrader_api_client/enums.py +0 -0
  57. {ctrader_api_client-0.5.0 → ctrader_api_client-0.5.1}/src/ctrader_api_client/events/__init__.py +0 -0
  58. {ctrader_api_client-0.5.0 → ctrader_api_client-0.5.1}/src/ctrader_api_client/events/emitter.py +0 -0
  59. {ctrader_api_client-0.5.0 → ctrader_api_client-0.5.1}/src/ctrader_api_client/events/router.py +0 -0
  60. {ctrader_api_client-0.5.0 → ctrader_api_client-0.5.1}/src/ctrader_api_client/events/types.py +0 -0
  61. {ctrader_api_client-0.5.0 → ctrader_api_client-0.5.1}/src/ctrader_api_client/exceptions.py +0 -0
  62. {ctrader_api_client-0.5.0 → ctrader_api_client-0.5.1}/src/ctrader_api_client/models/__init__.py +0 -0
  63. {ctrader_api_client-0.5.0 → ctrader_api_client-0.5.1}/src/ctrader_api_client/models/_base.py +0 -0
  64. {ctrader_api_client-0.5.0 → ctrader_api_client-0.5.1}/src/ctrader_api_client/models/account.py +0 -0
  65. {ctrader_api_client-0.5.0 → ctrader_api_client-0.5.1}/src/ctrader_api_client/models/deal.py +0 -0
  66. {ctrader_api_client-0.5.0 → ctrader_api_client-0.5.1}/src/ctrader_api_client/models/market_data.py +0 -0
  67. {ctrader_api_client-0.5.0 → ctrader_api_client-0.5.1}/src/ctrader_api_client/models/order.py +0 -0
  68. {ctrader_api_client-0.5.0 → ctrader_api_client-0.5.1}/src/ctrader_api_client/models/position.py +0 -0
  69. {ctrader_api_client-0.5.0 → ctrader_api_client-0.5.1}/src/ctrader_api_client/models/requests.py +0 -0
  70. {ctrader_api_client-0.5.0 → ctrader_api_client-0.5.1}/src/ctrader_api_client/models/symbol.py +0 -0
  71. {ctrader_api_client-0.5.0 → ctrader_api_client-0.5.1}/src/ctrader_api_client/py.typed +0 -0
  72. {ctrader_api_client-0.5.0 → ctrader_api_client-0.5.1}/tests/unit/_internal/test_messages.py +0 -0
  73. {ctrader_api_client-0.5.0 → ctrader_api_client-0.5.1}/tests/unit/_internal/test_serialization.py +0 -0
  74. {ctrader_api_client-0.5.0 → ctrader_api_client-0.5.1}/tests/unit/api/conftest.py +0 -0
  75. {ctrader_api_client-0.5.0 → ctrader_api_client-0.5.1}/tests/unit/api/test_accounts.py +0 -0
  76. {ctrader_api_client-0.5.0 → ctrader_api_client-0.5.1}/tests/unit/api/test_market_data_api.py +0 -0
  77. {ctrader_api_client-0.5.0 → ctrader_api_client-0.5.1}/tests/unit/api/test_symbols.py +0 -0
  78. {ctrader_api_client-0.5.0 → ctrader_api_client-0.5.1}/tests/unit/api/test_trading.py +0 -0
  79. {ctrader_api_client-0.5.0 → ctrader_api_client-0.5.1}/tests/unit/auth/test_credentials.py +0 -0
  80. {ctrader_api_client-0.5.0 → ctrader_api_client-0.5.1}/tests/unit/auth/test_manager.py +0 -0
  81. {ctrader_api_client-0.5.0 → ctrader_api_client-0.5.1}/tests/unit/connection/test_heartbeat.py +0 -0
  82. {ctrader_api_client-0.5.0 → ctrader_api_client-0.5.1}/tests/unit/connection/test_transport.py +0 -0
  83. {ctrader_api_client-0.5.0 → ctrader_api_client-0.5.1}/tests/unit/events/test_emitter.py +0 -0
  84. {ctrader_api_client-0.5.0 → ctrader_api_client-0.5.1}/tests/unit/events/test_router.py +0 -0
  85. {ctrader_api_client-0.5.0 → ctrader_api_client-0.5.1}/tests/unit/events/test_types.py +0 -0
  86. {ctrader_api_client-0.5.0 → ctrader_api_client-0.5.1}/tests/unit/models/test_account.py +0 -0
  87. {ctrader_api_client-0.5.0 → ctrader_api_client-0.5.1}/tests/unit/models/test_deal.py +0 -0
  88. {ctrader_api_client-0.5.0 → ctrader_api_client-0.5.1}/tests/unit/models/test_market_data.py +0 -0
  89. {ctrader_api_client-0.5.0 → ctrader_api_client-0.5.1}/tests/unit/models/test_order.py +0 -0
  90. {ctrader_api_client-0.5.0 → ctrader_api_client-0.5.1}/tests/unit/models/test_position.py +0 -0
  91. {ctrader_api_client-0.5.0 → ctrader_api_client-0.5.1}/tests/unit/models/test_requests.py +0 -0
  92. {ctrader_api_client-0.5.0 → ctrader_api_client-0.5.1}/tests/unit/models/test_symbol.py +0 -0
  93. {ctrader_api_client-0.5.0 → ctrader_api_client-0.5.1}/tests/unit/test_client.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ctrader-api-client
3
- Version: 0.5.0
3
+ Version: 0.5.1
4
4
  Summary: API Client to interact with the cTrader Open API spec
5
5
  Author-email: Elio <elioachukri@pm.me>
6
6
  License-File: LICENSE
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ctrader-api-client"
3
- version = "0.5.0"
3
+ version = "0.5.1"
4
4
  description = "API Client to interact with the cTrader Open API spec"
5
5
  readme = "README.md"
6
6
  authors = [
@@ -84,6 +84,14 @@ class HeartbeatManager:
84
84
  Resets the heartbeat timer and spawns a new heartbeat loop.
85
85
  Should be called after the protocol has reconnected.
86
86
  """
87
+ # Cancel any heartbeat loop still running from the previous connection.
88
+ # When a drop is detected by the reader loop (not by a heartbeat
89
+ # timeout/send failure), the old loop is merely asleep and would
90
+ # otherwise survive the reconnect, leaving two loops running and
91
+ # accumulating one extra on every reconnect.
92
+ if self._task_scope is not None:
93
+ self._task_scope.cancel()
94
+
87
95
  self._last_received = time.monotonic()
88
96
  if self._task_group is not None:
89
97
  self._task_group.start_soon(self._heartbeat_loop)
@@ -121,5 +129,8 @@ class HeartbeatManager:
121
129
  logger.debug("Heartbeat sent to server")
122
130
  except Exception as e:
123
131
  logger.warning("Failed to send heartbeat: %s", e)
124
- # Protocol will handle reconnection
132
+ # A failed write can be the first (or only) sign of a
133
+ # half-open connection, so drive reconnection explicitly
134
+ # instead of relying on the reader loop to notice.
135
+ await self._protocol.handle_disconnect()
125
136
  return
@@ -83,6 +83,10 @@ class Protocol:
83
83
  self._reconnect_min_wait = reconnect_min_wait
84
84
  self._reconnect_max_wait = reconnect_max_wait
85
85
 
86
+ # Guards against multiple concurrent reconnection attempts (e.g. the
87
+ # reader loop and the heartbeat loop both detecting the same drop).
88
+ self._reconnecting = False
89
+
86
90
  # Reconnection callback (set by CTraderClient)
87
91
  self._on_reconnect: Callable[[], Awaitable[None]] | None = None
88
92
 
@@ -314,29 +318,74 @@ class Protocol:
314
318
  logger.warning("Event handler error: %s", e)
315
319
 
316
320
  async def handle_disconnect(self) -> None:
317
- """Handle unexpected disconnection and attempt reconnection."""
318
- logger.warning("Connection lost, attempting to reconnect...")
321
+ """Trigger reconnection after an unexpected disconnect.
322
+
323
+ Safe to call from any task — the reader loop, the heartbeat loop, or a
324
+ failed heartbeat write. The reconnection itself runs as a *separate*
325
+ task on the protocol task group (see :meth:`_reconnect_task`) rather
326
+ than inline.
327
+
328
+ This indirection is essential: the caller is usually the reader loop,
329
+ which invokes this method from inside its own ``anyio.CancelScope``.
330
+ Doing the reconnection inline used to cancel that scope (to stop the
331
+ reader) and thereby abort the in-flight reconnection at its very first
332
+ checkpoint, leaving the client permanently offline. Spawning a
333
+ standalone task decouples the reconnection from the cancel scope of
334
+ whichever loop detected the drop.
335
+
336
+ Idempotent: calls made while a reconnection is already in flight are
337
+ ignored, so the reader and heartbeat loops racing to report the same
338
+ drop only produce a single reconnection.
339
+ """
340
+ if not self._running or self._reconnecting:
341
+ return
342
+ if self._task_group is None:
343
+ return
319
344
 
320
- # Cancel the reader loop first to prevent race conditions
321
- if self._reader_scope is not None:
322
- self._reader_scope.cancel()
323
- self._reader_scope = None
345
+ self._reconnecting = True
346
+ logger.warning("Connection lost, attempting to reconnect...")
347
+ self._task_group.start_soon(self._reconnect_task)
324
348
 
325
- # Close the transport
326
- await self._transport.close()
349
+ async def _reconnect_task(self) -> None:
350
+ """Own the full reconnection lifecycle from a standalone task.
327
351
 
328
- # Attempt reconnection
352
+ Runs outside the reader/heartbeat cancel scopes so it survives the death
353
+ of whichever task detected the drop. It must never let an exception
354
+ escape: an unhandled error here would propagate into the protocol task
355
+ group and tear down every other task. A terminal reconnection failure is
356
+ instead recorded by stopping the protocol and waking pending requests.
357
+ """
329
358
  try:
359
+ # The reader loop is reading a dead stream; cancel it before we
360
+ # reconnect so a stale reader can't race the fresh one we restart.
361
+ if self._reader_scope is not None:
362
+ self._reader_scope.cancel()
363
+ self._reader_scope = None
364
+
365
+ # Close the old transport (idempotent — the task that detected the
366
+ # drop may have already raced us to it).
367
+ await self._transport.close()
368
+
330
369
  await self._reconnect()
331
370
  logger.info("Reconnection successful")
332
371
  except (CTraderConnectionFailedError, CTraderConnectionClosedError) as e:
333
- logger.error("Reconnection failed: %s", e)
372
+ logger.error("Reconnection failed, giving up: %s", e)
334
373
  self._running = False
335
- # Signal all pending requests to wake up with error
374
+ # Wake all pending requests so callers fail fast instead of hanging.
336
375
  for msg_id in list(self._pending.keys()):
337
- self._errors[msg_id] = CTraderConnectionClosedError("Connection lost and reconnection failed")
376
+ self._errors[msg_id] = CTraderConnectionClosedError(
377
+ "Connection lost and reconnection failed"
378
+ )
338
379
  self._pending[msg_id].set()
339
- raise
380
+ except Exception:
381
+ # Defensive: never let the reconnection task crash the task group.
382
+ logger.exception("Unexpected error while reconnecting")
383
+ self._running = False
384
+ for msg_id in list(self._pending.keys()):
385
+ self._errors[msg_id] = CTraderConnectionClosedError("Reconnection error")
386
+ self._pending[msg_id].set()
387
+ finally:
388
+ self._reconnecting = False
340
389
 
341
390
  async def _reconnect(self) -> None:
342
391
  """Attempt reconnection with exponential backoff.
@@ -369,6 +418,12 @@ class Protocol:
369
418
  if self._task_group is not None:
370
419
  self._task_group.start_soon(self._reader_loop)
371
420
 
372
- # Notify callback for re-authentication
421
+ # Notify callback for re-authentication. The transport is already back
422
+ # up at this point, so a failure in the callback must not fail the
423
+ # reconnection itself — log it and let the caller's ReconnectedEvent
424
+ # surface the details.
373
425
  if self._on_reconnect is not None:
374
- await self._on_reconnect()
426
+ try:
427
+ await self._on_reconnect()
428
+ except Exception:
429
+ logger.exception("Post-reconnect re-authentication callback failed")
@@ -417,3 +417,86 @@ class TestReconnect:
417
417
  await protocol._reconnect()
418
418
 
419
419
  assert call_count == 3
420
+
421
+ @pytest.mark.anyio
422
+ async def test_reader_triggered_disconnect_actually_reconnects(self, mock_transport: MagicMock) -> None:
423
+ """Regression: a drop detected by the reader loop must fully reconnect.
424
+
425
+ Previously ``handle_disconnect`` ran inline and cancelled the reader
426
+ loop's own cancel scope, aborting the reconnection at its first
427
+ checkpoint so the client stayed offline forever.
428
+ """
429
+ # The stream reports EOF once (server closed the socket), then blocks so
430
+ # the restarted reader parks on a healthy connection instead of looping.
431
+ first_read = True
432
+
433
+ async def receive(_num_bytes: int) -> bytes:
434
+ nonlocal first_read
435
+ if first_read:
436
+ first_read = False
437
+ raise anyio.EndOfStream
438
+ await anyio.sleep_forever()
439
+
440
+ mock_stream = AsyncMock()
441
+ mock_stream.receive = receive
442
+ mock_transport.stream = mock_stream
443
+
444
+ # close() must contain a real suspension point (the true transport awaits
445
+ # stream.aclose()). That checkpoint is exactly where the pre-fix,
446
+ # self-cancelling handle_disconnect got aborted before reaching
447
+ # _reconnect() — an AsyncMock close() never suspends and would hide the bug.
448
+ close_calls = 0
449
+
450
+ async def close_with_checkpoint() -> None:
451
+ nonlocal close_calls
452
+ close_calls += 1
453
+ await anyio.sleep(0)
454
+
455
+ mock_transport.close = close_with_checkpoint
456
+
457
+ reconnected = anyio.Event()
458
+
459
+ async def on_reconnect() -> None:
460
+ reconnected.set()
461
+
462
+ protocol = Protocol(
463
+ mock_transport,
464
+ reconnect_attempts=3,
465
+ reconnect_min_wait=0.01,
466
+ reconnect_max_wait=0.02,
467
+ )
468
+ protocol._on_reconnect = on_reconnect
469
+
470
+ await protocol.start()
471
+ try:
472
+ with anyio.fail_after(2):
473
+ await reconnected.wait()
474
+
475
+ assert close_calls >= 1
476
+ assert mock_transport.connect.await_count >= 1
477
+ assert protocol._running is True
478
+ assert protocol._reconnecting is False
479
+ finally:
480
+ await protocol.stop()
481
+
482
+ @pytest.mark.anyio
483
+ async def test_handle_disconnect_noop_while_already_reconnecting(self, mock_transport: MagicMock) -> None:
484
+ """Concurrent disconnect reports collapse into a single reconnection."""
485
+ protocol = Protocol(mock_transport)
486
+ protocol._running = True
487
+ protocol._reconnecting = True
488
+
489
+ await protocol.handle_disconnect()
490
+
491
+ mock_transport.connect.assert_not_called()
492
+
493
+ @pytest.mark.anyio
494
+ async def test_handle_disconnect_noop_when_not_running(self, mock_transport: MagicMock) -> None:
495
+ """A disconnect during/after shutdown must not trigger reconnection."""
496
+ protocol = Protocol(mock_transport)
497
+ protocol._running = False
498
+
499
+ await protocol.handle_disconnect()
500
+
501
+ mock_transport.connect.assert_not_called()
502
+ assert protocol._reconnecting is False
@@ -201,7 +201,7 @@ wheels = [
201
201
 
202
202
  [[package]]
203
203
  name = "ctrader-api-client"
204
- version = "0.5.0"
204
+ version = "0.5.1"
205
205
  source = { editable = "." }
206
206
  dependencies = [
207
207
  { name = "anyio" },
@@ -1,7 +0,0 @@
1
- {
2
- "permissions": {
3
- "allow": [
4
- "Bash(git -C \"/home/tek/Desktop/Algo Trading/Live Trading/cTrader/ctrader-api-client\" log --oneline -10)"
5
- ]
6
- }
7
- }