ctrader-api-client 0.7.0__tar.gz → 0.7.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.
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/PKG-INFO +1 -1
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/docs/api/events.md +7 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/pyproject.toml +1 -1
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/src/ctrader_api_client/auth/_recovery.py +70 -9
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/src/ctrader_api_client/events/router.py +11 -10
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/src/ctrader_api_client/events/types.py +11 -5
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/src/ctrader_api_client/exceptions.py +9 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/tests/behavior/auth/test_token_lifecycle.py +70 -21
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/tests/behavior/events/test_routing.py +3 -2
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/tests/behavior/test_client.py +34 -1
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/tests/harness/recorder.py +1 -1
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/uv.lock +1 -1
- ctrader_api_client-0.7.0/HARDENING.md +0 -251
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/.claude/settings.local.json +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/.github/workflows/docs.yml +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/.gitignore +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/.pre-commit-config.yaml +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/.python-version +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/Justfile +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/LICENSE +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/README.md +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/docs/api/accounts.md +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/docs/api/client.md +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/docs/api/enums.md +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/docs/api/exceptions.md +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/docs/api/market-data.md +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/docs/api/models.md +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/docs/api/symbols.md +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/docs/api/trading.md +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/docs/getting-started.md +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/docs/index.md +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/mkdocs.yml +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/protos/SOURCE +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/protos/VERSION +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/protos/update.sh +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/protos/vendor/OpenApiCommonMessages.proto +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/protos/vendor/OpenApiCommonModelMessages.proto +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/protos/vendor/OpenApiMessages.proto +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/protos/vendor/OpenApiModelMessages.proto +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/scripts/fix_proto_imports.py +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/src/ctrader_api_client/__init__.py +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/src/ctrader_api_client/_internal/__init__.py +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/src/ctrader_api_client/_internal/clock.py +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/src/ctrader_api_client/_internal/conversions.py +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/src/ctrader_api_client/_internal/messages.py +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/src/ctrader_api_client/_internal/proto/OpenApiCommonMessages.py +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/src/ctrader_api_client/_internal/proto/OpenApiCommonModelMessages.py +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/src/ctrader_api_client/_internal/proto/OpenApiMessages.py +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/src/ctrader_api_client/_internal/proto/OpenApiModelMessages.py +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/src/ctrader_api_client/_internal/proto/__init__.py +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/src/ctrader_api_client/_internal/serialization.py +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/src/ctrader_api_client/api/__init__.py +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/src/ctrader_api_client/api/_base.py +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/src/ctrader_api_client/api/accounts.py +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/src/ctrader_api_client/api/market_data.py +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/src/ctrader_api_client/api/symbols.py +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/src/ctrader_api_client/api/trading.py +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/src/ctrader_api_client/auth/__init__.py +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/src/ctrader_api_client/auth/_refresh.py +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/src/ctrader_api_client/auth/_session.py +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/src/ctrader_api_client/auth/credentials.py +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/src/ctrader_api_client/auth/manager.py +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/src/ctrader_api_client/auth/policy.py +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/src/ctrader_api_client/auth/store.py +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/src/ctrader_api_client/client.py +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/src/ctrader_api_client/composition.py +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/src/ctrader_api_client/config.py +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/src/ctrader_api_client/connection/__init__.py +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/src/ctrader_api_client/connection/heartbeat.py +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/src/ctrader_api_client/connection/listener.py +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/src/ctrader_api_client/connection/protocol.py +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/src/ctrader_api_client/connection/supervisor.py +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/src/ctrader_api_client/connection/transport.py +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/src/ctrader_api_client/enums.py +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/src/ctrader_api_client/events/__init__.py +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/src/ctrader_api_client/events/_execution.py +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/src/ctrader_api_client/events/emitter.py +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/src/ctrader_api_client/events/publisher.py +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/src/ctrader_api_client/models/__init__.py +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/src/ctrader_api_client/models/_base.py +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/src/ctrader_api_client/models/account.py +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/src/ctrader_api_client/models/deal.py +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/src/ctrader_api_client/models/market_data.py +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/src/ctrader_api_client/models/order.py +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/src/ctrader_api_client/models/position.py +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/src/ctrader_api_client/models/requests.py +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/src/ctrader_api_client/models/symbol.py +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/src/ctrader_api_client/py.typed +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/tests/__init__.py +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/tests/behavior/__init__.py +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/tests/behavior/api/__init__.py +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/tests/behavior/api/conftest.py +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/tests/behavior/api/test_market_data.py +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/tests/behavior/api/test_reference_data.py +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/tests/behavior/api/test_subscription_restore.py +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/tests/behavior/api/test_trading.py +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/tests/behavior/auth/__init__.py +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/tests/behavior/auth/conftest.py +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/tests/behavior/auth/test_authentication.py +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/tests/behavior/connection/__init__.py +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/tests/behavior/connection/conftest.py +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/tests/behavior/connection/test_event_dispatch.py +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/tests/behavior/connection/test_framing.py +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/tests/behavior/connection/test_heartbeat.py +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/tests/behavior/connection/test_reconnection.py +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/tests/behavior/connection/test_request_response.py +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/tests/behavior/events/__init__.py +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/tests/behavior/events/conftest.py +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/tests/behavior/events/test_subscriptions.py +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/tests/conftest.py +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/tests/harness/__init__.py +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/tests/harness/clock.py +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/tests/harness/factories.py +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/tests/harness/server.py +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/tests/harness/signals.py +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/tests/harness/stub_protocol.py +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/tests/harness/wire.py +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/tests/unit/__init__.py +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/tests/unit/test_api_errors.py +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/tests/unit/test_market_data_conversion.py +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/tests/unit/test_request_conversion.py +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/tests/unit/test_symbol_conversion.py +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/tests/unit/test_trading_records.py +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/tests/unit/test_wire_format.py +0 -0
|
@@ -159,6 +159,13 @@ the existing connection with backoff and emits a `ReadyEvent` on success. The
|
|
|
159
159
|
event is informational; subscribe to it only if you want to observe or log the
|
|
160
160
|
drop. Check current authorization with `client.is_account_authorized(account_id)`.
|
|
161
161
|
|
|
162
|
+
A disconnect the server reports is checked before it is published. Rotating an
|
|
163
|
+
access token makes the server report one for a session it has not ended, so the
|
|
164
|
+
client re-authenticates to find out which it is: an account that still holds its
|
|
165
|
+
session is refused as already authorized, and nothing is published. What reaches
|
|
166
|
+
a handler is therefore always a session that was really gone at the moment it
|
|
167
|
+
was checked.
|
|
168
|
+
|
|
162
169
|
::: ctrader_api_client.events.TokenInvalidatedEvent
|
|
163
170
|
options:
|
|
164
171
|
show_source: false
|
{ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/src/ctrader_api_client/auth/_recovery.py
RENAMED
|
@@ -14,7 +14,8 @@ import anyio.abc
|
|
|
14
14
|
|
|
15
15
|
from .._internal import Clock, MonotonicClock
|
|
16
16
|
from ..enums import AuthTrigger
|
|
17
|
-
from ..events import EventPublisher, ReconnectedEvent
|
|
17
|
+
from ..events import AccountDisconnectEvent, EventPublisher, ReadyEvent, ReconnectedEvent
|
|
18
|
+
from ..exceptions import AccountAuthError
|
|
18
19
|
from ._session import AwaitingRecovery, SessionAuthenticator, SessionStore
|
|
19
20
|
from .policy import ReauthPolicy
|
|
20
21
|
|
|
@@ -55,6 +56,10 @@ class SessionRecovery:
|
|
|
55
56
|
# Wakes the recovery monitor when an account needs re-authenticating
|
|
56
57
|
self._reauth_signal = anyio.Event()
|
|
57
58
|
|
|
59
|
+
# Accounts whose reported disconnect is still being checked, so a
|
|
60
|
+
# repeated report does not start a second check against the server.
|
|
61
|
+
self._verifying: set[int] = set()
|
|
62
|
+
|
|
58
63
|
self._task_scope: anyio.CancelScope | None = None
|
|
59
64
|
self._running = False
|
|
60
65
|
|
|
@@ -90,24 +95,60 @@ class SessionRecovery:
|
|
|
90
95
|
if self._task_scope is not None:
|
|
91
96
|
self._task_scope.cancel()
|
|
92
97
|
|
|
93
|
-
def handle_account_disconnect(self, account_id: int) -> None:
|
|
94
|
-
"""
|
|
98
|
+
async def handle_account_disconnect(self, account_id: int) -> None:
|
|
99
|
+
"""Establish whether a reported account disconnect actually happened.
|
|
100
|
+
|
|
101
|
+
The server reports a disconnect whenever the token behind an
|
|
102
|
+
authorization is rotated, even though the session on this channel is
|
|
103
|
+
untouched. Taking the report at face value would mark a working account
|
|
104
|
+
unauthorized and publish a drop that never occurred, so the report is
|
|
105
|
+
checked against the server before anything acts on it.
|
|
95
106
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
107
|
+
Re-authenticating is the check: a session that is already live is
|
|
108
|
+
refused with `ALREADY_LOGGED_IN`, which says the report was spurious and
|
|
109
|
+
there is nothing to tell anyone. Any other answer means the session
|
|
110
|
+
really did end, and only then is the drop published and recovery armed.
|
|
99
111
|
|
|
100
112
|
Args:
|
|
101
113
|
account_id: The cTID trader account ID reported as disconnected.
|
|
102
114
|
"""
|
|
103
|
-
|
|
115
|
+
credentials = self._store.credentials(account_id)
|
|
116
|
+
if credentials is None or not self._store.is_authorized(account_id):
|
|
104
117
|
return
|
|
105
118
|
|
|
106
|
-
self.
|
|
119
|
+
if account_id in self._verifying:
|
|
120
|
+
return
|
|
121
|
+
|
|
122
|
+
self._verifying.add(account_id)
|
|
123
|
+
try:
|
|
124
|
+
await self._authenticator.establish(credentials, AuthTrigger.ACCOUNT_REAUTH)
|
|
125
|
+
except AccountAuthError as e:
|
|
126
|
+
if e.is_already_authorized():
|
|
127
|
+
logger.debug(
|
|
128
|
+
"Account %d reported as disconnected still holds its session; ignoring",
|
|
129
|
+
account_id,
|
|
130
|
+
)
|
|
131
|
+
return
|
|
132
|
+
await self._report_dropped(account_id, e)
|
|
133
|
+
except Exception as e:
|
|
134
|
+
await self._report_dropped(account_id, e)
|
|
135
|
+
finally:
|
|
136
|
+
self._verifying.discard(account_id)
|
|
137
|
+
|
|
138
|
+
async def _report_dropped(self, account_id: int, error: Exception) -> None:
|
|
139
|
+
"""Publish a confirmed drop and arm recovery for it.
|
|
140
|
+
|
|
141
|
+
Reached once the server has refused to re-authorize the account, so the
|
|
142
|
+
session is genuinely gone and could not be re-established on the spot.
|
|
143
|
+
"""
|
|
107
144
|
logger.warning(
|
|
108
|
-
"Account %d disconnected by server; scheduling re-authentication",
|
|
145
|
+
"Account %d disconnected by server; scheduling re-authentication: %s",
|
|
109
146
|
account_id,
|
|
147
|
+
error,
|
|
110
148
|
)
|
|
149
|
+
self._store.flag_for_recovery(account_id)
|
|
150
|
+
self._reauth_signal.set()
|
|
151
|
+
await self._publisher.emit(AccountDisconnectEvent(account_id=account_id))
|
|
111
152
|
|
|
112
153
|
async def on_connection_lost(self) -> None:
|
|
113
154
|
"""Discard the sessions that died with the link.
|
|
@@ -229,6 +270,26 @@ class SessionRecovery:
|
|
|
229
270
|
for account_id, credentials, state in due:
|
|
230
271
|
try:
|
|
231
272
|
await self._authenticator.establish(credentials, AuthTrigger.ACCOUNT_REAUTH)
|
|
273
|
+
except AccountAuthError as e:
|
|
274
|
+
if not e.is_already_authorized():
|
|
275
|
+
logger.warning(
|
|
276
|
+
"Recovery re-authentication for account %d failed, will retry: %s",
|
|
277
|
+
account_id,
|
|
278
|
+
e,
|
|
279
|
+
)
|
|
280
|
+
self._defer_recovery(account_id, state)
|
|
281
|
+
continue
|
|
282
|
+
# The session came back by some other route between
|
|
283
|
+
# attempts. Retrying could only be refused the same way, so
|
|
284
|
+
# take the account as recovered and announce it, noting that
|
|
285
|
+
# nothing re-applied the subscriptions the dead session took
|
|
286
|
+
# with it.
|
|
287
|
+
logger.warning(
|
|
288
|
+
"Account %d holds a session again; its subscriptions were not re-applied",
|
|
289
|
+
account_id,
|
|
290
|
+
)
|
|
291
|
+
self._store.authorize(credentials)
|
|
292
|
+
await self._publisher.emit(ReadyEvent(account_id=account_id, trigger=AuthTrigger.ACCOUNT_REAUTH))
|
|
232
293
|
except Exception as e:
|
|
233
294
|
logger.warning(
|
|
234
295
|
"Recovery re-authentication for account %d failed, will retry: %s",
|
{ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/src/ctrader_api_client/events/router.py
RENAMED
|
@@ -27,7 +27,6 @@ from ..models import Trendbar
|
|
|
27
27
|
from ._execution import execution_event_from_proto
|
|
28
28
|
from .emitter import EventEmitter
|
|
29
29
|
from .types import (
|
|
30
|
-
AccountDisconnectEvent,
|
|
31
30
|
ClientDisconnectEvent,
|
|
32
31
|
DepthEvent,
|
|
33
32
|
DepthQuote,
|
|
@@ -52,8 +51,8 @@ logger = logging.getLogger(__name__)
|
|
|
52
51
|
class SessionRecovery(TypingProtocol):
|
|
53
52
|
"""Whatever knows how to restore an account session the server dropped."""
|
|
54
53
|
|
|
55
|
-
def handle_account_disconnect(self, account_id: int) -> None:
|
|
56
|
-
"""
|
|
54
|
+
async def handle_account_disconnect(self, account_id: int) -> None:
|
|
55
|
+
"""Check the reported disconnect, then publish and recover a real one."""
|
|
57
56
|
...
|
|
58
57
|
|
|
59
58
|
|
|
@@ -276,13 +275,15 @@ class EventRouter:
|
|
|
276
275
|
self,
|
|
277
276
|
proto: ProtoOAAccountDisconnectEvent,
|
|
278
277
|
) -> None:
|
|
279
|
-
"""
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
278
|
+
"""Hand a reported account disconnect to recovery, which publishes it.
|
|
279
|
+
|
|
280
|
+
The server reports a disconnect on every token rotation, for a session
|
|
281
|
+
it has not actually ended, so the report is checked before anyone hears
|
|
282
|
+
about it. Recovery does that checking and emits `AccountDisconnectEvent`
|
|
283
|
+
only for a drop that turns out to be real.
|
|
284
|
+
"""
|
|
285
|
+
logger.debug("Account %d reported as disconnected by server", proto.ctid_trader_account_id)
|
|
286
|
+
await self._recovery.handle_account_disconnect(proto.ctid_trader_account_id)
|
|
286
287
|
|
|
287
288
|
async def _handle_symbol_changed(
|
|
288
289
|
self,
|
{ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/src/ctrader_api_client/events/types.py
RENAMED
|
@@ -238,11 +238,17 @@ class ClientDisconnectEvent:
|
|
|
238
238
|
class AccountDisconnectEvent:
|
|
239
239
|
"""Account disconnect event.
|
|
240
240
|
|
|
241
|
-
Emitted when
|
|
242
|
-
the underlying connection stays up
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
241
|
+
Emitted when an account's session has been terminated by the server while
|
|
242
|
+
the underlying connection stays up, and could not be re-established on the
|
|
243
|
+
spot. The client keeps re-authenticating on the existing connection with
|
|
244
|
+
backoff until it succeeds, then emits a ReadyEvent; no user action is
|
|
245
|
+
required to restore the session.
|
|
246
|
+
|
|
247
|
+
The server also reports a disconnect whenever the token behind an
|
|
248
|
+
authorization is rotated, for a session it has not actually ended. Those
|
|
249
|
+
reports are checked against the server and discarded, so they do not appear
|
|
250
|
+
here — every event published is a drop that outlived a re-authentication
|
|
251
|
+
attempt.
|
|
246
252
|
|
|
247
253
|
Attributes:
|
|
248
254
|
account_id: The cTID trader account ID.
|
|
@@ -108,6 +108,15 @@ class AccountAuthError(AuthenticationError):
|
|
|
108
108
|
message += f" - {description}"
|
|
109
109
|
super().__init__(message)
|
|
110
110
|
|
|
111
|
+
def is_already_authorized(self) -> bool:
|
|
112
|
+
"""Whether the account was rejected for holding a live session already.
|
|
113
|
+
|
|
114
|
+
The server refuses to authorize an account twice on one channel. A
|
|
115
|
+
caller that was trying to reach exactly that state has already reached
|
|
116
|
+
it, so this is a rejection worth reading as success.
|
|
117
|
+
"""
|
|
118
|
+
return self.error_code == ProtoOAErrorCode.ALREADY_LOGGED_IN.name
|
|
119
|
+
|
|
111
120
|
|
|
112
121
|
class TokenExpiredError(AuthenticationError):
|
|
113
122
|
"""Access token has expired."""
|
{ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/tests/behavior/auth/test_token_lifecycle.py
RENAMED
|
@@ -8,9 +8,9 @@ import betterproto
|
|
|
8
8
|
import pytest
|
|
9
9
|
|
|
10
10
|
from ctrader_api_client._internal.proto import ProtoOAAccountAuthReq, ProtoOARefreshTokenReq
|
|
11
|
-
from ctrader_api_client.auth import AuthManager, ReauthPolicy
|
|
11
|
+
from ctrader_api_client.auth import AuthManager, ReauthPolicy, SessionRecovery, SessionStore
|
|
12
12
|
from ctrader_api_client.enums import AuthTrigger
|
|
13
|
-
from ctrader_api_client.events import ReadyEvent, TokenRefreshFailedEvent
|
|
13
|
+
from ctrader_api_client.events import AccountDisconnectEvent, ReadyEvent, TokenRefreshFailedEvent
|
|
14
14
|
from ctrader_api_client.exceptions import APIError, TokenExpiredError
|
|
15
15
|
|
|
16
16
|
from ...harness import ManualClock, RecordingPublisher, RecordingStore, StubProtocol, factories
|
|
@@ -292,17 +292,55 @@ async def test_a_transient_store_outage_recovers_on_the_next_check(
|
|
|
292
292
|
assert protocol.sent_of(ProtoOAAccountAuthReq)[-1].access_token == "new-access-token"
|
|
293
293
|
|
|
294
294
|
|
|
295
|
-
async def
|
|
295
|
+
async def test_an_account_the_server_confirms_is_gone_stops_being_authorized(
|
|
296
296
|
auth: AuthManager,
|
|
297
297
|
monitors: Monitors,
|
|
298
298
|
protocol: StubProtocol,
|
|
299
299
|
clock: ManualClock,
|
|
300
|
+
publisher: RecordingPublisher,
|
|
300
301
|
) -> None:
|
|
301
|
-
|
|
302
|
+
"""A report the server stands behind is a real drop, and is published as one."""
|
|
303
|
+
protocol.respond_in_sequence(
|
|
304
|
+
ProtoOAAccountAuthReq,
|
|
305
|
+
[factories.account_auth_res(), APIError(error_code="TEMPORARY")],
|
|
306
|
+
)
|
|
307
|
+
await auth.authenticate_trader(factories.credentials(expires_in=PLENTY_OF_TIME))
|
|
308
|
+
await clock.wait_for_sleepers(SLEEPERS_WHEN_STARTED)
|
|
302
309
|
|
|
303
|
-
monitors.recovery.handle_account_disconnect(factories.ACCOUNT_ID)
|
|
310
|
+
await monitors.recovery.handle_account_disconnect(factories.ACCOUNT_ID)
|
|
304
311
|
|
|
305
312
|
assert not auth.is_account_authorized(factories.ACCOUNT_ID)
|
|
313
|
+
assert publisher.of(AccountDisconnectEvent) == [AccountDisconnectEvent(factories.ACCOUNT_ID)]
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
async def test_a_session_the_server_still_holds_is_left_alone(
|
|
317
|
+
auth: AuthManager,
|
|
318
|
+
monitors: Monitors,
|
|
319
|
+
protocol: StubProtocol,
|
|
320
|
+
clock: ManualClock,
|
|
321
|
+
publisher: RecordingPublisher,
|
|
322
|
+
) -> None:
|
|
323
|
+
"""A token rotation makes the server report a disconnect it did not perform.
|
|
324
|
+
|
|
325
|
+
Regression guard: taking that report at face value marked a working account
|
|
326
|
+
unauthorized, published a drop that never happened, and left recovery
|
|
327
|
+
retrying an account the server would only ever refuse as already logged in.
|
|
328
|
+
"""
|
|
329
|
+
protocol.respond_in_sequence(
|
|
330
|
+
ProtoOAAccountAuthReq,
|
|
331
|
+
[
|
|
332
|
+
factories.account_auth_res(),
|
|
333
|
+
APIError(error_code="ALREADY_LOGGED_IN", description="Trading account is already authorized"),
|
|
334
|
+
],
|
|
335
|
+
)
|
|
336
|
+
await auth.authenticate_trader(factories.credentials(expires_in=PLENTY_OF_TIME))
|
|
337
|
+
await clock.wait_for_sleepers(SLEEPERS_WHEN_STARTED)
|
|
338
|
+
|
|
339
|
+
await monitors.recovery.handle_account_disconnect(factories.ACCOUNT_ID)
|
|
340
|
+
|
|
341
|
+
assert auth.is_account_authorized(factories.ACCOUNT_ID)
|
|
342
|
+
assert publisher.of(AccountDisconnectEvent) == []
|
|
343
|
+
assert publisher.of(ReadyEvent) == [ReadyEvent(factories.ACCOUNT_ID, AuthTrigger.INITIAL)]
|
|
306
344
|
|
|
307
345
|
|
|
308
346
|
async def test_a_disconnected_account_is_re_authenticated(
|
|
@@ -314,7 +352,7 @@ async def test_a_disconnected_account_is_re_authenticated(
|
|
|
314
352
|
) -> None:
|
|
315
353
|
await start_with_account(auth, protocol, clock, expires_in=PLENTY_OF_TIME)
|
|
316
354
|
|
|
317
|
-
monitors.recovery.handle_account_disconnect(factories.ACCOUNT_ID)
|
|
355
|
+
await monitors.recovery.handle_account_disconnect(factories.ACCOUNT_ID)
|
|
318
356
|
await publisher.wait_for_type(ReadyEvent, count=2)
|
|
319
357
|
|
|
320
358
|
assert auth.is_account_authorized(factories.ACCOUNT_ID)
|
|
@@ -332,6 +370,8 @@ async def test_recovery_keeps_trying_after_a_failure(
|
|
|
332
370
|
ProtoOAAccountAuthReq,
|
|
333
371
|
[
|
|
334
372
|
factories.account_auth_res(),
|
|
373
|
+
# The check that confirms the drop, then the first retry after it.
|
|
374
|
+
APIError(error_code="TEMPORARY"),
|
|
335
375
|
APIError(error_code="TEMPORARY"),
|
|
336
376
|
factories.account_auth_res(),
|
|
337
377
|
],
|
|
@@ -339,7 +379,7 @@ async def test_recovery_keeps_trying_after_a_failure(
|
|
|
339
379
|
await auth.authenticate_trader(factories.credentials(expires_in=PLENTY_OF_TIME))
|
|
340
380
|
await clock.wait_for_sleepers(SLEEPERS_WHEN_STARTED)
|
|
341
381
|
|
|
342
|
-
monitors.recovery.handle_account_disconnect(factories.ACCOUNT_ID)
|
|
382
|
+
await monitors.recovery.handle_account_disconnect(factories.ACCOUNT_ID)
|
|
343
383
|
# The failed attempt arms a backoff; time has to move for the retry.
|
|
344
384
|
await clock.wait_for_sleepers(SLEEPERS_WHEN_STARTED + 1)
|
|
345
385
|
await clock.advance(1.0)
|
|
@@ -372,12 +412,11 @@ async def test_an_account_dropped_during_another_backoff_is_recovered_at_once(
|
|
|
372
412
|
|
|
373
413
|
protocol.respond_with(ProtoOAAccountAuthReq, answer)
|
|
374
414
|
|
|
375
|
-
monitors.recovery.handle_account_disconnect(stubborn)
|
|
415
|
+
await monitors.recovery.handle_account_disconnect(stubborn)
|
|
376
416
|
# Its failed attempt parks recovery on a ten second backoff.
|
|
377
417
|
await clock.wait_for_sleepers(SLEEPERS_WHEN_STARTED + 1)
|
|
378
418
|
|
|
379
|
-
monitors.recovery.handle_account_disconnect(healthy)
|
|
380
|
-
await protocol.wait_for_sent(ProtoOAAccountAuthReq, count=4)
|
|
419
|
+
await monitors.recovery.handle_account_disconnect(healthy)
|
|
381
420
|
|
|
382
421
|
assert auth.is_account_authorized(healthy)
|
|
383
422
|
assert not auth.is_account_authorized(stubborn)
|
|
@@ -391,26 +430,36 @@ async def test_a_disconnect_for_an_unknown_account_is_ignored(
|
|
|
391
430
|
) -> None:
|
|
392
431
|
await start_with_account(auth, protocol, clock, expires_in=PLENTY_OF_TIME)
|
|
393
432
|
|
|
394
|
-
monitors.recovery.handle_account_disconnect(999_999)
|
|
433
|
+
await monitors.recovery.handle_account_disconnect(999_999)
|
|
395
434
|
await clock.wait_for_sleepers(SLEEPERS_WHEN_STARTED)
|
|
396
435
|
|
|
397
436
|
assert len(protocol.sent_of(ProtoOAAccountAuthReq)) == 1
|
|
398
437
|
assert auth.is_account_authorized(factories.ACCOUNT_ID)
|
|
399
438
|
|
|
400
439
|
|
|
401
|
-
async def
|
|
440
|
+
async def test_a_report_for_an_account_already_recovering_is_not_checked_again(
|
|
402
441
|
auth: AuthManager,
|
|
403
|
-
monitors: Monitors,
|
|
404
442
|
protocol: StubProtocol,
|
|
405
|
-
clock: ManualClock,
|
|
406
443
|
publisher: RecordingPublisher,
|
|
444
|
+
sessions: SessionStore,
|
|
445
|
+
clock: ManualClock,
|
|
407
446
|
) -> None:
|
|
408
|
-
|
|
447
|
+
"""Once a drop is confirmed, further reports say nothing the queue does not.
|
|
409
448
|
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
449
|
+
The monitor is not served here: recovery's own retries would be
|
|
450
|
+
indistinguishable from a second check of the same report.
|
|
451
|
+
"""
|
|
452
|
+
recovery = SessionRecovery(store=sessions, authenticator=auth, publisher=publisher, clock=clock)
|
|
453
|
+
protocol.respond_in_sequence(
|
|
454
|
+
ProtoOAAccountAuthReq,
|
|
455
|
+
[factories.account_auth_res(), APIError(error_code="TEMPORARY")],
|
|
456
|
+
)
|
|
457
|
+
await auth.authenticate_trader(factories.credentials(expires_in=PLENTY_OF_TIME))
|
|
458
|
+
|
|
459
|
+
await recovery.handle_account_disconnect(factories.ACCOUNT_ID)
|
|
460
|
+
checked = len(protocol.sent_of(ProtoOAAccountAuthReq))
|
|
461
|
+
await recovery.handle_account_disconnect(factories.ACCOUNT_ID)
|
|
462
|
+
await recovery.handle_account_disconnect(factories.ACCOUNT_ID)
|
|
415
463
|
|
|
416
|
-
assert len(protocol.sent_of(ProtoOAAccountAuthReq)) ==
|
|
464
|
+
assert len(protocol.sent_of(ProtoOAAccountAuthReq)) == checked
|
|
465
|
+
assert publisher.of(AccountDisconnectEvent) == [AccountDisconnectEvent(factories.ACCOUNT_ID)]
|
|
@@ -542,16 +542,17 @@ async def test_a_client_disconnect_carries_its_reason(
|
|
|
542
542
|
assert received.only.reason == "Maintenance"
|
|
543
543
|
|
|
544
544
|
|
|
545
|
-
async def
|
|
545
|
+
async def test_an_account_disconnect_is_not_published_by_the_router(
|
|
546
546
|
routing: EventEmitter,
|
|
547
547
|
protocol: StubProtocol,
|
|
548
548
|
) -> None:
|
|
549
|
+
"""The report is unverified here; recovery publishes the ones that hold up."""
|
|
549
550
|
received: Recorder[AccountDisconnectEvent] = Recorder()
|
|
550
551
|
routing.subscribe(AccountDisconnectEvent, received)
|
|
551
552
|
|
|
552
553
|
await protocol.emit(ProtoOAAccountDisconnectEvent(ctid_trader_account_id=factories.ACCOUNT_ID))
|
|
553
554
|
|
|
554
|
-
assert received.
|
|
555
|
+
assert received.count == 0
|
|
555
556
|
|
|
556
557
|
|
|
557
558
|
@pytest.mark.usefixtures("routing")
|
|
@@ -17,6 +17,7 @@ from ctrader_api_client._internal.proto import (
|
|
|
17
17
|
)
|
|
18
18
|
from ctrader_api_client.auth import AccountCredentials
|
|
19
19
|
from ctrader_api_client.events import (
|
|
20
|
+
AccountDisconnectEvent,
|
|
20
21
|
ReadyEvent,
|
|
21
22
|
ReconnectedEvent,
|
|
22
23
|
SpotEvent,
|
|
@@ -49,6 +50,14 @@ def rejecting(_request: betterproto.Message) -> betterproto.Message:
|
|
|
49
50
|
return factories.error_res(error_code="ACCOUNT_NOT_AUTHORIZED")
|
|
50
51
|
|
|
51
52
|
|
|
53
|
+
def already_logged_in(_request: betterproto.Message) -> betterproto.Message:
|
|
54
|
+
"""Refuse the way the server refuses an account whose session is still live."""
|
|
55
|
+
return factories.error_res(
|
|
56
|
+
error_code="ALREADY_LOGGED_IN",
|
|
57
|
+
description="Trading account is already authorized in this channel",
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
52
61
|
# -----------------------------------------------------------------------------
|
|
53
62
|
# Lifecycle
|
|
54
63
|
# -----------------------------------------------------------------------------
|
|
@@ -387,15 +396,39 @@ async def test_a_dropped_account_stays_unauthorized_until_recovery_succeeds(
|
|
|
387
396
|
server: FakeServer,
|
|
388
397
|
) -> None:
|
|
389
398
|
await authenticate(client, server)
|
|
399
|
+
dropped: Recorder[AccountDisconnectEvent] = Recorder()
|
|
400
|
+
client.register_handler(AccountDisconnectEvent, dropped)
|
|
390
401
|
server.on(ProtoOAAccountAuthReq, rejecting)
|
|
391
402
|
|
|
392
403
|
await server.push(ProtoOAAccountDisconnectEvent(ctid_trader_account_id=factories.ACCOUNT_ID))
|
|
393
|
-
|
|
404
|
+
# The drop is published once the server has confirmed it, which is also when
|
|
405
|
+
# the account stops counting as authorized.
|
|
406
|
+
await dropped.wait_for(1)
|
|
394
407
|
|
|
395
408
|
assert client.is_account_authorized(factories.ACCOUNT_ID) is False
|
|
396
409
|
assert client.is_connected is True
|
|
397
410
|
|
|
398
411
|
|
|
412
|
+
async def test_a_disconnect_the_server_does_not_stand_behind_is_not_published(
|
|
413
|
+
client: CTraderClient,
|
|
414
|
+
server: FakeServer,
|
|
415
|
+
) -> None:
|
|
416
|
+
"""A token rotation makes the server report a disconnect it did not perform."""
|
|
417
|
+
await authenticate(client, server)
|
|
418
|
+
dropped: Recorder[AccountDisconnectEvent] = Recorder()
|
|
419
|
+
ready: Recorder[ReadyEvent] = Recorder()
|
|
420
|
+
client.register_handler(AccountDisconnectEvent, dropped)
|
|
421
|
+
client.register_handler(ReadyEvent, ready)
|
|
422
|
+
server.on(ProtoOAAccountAuthReq, already_logged_in)
|
|
423
|
+
|
|
424
|
+
await server.push(ProtoOAAccountDisconnectEvent(ctid_trader_account_id=factories.ACCOUNT_ID))
|
|
425
|
+
await server.wait_for_request(ProtoOAAccountAuthReq, count=2)
|
|
426
|
+
|
|
427
|
+
assert dropped.count == 0
|
|
428
|
+
assert ready.count == 0
|
|
429
|
+
assert client.is_account_authorized(factories.ACCOUNT_ID) is True
|
|
430
|
+
|
|
431
|
+
|
|
399
432
|
# -----------------------------------------------------------------------------
|
|
400
433
|
# Token refresh
|
|
401
434
|
# -----------------------------------------------------------------------------
|
|
@@ -133,7 +133,7 @@ class RecordingRecovery:
|
|
|
133
133
|
def __init__(self) -> None:
|
|
134
134
|
self.disconnected: list[int] = []
|
|
135
135
|
|
|
136
|
-
def handle_account_disconnect(self, account_id: int) -> None:
|
|
136
|
+
async def handle_account_disconnect(self, account_id: int) -> None:
|
|
137
137
|
"""Record the disconnected account."""
|
|
138
138
|
self.disconnected.append(account_id)
|
|
139
139
|
|
|
@@ -1,251 +0,0 @@
|
|
|
1
|
-
# Architecture hardening
|
|
2
|
-
|
|
3
|
-
Working notes for the `harden-architecture` branch. Everything below is against
|
|
4
|
-
`master` at `8f649f2`.
|
|
5
|
-
|
|
6
|
-
## Done
|
|
7
|
-
|
|
8
|
-
Each item is one commit, in order.
|
|
9
|
-
|
|
10
|
-
### Connection and auth lifecycle tests — `de41c4d`
|
|
11
|
-
|
|
12
|
-
Edge cases the existing suite did not reach, added before any of the
|
|
13
|
-
refactoring below so the refactoring had something to fail against: framing,
|
|
14
|
-
reconnection, token lifecycle and client startup.
|
|
15
|
-
|
|
16
|
-
### Typed request correlation — `bb66230`
|
|
17
|
-
|
|
18
|
-
`Protocol` tracked in-flight requests across three parallel dicts keyed by
|
|
19
|
-
message id. They could disagree. Replaced by a single object holding the event,
|
|
20
|
-
the outcome and the expected response type, so a pending request is one thing
|
|
21
|
-
that either exists or does not.
|
|
22
|
-
|
|
23
|
-
### Event handler registration — `4131424`
|
|
24
|
-
|
|
25
|
-
Registering a handler and unregistering it were written out separately at every
|
|
26
|
-
call site. `on_event` now returns a disposer, and callers hold that instead of
|
|
27
|
-
remembering how to undo their own registration.
|
|
28
|
-
|
|
29
|
-
### Pluggable token storage — `b6dc7df`
|
|
30
|
-
|
|
31
|
-
Added the `TokenStore` protocol and the option to pass one to the client. The
|
|
32
|
-
client writes through it whenever tokens rotate, so a process that restarts can
|
|
33
|
-
pick up where it left off rather than re-authenticating from credentials.
|
|
34
|
-
|
|
35
|
-
Two things moved in passing, both breaking:
|
|
36
|
-
|
|
37
|
-
- `AuthTrigger` moved from `auth/trigger.py`, which is gone, to `enums.py`. It
|
|
38
|
-
describes why a session was established, which the event types need and the
|
|
39
|
-
auth package should not own alone.
|
|
40
|
-
- `ReadyEvent.is_reconnect` is now a property over a `trigger: AuthTrigger`
|
|
41
|
-
field. Reading it is unchanged; constructing a `ReadyEvent` is not.
|
|
42
|
-
|
|
43
|
-
### Dead code and a start() guard — `de21183`
|
|
44
|
-
|
|
45
|
-
Removed `on_handler_error()`, which nothing called. Made
|
|
46
|
-
`HeartbeatManager.start()` idempotent: a second call used to overwrite the
|
|
47
|
-
nursery reference and leave the first loop unreachable, so `stop()` could never
|
|
48
|
-
find it.
|
|
49
|
-
|
|
50
|
-
### Automatic subscription restore — `e0a3273`
|
|
51
|
-
|
|
52
|
-
Subscriptions were restored by an explicit call after reconnection, which meant
|
|
53
|
-
every reconnection path had to remember to make it. `MarketDataAPI` is now
|
|
54
|
-
registered as a restorer and the reconnect path drives it.
|
|
55
|
-
|
|
56
|
-
### TokenStore documentation — `2f17ac3`
|
|
57
|
-
|
|
58
|
-
No code change. `TokenStore` is write-only by design — the library never reads
|
|
59
|
-
it back, because only the caller knows which accounts a given process is
|
|
60
|
-
responsible for. The name suggested otherwise, so the docstrings, the README
|
|
61
|
-
example and `docs/api/client.md` now say so and show the startup branch the
|
|
62
|
-
caller is expected to write.
|
|
63
|
-
|
|
64
|
-
Deliberately **not** done: adding `load()` to the protocol. The library would
|
|
65
|
-
have no reason to call it.
|
|
66
|
-
|
|
67
|
-
### Composition root — `b3e6fb5`
|
|
68
|
-
|
|
69
|
-
`CTraderClient.__init__` built eleven collaborators and wired them together,
|
|
70
|
-
which made the client impossible to construct with a substitute for any one of
|
|
71
|
-
them. That wiring moved verbatim to `composition.py` as `build_graph()`
|
|
72
|
-
returning a frozen `ClientGraph`, and `CTraderClient.from_graph()` adopts one.
|
|
73
|
-
|
|
74
|
-
Two ordering constraints in the wiring are load-bearing and are noted in the
|
|
75
|
-
source: market data is built before auth because it is the restorer, and auth
|
|
76
|
-
before the router because it is the recovery.
|
|
77
|
-
|
|
78
|
-
Deliberately **not** done: a `build_client()` convenience wrapper.
|
|
79
|
-
`build_graph` plus `from_graph` already covers it.
|
|
80
|
-
|
|
81
|
-
### Lifecycle inversion — `bd92f44` — **breaking**
|
|
82
|
-
|
|
83
|
-
`Protocol`, `HeartbeatManager` and `AuthManager` each held a task group handed
|
|
84
|
-
to them by a `start()` call and exited by a later `stop()` call from a different
|
|
85
|
-
task. `start()` replaced by `serve()`, which owns its nursery lexically and
|
|
86
|
-
suspends inside it; `stop()` reduced to cancelling the scope, which is allowed
|
|
87
|
-
from any task.
|
|
88
|
-
|
|
89
|
-
`CTraderClient.connect()` and `.close()` are **gone**. `async with client:` is
|
|
90
|
-
the only way to bring a client up. The nursery lives in the `async with` block,
|
|
91
|
-
so a background loop that dies is raised out of it rather than discovered at
|
|
92
|
-
shutdown.
|
|
93
|
-
|
|
94
|
-
Three things fell out of this:
|
|
95
|
-
|
|
96
|
-
- Every `except Exception: pass` in a `stop()` is gone; there is nothing left
|
|
97
|
-
to swallow.
|
|
98
|
-
- Cleanup moved into each `serve()`'s `finally`, so cancellation now cleans up
|
|
99
|
-
as thoroughly as an orderly stop.
|
|
100
|
-
- `task_group.start` rather than `start_soon` in the client means the reader is
|
|
101
|
-
provably live before anything is sent. That ordering used to be implicit.
|
|
102
|
-
|
|
103
|
-
Test-side, `connected` and `serving` fixtures give the *test* ownership of the
|
|
104
|
-
task group, for the same reason: a loop that dies mid-test surfaces at the end
|
|
105
|
-
of it instead of vanishing.
|
|
106
|
-
|
|
107
|
-
### B1 — connection supervisor — `9df080b` — **breaking**
|
|
108
|
-
|
|
109
|
-
`Protocol` reported drops and recoveries through two callbacks the client set
|
|
110
|
-
on it by hand, and `CTraderClient._lifecycle` knew the order transport,
|
|
111
|
-
protocol, heartbeat and auth had to come up in. Both are gone.
|
|
112
|
-
|
|
113
|
-
`ConnectionSupervisor` owns the transport, the protocol and the heartbeat.
|
|
114
|
-
`serving()` is an async context manager rather than a `serve()`: connecting
|
|
115
|
-
happens before the block is entered, so a server that is not there still raises
|
|
116
|
-
`CTraderConnectionFailedError` instead of the `ExceptionGroup` a task group
|
|
117
|
-
would make of it. `_lifecycle` is now the supervisor's block with auth and the
|
|
118
|
-
router inside it.
|
|
119
|
-
|
|
120
|
-
The callbacks became one port. `ConnectionListener` — `on_connection_lost` and
|
|
121
|
-
`on_connection_restored` — is what `Protocol` reports to and what the
|
|
122
|
-
supervisor fans out to. The supervisor puts the heartbeat back itself before
|
|
123
|
-
notifying, because that is its own machinery rather than a listener's concern.
|
|
124
|
-
|
|
125
|
-
The listener is `AuthManager`, not the client. Re-authenticating the
|
|
126
|
-
application and every account after a reconnect, and reporting the outcome as
|
|
127
|
-
`ReconnectedEvent`, is session work; it only lived on the client because the
|
|
128
|
-
callback did. That takes ~70 lines off `client.py`, and puts the logic where
|
|
129
|
-
B3's `SessionRecovery` would want it.
|
|
130
|
-
|
|
131
|
-
Breaking: `Protocol.set_reconnect_handler` and `set_disconnect_handler` are
|
|
132
|
-
replaced by `Protocol.set_listener`, and `AuthManager.handle_connection_lost`
|
|
133
|
-
is now the private `_forget_sessions`, reached through the port.
|
|
134
|
-
|
|
135
|
-
Test-side, a `make_graph` fixture hands tests the graph before the client
|
|
136
|
-
adopts it, which is how the listener fan-out is exercised.
|
|
137
|
-
|
|
138
|
-
### Exception shape at the client boundary — `ff6137a`
|
|
139
|
-
|
|
140
|
-
Found while placing automatic application authentication. Since the lifecycle
|
|
141
|
-
inversion, the task group holding the background tasks surrounds the `yield` in
|
|
142
|
-
`_lifecycle`, so *every* exception leaving `async with client:` passed through
|
|
143
|
-
it — and anyio wraps a lone exception as readily as a group. A caller's own
|
|
144
|
-
`ValueError` came back as `ExceptionGroup: unhandled errors in a TaskGroup (1
|
|
145
|
-
sub-exception)`, and `except CTraderConnectionFailedError` would have stopped
|
|
146
|
-
matching anything the machinery raised.
|
|
147
|
-
|
|
148
|
-
`__aenter__` and `__aexit__` now peel single-exception groups, recursively,
|
|
149
|
-
since the supervisor's task group nests inside the client's. Several failures
|
|
150
|
-
still arrive as a group, because that is what they are, and the block's own
|
|
151
|
-
exception is returned to the caller untouched rather than re-raised.
|
|
152
|
-
|
|
153
|
-
Not a break against `master`: this restores the shape callers had before the
|
|
154
|
-
branch.
|
|
155
|
-
|
|
156
|
-
### B3 — split AuthManager — `651da82` — **breaking**
|
|
157
|
-
|
|
158
|
-
`auth/manager.py` was 751 lines doing four unrelated jobs. It is now four
|
|
159
|
-
pieces, each with one:
|
|
160
|
-
|
|
161
|
-
- `auth/_session.py` — `SessionStore`, joining the session state types already
|
|
162
|
-
there. It owns the dict, its invalidation and every query over it, so the
|
|
163
|
-
distinction between credentials (which outlive a link) and sessions (which do
|
|
164
|
-
not) is stated once instead of being re-derived by each collaborator.
|
|
165
|
-
- `auth/_refresh.py` — `TokenRefresher`, the refresh loop, its retry policy and
|
|
166
|
-
the `TokenStore` write-through.
|
|
167
|
-
- `auth/_recovery.py` — `SessionRecovery`, the reauth loop and the
|
|
168
|
-
`ConnectionListener` methods. It is also the `SessionRecovery` port the event
|
|
169
|
-
router already typed its collaborator as, so the names finally line up.
|
|
170
|
-
- `auth/manager.py` — `AuthManager`, down to turning credentials into live
|
|
171
|
-
sessions.
|
|
172
|
-
|
|
173
|
-
The refresher and the recovery monitor each serve themselves, so `_lifecycle`
|
|
174
|
-
starts two tasks where it started one, and `AuthManager` no longer has a
|
|
175
|
-
lifecycle of its own.
|
|
176
|
-
|
|
177
|
-
The public surface was cut at the same time, on the rule that nothing returning
|
|
178
|
-
a bare `ProtoOA` message should be public:
|
|
179
|
-
|
|
180
|
-
- `authenticate_app` is called by the client as it connects, and on every
|
|
181
|
-
reconnect. A client that cannot authenticate does not open, and the failure
|
|
182
|
-
arrives as `ApplicationAuthError` thanks to the unwrapping above.
|
|
183
|
-
- `authenticate_account` became `authenticate_trader`, returning nothing. The
|
|
184
|
-
`trigger` argument moved to the internal `establish`, where the refresher and
|
|
185
|
-
the recovery monitor use it.
|
|
186
|
-
- `get_accounts` and `resolve_account_id` moved to `AccountsAPI` as
|
|
187
|
-
`list_by_token` and `resolve_account_id`. They are account queries, not auth.
|
|
188
|
-
- `authenticate_by_trader_login` is gone with no replacement. Resolving a login
|
|
189
|
-
and authenticating an account are two steps, and the convenience of joining
|
|
190
|
-
them cost a second way to do the same thing:
|
|
191
|
-
`await client.auth.authenticate_trader(await store.load(account_id))` is now
|
|
192
|
-
the only shape, restart or not.
|
|
193
|
-
|
|
194
|
-
`APIError.is_token_failure()` replaces the module-level helper, since three
|
|
195
|
-
modules now need to ask.
|
|
196
|
-
|
|
197
|
-
### Flat public surface — `6cc397c`
|
|
198
|
-
|
|
199
|
-
The package exported `CTraderClient`, `ClientConfig` and seven of the fourteen
|
|
200
|
-
enums "for easier access". The seven were an arbitrary subset: a caller reading
|
|
201
|
-
`order.status`, `order.time_in_force`, `deal.status`, `symbol.trading_mode` or
|
|
202
|
-
`account.access_rights` had to switch to `ctrader_api_client.enums` halfway down
|
|
203
|
-
the file. `AccountCredentials`, the one type nobody can avoid constructing, sat
|
|
204
|
-
two modules deep.
|
|
205
|
-
|
|
206
|
-
The top level now carries what a caller names — what you construct, what you
|
|
207
|
-
receive, what you catch — and the subpackages keep what the composition root
|
|
208
|
-
wires. Sixty-eight names: the client and its config, the credentials and the two
|
|
209
|
-
policies and the `TokenStore` protocol, every enum, every model, every event
|
|
210
|
-
type, every exception. `AuthManager`, `SessionStore`, `TokenRefresher`,
|
|
211
|
-
`SessionRecovery`, the emitter/publisher/router and everything under `api/`,
|
|
212
|
-
`connection/` and `_internal/` stay where they are, since the client hands them
|
|
213
|
-
to you already assembled.
|
|
214
|
-
|
|
215
|
-
The result models are exported despite `Account`, `Order`, `Position`, `Symbol`
|
|
216
|
-
and `Event` being names a trading codebase may well use itself. A name you
|
|
217
|
-
receive is a name you annotate, and `import ctrader_api_client as ct` is there
|
|
218
|
-
for anyone with a conflict.
|
|
219
|
-
|
|
220
|
-
Additive: the subpackage paths still work. Importing costs nothing extra either,
|
|
221
|
-
since `from .client import CTraderClient` already pulled the whole graph in.
|
|
222
|
-
|
|
223
|
-
The `Environment` enum was deleted in passing — declared, documented, and
|
|
224
|
-
referenced nowhere. `ClientConfig` takes a `host` string.
|
|
225
|
-
|
|
226
|
-
## Open
|
|
227
|
-
|
|
228
|
-
### Version bump and changelog
|
|
229
|
-
|
|
230
|
-
The lifecycle inversion removes `connect()` and `close()` from the public API,
|
|
231
|
-
and the connection supervisor replaces `Protocol.set_reconnect_handler` /
|
|
232
|
-
`set_disconnect_handler` with `set_listener` and drops
|
|
233
|
-
`AuthManager.handle_connection_lost`. The AuthManager split then removes
|
|
234
|
-
`authenticate_app`, `authenticate_account`, `authenticate_by_trader_login`,
|
|
235
|
-
`get_accounts` and `resolve_account_id` from `client.auth`, adding
|
|
236
|
-
`authenticate_trader` in their place and moving the two queries to
|
|
237
|
-
`client.accounts`. The `TokenStore` commit moved `AuthTrigger` out of
|
|
238
|
-
`auth.trigger` into `enums`, and turned `ReadyEvent.is_reconnect` into a
|
|
239
|
-
property over a new `trigger` field, which breaks anyone constructing the event
|
|
240
|
-
themselves. This needs a bump from `0.6.0` and a note saying so.
|
|
241
|
-
|
|
242
|
-
The flat surface is additive and breaks nobody, but the `Environment` enum it
|
|
243
|
-
deleted was public, so it belongs in the same note. Nothing else on the branch
|
|
244
|
-
breaks a caller.
|
|
245
|
-
|
|
246
|
-
### Smaller things noticed but not acted on
|
|
247
|
-
|
|
248
|
-
- `client.py` is down to 501 lines, nearly all delegation and docstrings.
|
|
249
|
-
- `protocol.py` is 550 lines and does framing, correlation, dispatch and
|
|
250
|
-
reconnection. Splitting it was never discussed; noting it as an observation
|
|
251
|
-
rather than a proposal.
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/protos/vendor/OpenApiCommonMessages.proto
RENAMED
|
File without changes
|
{ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/protos/vendor/OpenApiCommonModelMessages.proto
RENAMED
|
File without changes
|
|
File without changes
|
{ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/protos/vendor/OpenApiModelMessages.proto
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/src/ctrader_api_client/_internal/__init__.py
RENAMED
|
File without changes
|
{ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/src/ctrader_api_client/_internal/clock.py
RENAMED
|
File without changes
|
|
File without changes
|
{ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/src/ctrader_api_client/_internal/messages.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/src/ctrader_api_client/api/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
{ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/src/ctrader_api_client/api/accounts.py
RENAMED
|
File without changes
|
{ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/src/ctrader_api_client/api/market_data.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/src/ctrader_api_client/auth/__init__.py
RENAMED
|
File without changes
|
{ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/src/ctrader_api_client/auth/_refresh.py
RENAMED
|
File without changes
|
{ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/src/ctrader_api_client/auth/_session.py
RENAMED
|
File without changes
|
{ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/src/ctrader_api_client/auth/credentials.py
RENAMED
|
File without changes
|
{ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/src/ctrader_api_client/auth/manager.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/src/ctrader_api_client/connection/__init__.py
RENAMED
|
File without changes
|
{ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/src/ctrader_api_client/connection/heartbeat.py
RENAMED
|
File without changes
|
{ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/src/ctrader_api_client/connection/listener.py
RENAMED
|
File without changes
|
{ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/src/ctrader_api_client/connection/protocol.py
RENAMED
|
File without changes
|
|
File without changes
|
{ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/src/ctrader_api_client/connection/transport.py
RENAMED
|
File without changes
|
|
File without changes
|
{ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/src/ctrader_api_client/events/__init__.py
RENAMED
|
File without changes
|
{ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/src/ctrader_api_client/events/_execution.py
RENAMED
|
File without changes
|
{ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/src/ctrader_api_client/events/emitter.py
RENAMED
|
File without changes
|
{ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/src/ctrader_api_client/events/publisher.py
RENAMED
|
File without changes
|
{ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/src/ctrader_api_client/models/__init__.py
RENAMED
|
File without changes
|
{ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/src/ctrader_api_client/models/_base.py
RENAMED
|
File without changes
|
{ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/src/ctrader_api_client/models/account.py
RENAMED
|
File without changes
|
|
File without changes
|
{ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/src/ctrader_api_client/models/market_data.py
RENAMED
|
File without changes
|
{ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/src/ctrader_api_client/models/order.py
RENAMED
|
File without changes
|
{ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/src/ctrader_api_client/models/position.py
RENAMED
|
File without changes
|
{ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/src/ctrader_api_client/models/requests.py
RENAMED
|
File without changes
|
{ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/src/ctrader_api_client/models/symbol.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/tests/behavior/api/test_market_data.py
RENAMED
|
File without changes
|
{ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/tests/behavior/api/test_reference_data.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/tests/behavior/auth/test_authentication.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/tests/behavior/connection/test_framing.py
RENAMED
|
File without changes
|
{ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/tests/behavior/connection/test_heartbeat.py
RENAMED
|
File without changes
|
{ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/tests/behavior/connection/test_reconnection.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/tests/behavior/events/test_subscriptions.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/tests/unit/test_market_data_conversion.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|