ctrader-api-client 0.7.1__tar.gz → 0.7.3__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.
- {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/.gitignore +4 -0
- {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/PKG-INFO +1 -1
- {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/docs/api/events.md +24 -4
- {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/pyproject.toml +2 -1
- {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/src/ctrader_api_client/auth/_recovery.py +128 -18
- {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/src/ctrader_api_client/auth/_refresh.py +7 -0
- {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/src/ctrader_api_client/auth/_session.py +38 -0
- {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/src/ctrader_api_client/auth/manager.py +39 -0
- {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/src/ctrader_api_client/connection/protocol.py +68 -23
- {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/src/ctrader_api_client/connection/transport.py +1 -1
- {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/src/ctrader_api_client/events/types.py +7 -1
- {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/tests/behavior/auth/test_token_lifecycle.py +117 -17
- {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/tests/behavior/connection/test_reconnection.py +97 -0
- {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/tests/behavior/test_client.py +86 -3
- {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/tests/harness/factories.py +10 -0
- {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/uv.lock +1 -1
- {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/.claude/settings.local.json +0 -0
- {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/.github/workflows/docs.yml +0 -0
- {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/.pre-commit-config.yaml +0 -0
- {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/.python-version +0 -0
- {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/Justfile +0 -0
- {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/LICENSE +0 -0
- {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/README.md +0 -0
- {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/docs/api/accounts.md +0 -0
- {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/docs/api/client.md +0 -0
- {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/docs/api/enums.md +0 -0
- {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/docs/api/exceptions.md +0 -0
- {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/docs/api/market-data.md +0 -0
- {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/docs/api/models.md +0 -0
- {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/docs/api/symbols.md +0 -0
- {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/docs/api/trading.md +0 -0
- {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/docs/getting-started.md +0 -0
- {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/docs/index.md +0 -0
- {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/mkdocs.yml +0 -0
- {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/protos/SOURCE +0 -0
- {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/protos/VERSION +0 -0
- {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/protos/update.sh +0 -0
- {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/protos/vendor/OpenApiCommonMessages.proto +0 -0
- {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/protos/vendor/OpenApiCommonModelMessages.proto +0 -0
- {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/protos/vendor/OpenApiMessages.proto +0 -0
- {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/protos/vendor/OpenApiModelMessages.proto +0 -0
- {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/scripts/fix_proto_imports.py +0 -0
- {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/src/ctrader_api_client/__init__.py +0 -0
- {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/src/ctrader_api_client/_internal/__init__.py +0 -0
- {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/src/ctrader_api_client/_internal/clock.py +0 -0
- {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/src/ctrader_api_client/_internal/conversions.py +0 -0
- {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/src/ctrader_api_client/_internal/messages.py +0 -0
- {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/src/ctrader_api_client/_internal/proto/OpenApiCommonMessages.py +0 -0
- {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/src/ctrader_api_client/_internal/proto/OpenApiCommonModelMessages.py +0 -0
- {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/src/ctrader_api_client/_internal/proto/OpenApiMessages.py +0 -0
- {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/src/ctrader_api_client/_internal/proto/OpenApiModelMessages.py +0 -0
- {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/src/ctrader_api_client/_internal/proto/__init__.py +0 -0
- {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/src/ctrader_api_client/_internal/serialization.py +0 -0
- {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/src/ctrader_api_client/api/__init__.py +0 -0
- {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/src/ctrader_api_client/api/_base.py +0 -0
- {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/src/ctrader_api_client/api/accounts.py +0 -0
- {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/src/ctrader_api_client/api/market_data.py +0 -0
- {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/src/ctrader_api_client/api/symbols.py +0 -0
- {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/src/ctrader_api_client/api/trading.py +0 -0
- {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/src/ctrader_api_client/auth/__init__.py +0 -0
- {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/src/ctrader_api_client/auth/credentials.py +0 -0
- {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/src/ctrader_api_client/auth/policy.py +0 -0
- {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/src/ctrader_api_client/auth/store.py +0 -0
- {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/src/ctrader_api_client/client.py +0 -0
- {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/src/ctrader_api_client/composition.py +0 -0
- {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/src/ctrader_api_client/config.py +0 -0
- {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/src/ctrader_api_client/connection/__init__.py +0 -0
- {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/src/ctrader_api_client/connection/heartbeat.py +0 -0
- {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/src/ctrader_api_client/connection/listener.py +0 -0
- {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/src/ctrader_api_client/connection/supervisor.py +0 -0
- {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/src/ctrader_api_client/enums.py +0 -0
- {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/src/ctrader_api_client/events/__init__.py +0 -0
- {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/src/ctrader_api_client/events/_execution.py +0 -0
- {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/src/ctrader_api_client/events/emitter.py +0 -0
- {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/src/ctrader_api_client/events/publisher.py +0 -0
- {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/src/ctrader_api_client/events/router.py +0 -0
- {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/src/ctrader_api_client/exceptions.py +0 -0
- {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/src/ctrader_api_client/models/__init__.py +0 -0
- {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/src/ctrader_api_client/models/_base.py +0 -0
- {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/src/ctrader_api_client/models/account.py +0 -0
- {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/src/ctrader_api_client/models/deal.py +0 -0
- {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/src/ctrader_api_client/models/market_data.py +0 -0
- {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/src/ctrader_api_client/models/order.py +0 -0
- {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/src/ctrader_api_client/models/position.py +0 -0
- {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/src/ctrader_api_client/models/requests.py +0 -0
- {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/src/ctrader_api_client/models/symbol.py +0 -0
- {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/src/ctrader_api_client/py.typed +0 -0
- {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/tests/__init__.py +0 -0
- {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/tests/behavior/__init__.py +0 -0
- {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/tests/behavior/api/__init__.py +0 -0
- {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/tests/behavior/api/conftest.py +0 -0
- {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/tests/behavior/api/test_market_data.py +0 -0
- {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/tests/behavior/api/test_reference_data.py +0 -0
- {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/tests/behavior/api/test_subscription_restore.py +0 -0
- {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/tests/behavior/api/test_trading.py +0 -0
- {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/tests/behavior/auth/__init__.py +0 -0
- {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/tests/behavior/auth/conftest.py +0 -0
- {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/tests/behavior/auth/test_authentication.py +0 -0
- {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/tests/behavior/connection/__init__.py +0 -0
- {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/tests/behavior/connection/conftest.py +0 -0
- {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/tests/behavior/connection/test_event_dispatch.py +0 -0
- {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/tests/behavior/connection/test_framing.py +0 -0
- {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/tests/behavior/connection/test_heartbeat.py +0 -0
- {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/tests/behavior/connection/test_request_response.py +0 -0
- {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/tests/behavior/events/__init__.py +0 -0
- {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/tests/behavior/events/conftest.py +0 -0
- {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/tests/behavior/events/test_routing.py +0 -0
- {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/tests/behavior/events/test_subscriptions.py +0 -0
- {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/tests/conftest.py +0 -0
- {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/tests/harness/__init__.py +0 -0
- {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/tests/harness/clock.py +0 -0
- {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/tests/harness/recorder.py +0 -0
- {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/tests/harness/server.py +0 -0
- {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/tests/harness/signals.py +0 -0
- {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/tests/harness/stub_protocol.py +0 -0
- {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/tests/harness/wire.py +0 -0
- {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/tests/unit/__init__.py +0 -0
- {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/tests/unit/test_api_errors.py +0 -0
- {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/tests/unit/test_market_data_conversion.py +0 -0
- {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/tests/unit/test_request_conversion.py +0 -0
- {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/tests/unit/test_symbol_conversion.py +0 -0
- {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/tests/unit/test_trading_records.py +0 -0
- {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/tests/unit/test_wire_format.py +0 -0
|
@@ -146,6 +146,14 @@ async def on_reconnected(event: ReconnectedEvent):
|
|
|
146
146
|
print(f"Failed accounts: {event.failed_accounts}")
|
|
147
147
|
```
|
|
148
148
|
|
|
149
|
+
An `app_auth_restored=False` is not the end of the story. The server can refuse
|
|
150
|
+
application authentication for transient reasons of its own, so the client keeps
|
|
151
|
+
retrying with backoff and announces each account it restores afterwards with a
|
|
152
|
+
`ReadyEvent`. Treat this event as a signal that the link is back but degraded —
|
|
153
|
+
stop acting on the session until the accounts are announced ready — rather than
|
|
154
|
+
as a permanent failure. Check the current state with
|
|
155
|
+
`client.auth.is_app_authenticated`.
|
|
156
|
+
|
|
149
157
|
::: ctrader_api_client.events.ClientDisconnectEvent
|
|
150
158
|
options:
|
|
151
159
|
show_source: false
|
|
@@ -161,10 +169,22 @@ drop. Check current authorization with `client.is_account_authorized(account_id)
|
|
|
161
169
|
|
|
162
170
|
A disconnect the server reports is checked before it is published. Rotating an
|
|
163
171
|
access token makes the server report one for a session it has not ended, so the
|
|
164
|
-
client
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
172
|
+
client asks whether the account is still served on this connection: if it is,
|
|
173
|
+
nothing is published. The check does not re-authenticate, because that answers
|
|
174
|
+
for the token as much as for the session, and the rotation prompting the report
|
|
175
|
+
is the very thing that would make such an answer unreliable.
|
|
176
|
+
|
|
177
|
+
The check also waits for any token refresh on that account to finish first.
|
|
178
|
+
Rotating a token means the server drops the old authorization and the client
|
|
179
|
+
establishes a new one, and in between the account is genuinely unauthorized — it
|
|
180
|
+
refuses everything, including the check. Since the refresh always begins before
|
|
181
|
+
the report it provokes, waiting for it to settle is what keeps a moment the
|
|
182
|
+
client itself created from reading as a lost session.
|
|
183
|
+
|
|
184
|
+
A check that cannot be completed — a timeout, a link that has just gone — is not
|
|
185
|
+
read as a disconnect either. The account is left as it was, and the next report
|
|
186
|
+
is checked afresh. What reaches a handler is therefore always a session the
|
|
187
|
+
server confirmed it was no longer serving.
|
|
168
188
|
|
|
169
189
|
::: ctrader_api_client.events.TokenInvalidatedEvent
|
|
170
190
|
options:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "ctrader-api-client"
|
|
3
|
-
version = "0.7.
|
|
3
|
+
version = "0.7.3"
|
|
4
4
|
description = "API Client to interact with the cTrader Open API spec"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
authors = [
|
|
@@ -79,6 +79,7 @@ select = [
|
|
|
79
79
|
"RUF022", # Unsorted __all__ blocks
|
|
80
80
|
"FURB157", # Check unnecessary casts in Decimal constructors
|
|
81
81
|
"RUF032", # Check for Decimal calls passing a float literal
|
|
82
|
+
"G004", # Check for all f strings in logging calls
|
|
82
83
|
]
|
|
83
84
|
|
|
84
85
|
[tool.ruff.lint.per-file-ignores]
|
{ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/src/ctrader_api_client/auth/_recovery.py
RENAMED
|
@@ -60,6 +60,14 @@ class SessionRecovery:
|
|
|
60
60
|
# repeated report does not start a second check against the server.
|
|
61
61
|
self._verifying: set[int] = set()
|
|
62
62
|
|
|
63
|
+
# A reconnect whose application re-authentication failed, left for the
|
|
64
|
+
# recovery loop to keep retrying. Nothing can be authorized on a link
|
|
65
|
+
# the application itself is not authenticated on, so this is settled
|
|
66
|
+
# before any account is attempted.
|
|
67
|
+
self._app_auth_pending = False
|
|
68
|
+
self._app_auth_attempts = 0
|
|
69
|
+
self._app_auth_next_at = 0.0
|
|
70
|
+
|
|
63
71
|
self._task_scope: anyio.CancelScope | None = None
|
|
64
72
|
self._running = False
|
|
65
73
|
|
|
@@ -85,6 +93,7 @@ class SessionRecovery:
|
|
|
85
93
|
finally:
|
|
86
94
|
self._running = False
|
|
87
95
|
self._task_scope = None
|
|
96
|
+
self._app_auth_pending = False
|
|
88
97
|
self._store.invalidate_all()
|
|
89
98
|
logger.debug("Session recovery monitor stopped")
|
|
90
99
|
|
|
@@ -104,14 +113,32 @@ class SessionRecovery:
|
|
|
104
113
|
unauthorized and publish a drop that never occurred, so the report is
|
|
105
114
|
checked against the server before anything acts on it.
|
|
106
115
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
116
|
+
Every report the server sends for a token rotation arrives while the
|
|
117
|
+
client is replacing that authorization, and for the length of that
|
|
118
|
+
replacement the account really is unauthorized: it answers nothing, and
|
|
119
|
+
asking it anything would only confirm a state the client is already
|
|
120
|
+
undoing. So the check waits for the refresh to settle before it asks.
|
|
121
|
+
The refresh always starts before the report it provokes, so there is no
|
|
122
|
+
ordering in which the report escapes the wait.
|
|
123
|
+
|
|
124
|
+
The check itself asks whether the account is still served on this link
|
|
125
|
+
rather than trying to re-authorize it, since re-authorizing answers for
|
|
126
|
+
the token as much as for the session.
|
|
127
|
+
|
|
128
|
+
A check that cannot be completed is not an answer of no. The account is
|
|
129
|
+
left as it was, because nothing has said the session ended.
|
|
111
130
|
|
|
112
131
|
Args:
|
|
113
132
|
account_id: The cTID trader account ID reported as disconnected.
|
|
114
133
|
"""
|
|
134
|
+
if self._store.is_refreshing(account_id):
|
|
135
|
+
logger.debug(
|
|
136
|
+
"Account %d was reported as disconnected while its token was being "
|
|
137
|
+
"refreshed; waiting for that to settle before checking",
|
|
138
|
+
account_id,
|
|
139
|
+
)
|
|
140
|
+
await self._store.wait_for_refresh(account_id)
|
|
141
|
+
|
|
115
142
|
credentials = self._store.credentials(account_id)
|
|
116
143
|
if credentials is None or not self._store.is_authorized(account_id):
|
|
117
144
|
return
|
|
@@ -121,30 +148,36 @@ class SessionRecovery:
|
|
|
121
148
|
|
|
122
149
|
self._verifying.add(account_id)
|
|
123
150
|
try:
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
151
|
+
try:
|
|
152
|
+
alive = await self._authenticator.probe_session(account_id)
|
|
153
|
+
except Exception as e:
|
|
154
|
+
logger.debug(
|
|
155
|
+
"Could not check the disconnect reported for account %d, leaving it as it is: %s",
|
|
156
|
+
account_id,
|
|
157
|
+
e,
|
|
158
|
+
)
|
|
159
|
+
return
|
|
160
|
+
|
|
161
|
+
if alive:
|
|
127
162
|
logger.debug(
|
|
128
163
|
"Account %d reported as disconnected still holds its session; ignoring",
|
|
129
164
|
account_id,
|
|
130
165
|
)
|
|
131
166
|
return
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
await self._report_dropped(account_id, e)
|
|
167
|
+
|
|
168
|
+
await self._report_dropped(account_id)
|
|
135
169
|
finally:
|
|
136
170
|
self._verifying.discard(account_id)
|
|
137
171
|
|
|
138
|
-
async def _report_dropped(self, account_id: int
|
|
172
|
+
async def _report_dropped(self, account_id: int) -> None:
|
|
139
173
|
"""Publish a confirmed drop and arm recovery for it.
|
|
140
174
|
|
|
141
|
-
Reached once the server has
|
|
142
|
-
session is genuinely gone
|
|
175
|
+
Reached once the server has stopped serving the account on this link, so
|
|
176
|
+
the session is genuinely gone.
|
|
143
177
|
"""
|
|
144
178
|
logger.warning(
|
|
145
|
-
"Account %d disconnected by server; scheduling re-authentication
|
|
179
|
+
"Account %d disconnected by server; scheduling re-authentication",
|
|
146
180
|
account_id,
|
|
147
|
-
error,
|
|
148
181
|
)
|
|
149
182
|
self._store.flag_for_recovery(account_id)
|
|
150
183
|
self._reauth_signal.set()
|
|
@@ -157,6 +190,11 @@ class SessionRecovery:
|
|
|
157
190
|
credentials are kept, so the sessions can be re-established once the
|
|
158
191
|
link is back.
|
|
159
192
|
"""
|
|
193
|
+
# Any application re-authentication still being retried belonged to the
|
|
194
|
+
# link that just died. The reconnection attempts it again from scratch
|
|
195
|
+
# and re-arms this if it has to, so carrying the old state forward would
|
|
196
|
+
# only make the next backoff start deeper than it should.
|
|
197
|
+
self._app_auth_pending = False
|
|
160
198
|
self._store.invalidate_all()
|
|
161
199
|
|
|
162
200
|
async def on_connection_restored(self) -> None:
|
|
@@ -166,6 +204,11 @@ class SessionRecovery:
|
|
|
166
204
|
link is already back, and a caller can only act on a failure it is told
|
|
167
205
|
about. Without application authentication nothing else can be
|
|
168
206
|
attempted, so that failure is reported on its own.
|
|
207
|
+
|
|
208
|
+
Only the first attempt is made here. This runs inside the protocol's
|
|
209
|
+
reconnection task, which cannot detect a further drop while it is
|
|
210
|
+
blocked, so a failure is handed to the recovery loop to keep retrying in
|
|
211
|
+
the background rather than waited out inline.
|
|
169
212
|
"""
|
|
170
213
|
logger.debug("Connection restored, re-authenticating...")
|
|
171
214
|
|
|
@@ -173,7 +216,8 @@ class SessionRecovery:
|
|
|
173
216
|
await self._authenticator.authenticate_app()
|
|
174
217
|
logger.debug("App re-authenticated successfully")
|
|
175
218
|
except Exception as e:
|
|
176
|
-
logger.error("Failed to re-authenticate app after reconnect: %s", e)
|
|
219
|
+
logger.error("Failed to re-authenticate app after reconnect, will retry: %s", e)
|
|
220
|
+
self._arm_app_auth_recovery()
|
|
177
221
|
await self._publisher.emit(
|
|
178
222
|
ReconnectedEvent(
|
|
179
223
|
app_auth_restored=False,
|
|
@@ -204,15 +248,66 @@ class SessionRecovery:
|
|
|
204
248
|
)
|
|
205
249
|
)
|
|
206
250
|
|
|
251
|
+
def _backoff_delay(self, attempts: int) -> float:
|
|
252
|
+
"""How long to wait before attempt number `attempts`, per the policy."""
|
|
253
|
+
return min(self._policy.min_wait * 2 ** (attempts - 1), self._policy.max_wait)
|
|
254
|
+
|
|
207
255
|
def _defer_recovery(self, account_id: int, state: AwaitingRecovery) -> None:
|
|
208
256
|
"""Back off before the next recovery attempt for a single account."""
|
|
209
257
|
attempts = state.attempts + 1
|
|
210
|
-
delay = min(self._policy.min_wait * 2 ** (attempts - 1), self._policy.max_wait)
|
|
211
258
|
self._store.reschedule_recovery(
|
|
212
259
|
account_id,
|
|
213
|
-
AwaitingRecovery(
|
|
260
|
+
AwaitingRecovery(
|
|
261
|
+
attempts=attempts,
|
|
262
|
+
next_attempt_at=self._clock.now() + self._backoff_delay(attempts),
|
|
263
|
+
),
|
|
214
264
|
)
|
|
215
265
|
|
|
266
|
+
def _arm_app_auth_recovery(self) -> None:
|
|
267
|
+
"""Hand a failed application re-authentication to the recovery loop.
|
|
268
|
+
|
|
269
|
+
Every account is flagged alongside it: their sessions died with the
|
|
270
|
+
link, and the re-authentication that would have restored them is the
|
|
271
|
+
very thing that failed, so they are recovered by the same loop once the
|
|
272
|
+
application is authenticated again.
|
|
273
|
+
"""
|
|
274
|
+
self._app_auth_pending = True
|
|
275
|
+
self._app_auth_attempts = 0
|
|
276
|
+
self._defer_app_auth()
|
|
277
|
+
|
|
278
|
+
for credentials in self._store.all_credentials():
|
|
279
|
+
self._store.flag_for_recovery(credentials.account_id)
|
|
280
|
+
|
|
281
|
+
self._reauth_signal.set()
|
|
282
|
+
|
|
283
|
+
def _defer_app_auth(self) -> None:
|
|
284
|
+
"""Back off before the next application re-authentication attempt."""
|
|
285
|
+
self._app_auth_attempts += 1
|
|
286
|
+
self._app_auth_next_at = self._clock.now() + self._backoff_delay(self._app_auth_attempts)
|
|
287
|
+
|
|
288
|
+
async def _retry_app_auth(self) -> bool:
|
|
289
|
+
"""Attempt the deferred application re-authentication once.
|
|
290
|
+
|
|
291
|
+
Returns:
|
|
292
|
+
True if the application is authenticated again, False if the
|
|
293
|
+
attempt failed and has been deferred for another try.
|
|
294
|
+
"""
|
|
295
|
+
try:
|
|
296
|
+
await self._authenticator.authenticate_app()
|
|
297
|
+
except Exception as e:
|
|
298
|
+
self._defer_app_auth()
|
|
299
|
+
logger.warning(
|
|
300
|
+
"Re-authenticating the application failed (attempt %d), will retry: %s",
|
|
301
|
+
self._app_auth_attempts,
|
|
302
|
+
e,
|
|
303
|
+
)
|
|
304
|
+
return False
|
|
305
|
+
|
|
306
|
+
self._app_auth_pending = False
|
|
307
|
+
self._app_auth_attempts = 0
|
|
308
|
+
logger.info("Application re-authenticated; restoring accounts")
|
|
309
|
+
return True
|
|
310
|
+
|
|
216
311
|
async def _wait_for_retry(self, delay: float, flagged: anyio.Event) -> None:
|
|
217
312
|
"""Wait out a backoff, returning early if another account needs recovery.
|
|
218
313
|
|
|
@@ -242,6 +337,11 @@ class SessionRecovery:
|
|
|
242
337
|
and retries continue until every account succeeds, is removed, or the
|
|
243
338
|
monitor stops. A successful re-auth restores authorized state and emits
|
|
244
339
|
a ReadyEvent so subscriptions can be restored.
|
|
340
|
+
|
|
341
|
+
Also carries the application re-authentication a reconnect could not
|
|
342
|
+
complete. That one is not per-account and blocks every account behind
|
|
343
|
+
it, so it is settled first and the accounts are left flagged until it
|
|
344
|
+
succeeds.
|
|
245
345
|
"""
|
|
246
346
|
while self._running:
|
|
247
347
|
# Consume any pending notification before reading the queue, so a
|
|
@@ -250,6 +350,16 @@ class SessionRecovery:
|
|
|
250
350
|
self._reauth_signal = anyio.Event()
|
|
251
351
|
flagged = self._reauth_signal
|
|
252
352
|
|
|
353
|
+
if self._app_auth_pending:
|
|
354
|
+
waiting = self._app_auth_next_at - self._clock.now()
|
|
355
|
+
if waiting > 0:
|
|
356
|
+
await self._wait_for_retry(waiting, flagged)
|
|
357
|
+
continue
|
|
358
|
+
if not await self._retry_app_auth():
|
|
359
|
+
continue
|
|
360
|
+
# Authenticating the application is exactly what the flagged
|
|
361
|
+
# accounts were held up by, so go straight on to them.
|
|
362
|
+
|
|
253
363
|
queue = self._store.awaiting_recovery()
|
|
254
364
|
if not queue:
|
|
255
365
|
await flagged.wait()
|
{ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/src/ctrader_api_client/auth/_refresh.py
RENAMED
|
@@ -142,6 +142,11 @@ class TokenRefresher:
|
|
|
142
142
|
|
|
143
143
|
last_error: Exception | None = None
|
|
144
144
|
|
|
145
|
+
# Held from before the request goes out until the account is authorized
|
|
146
|
+
# again, because the disconnect the server reports for the old token
|
|
147
|
+
# arrives inside that window and means nothing about the session.
|
|
148
|
+
self._store.begin_refresh(account_id)
|
|
149
|
+
|
|
145
150
|
try:
|
|
146
151
|
async for attempt in AsyncRetrying(
|
|
147
152
|
stop=stop_after_attempt(self._policy.retry_attempts),
|
|
@@ -198,6 +203,8 @@ class TokenRefresher:
|
|
|
198
203
|
last_error = e
|
|
199
204
|
except Exception as e:
|
|
200
205
|
last_error = e
|
|
206
|
+
finally:
|
|
207
|
+
self._store.end_refresh(account_id)
|
|
201
208
|
|
|
202
209
|
if last_error is not None:
|
|
203
210
|
raise TokenRefreshError(account_id, last_error)
|
{ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/src/ctrader_api_client/auth/_session.py
RENAMED
|
@@ -17,6 +17,8 @@ from __future__ import annotations
|
|
|
17
17
|
from dataclasses import dataclass
|
|
18
18
|
from typing import Protocol as TypingProtocol
|
|
19
19
|
|
|
20
|
+
import anyio
|
|
21
|
+
|
|
20
22
|
from ..enums import AuthTrigger
|
|
21
23
|
from .credentials import AccountCredentials
|
|
22
24
|
|
|
@@ -77,6 +79,15 @@ class SessionAuthenticator(TypingProtocol):
|
|
|
77
79
|
"""Authorize an account, recording why so the right events follow."""
|
|
78
80
|
...
|
|
79
81
|
|
|
82
|
+
async def probe_session(self, account_id: int, timeout: float = 10.0) -> bool:
|
|
83
|
+
"""Whether the account still holds a live session on this link.
|
|
84
|
+
|
|
85
|
+
Raises whatever stopped the question from being answered — including a
|
|
86
|
+
refusal that is about the token rather than the session — which is not
|
|
87
|
+
the same as an answer of no.
|
|
88
|
+
"""
|
|
89
|
+
...
|
|
90
|
+
|
|
80
91
|
|
|
81
92
|
class SessionStore:
|
|
82
93
|
"""Every account the client knows about, keyed by cTID trader account ID.
|
|
@@ -90,6 +101,33 @@ class SessionStore:
|
|
|
90
101
|
def __init__(self) -> None:
|
|
91
102
|
self._accounts: dict[int, TrackedAccount] = {}
|
|
92
103
|
|
|
104
|
+
# Accounts whose authorization is being replaced by a token refresh.
|
|
105
|
+
# The server drops the old authorization as it issues the new pair, so
|
|
106
|
+
# for that stretch the account is genuinely unauthorized and answers
|
|
107
|
+
# nothing — a state no observer should mistake for a lost session.
|
|
108
|
+
self._refreshing: dict[int, anyio.Event] = {}
|
|
109
|
+
|
|
110
|
+
def begin_refresh(self, account_id: int) -> None:
|
|
111
|
+
"""Note that the account's authorization is being replaced."""
|
|
112
|
+
if account_id not in self._refreshing:
|
|
113
|
+
self._refreshing[account_id] = anyio.Event()
|
|
114
|
+
|
|
115
|
+
def end_refresh(self, account_id: int) -> None:
|
|
116
|
+
"""Note that it has been replaced, or that the attempt gave up."""
|
|
117
|
+
settled = self._refreshing.pop(account_id, None)
|
|
118
|
+
if settled is not None:
|
|
119
|
+
settled.set()
|
|
120
|
+
|
|
121
|
+
def is_refreshing(self, account_id: int) -> bool:
|
|
122
|
+
"""Whether a refresh is currently replacing the account's authorization."""
|
|
123
|
+
return account_id in self._refreshing
|
|
124
|
+
|
|
125
|
+
async def wait_for_refresh(self, account_id: int) -> None:
|
|
126
|
+
"""Wait until no refresh is replacing the account's authorization."""
|
|
127
|
+
settled = self._refreshing.get(account_id)
|
|
128
|
+
if settled is not None:
|
|
129
|
+
await settled.wait()
|
|
130
|
+
|
|
93
131
|
def account_ids(self) -> list[int]:
|
|
94
132
|
"""Every account held, oldest first."""
|
|
95
133
|
return list(self._accounts)
|
{ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/src/ctrader_api_client/auth/manager.py
RENAMED
|
@@ -15,6 +15,8 @@ from .._internal.proto import (
|
|
|
15
15
|
ProtoOAAccountAuthRes,
|
|
16
16
|
ProtoOAApplicationAuthReq,
|
|
17
17
|
ProtoOAApplicationAuthRes,
|
|
18
|
+
ProtoOATraderReq,
|
|
19
|
+
ProtoOATraderRes,
|
|
18
20
|
)
|
|
19
21
|
from ..enums import AuthTrigger
|
|
20
22
|
from ..events import EventPublisher, ReadyEvent
|
|
@@ -238,6 +240,43 @@ class AuthManager:
|
|
|
238
240
|
await self._restorer.restore(credentials.account_id)
|
|
239
241
|
await self._publisher.emit(ReadyEvent(account_id=credentials.account_id, trigger=trigger))
|
|
240
242
|
|
|
243
|
+
async def probe_session(self, account_id: int, timeout: float = 10.0) -> bool:
|
|
244
|
+
"""Whether the account still holds a live session on this link.
|
|
245
|
+
|
|
246
|
+
Asks the server for something only an authorized account is told. The
|
|
247
|
+
request carries no access token of its own, but the authorization behind
|
|
248
|
+
it does: while a rotation is in flight the server refuses everything for
|
|
249
|
+
the account, including this. That refusal names the token, and it is
|
|
250
|
+
raised rather than answered, because it says nothing about the session.
|
|
251
|
+
|
|
252
|
+
Args:
|
|
253
|
+
account_id: The cTID trader account ID.
|
|
254
|
+
timeout: Request timeout in seconds.
|
|
255
|
+
|
|
256
|
+
Returns:
|
|
257
|
+
True if the server still serves the account on this link.
|
|
258
|
+
|
|
259
|
+
Raises:
|
|
260
|
+
APIError: If the refusal is about the token rather than the account.
|
|
261
|
+
CTraderConnectionTimeoutError: If the request times out.
|
|
262
|
+
CTraderConnectionClosedError: If the link is gone.
|
|
263
|
+
"""
|
|
264
|
+
request = ProtoOATraderReq(ctid_trader_account_id=account_id)
|
|
265
|
+
|
|
266
|
+
try:
|
|
267
|
+
await self._protocol.request(request, ProtoOATraderRes, timeout=timeout)
|
|
268
|
+
except APIError as e:
|
|
269
|
+
logger.debug(
|
|
270
|
+
"Account %d was refused a trader lookup: %s (%s)",
|
|
271
|
+
account_id,
|
|
272
|
+
e.error_code,
|
|
273
|
+
e.description,
|
|
274
|
+
)
|
|
275
|
+
if e.is_token_failure():
|
|
276
|
+
raise
|
|
277
|
+
return False
|
|
278
|
+
return True
|
|
279
|
+
|
|
241
280
|
def remove_account(self, account_id: int) -> bool:
|
|
242
281
|
"""Remove an account from refresh monitoring.
|
|
243
282
|
|
{ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/src/ctrader_api_client/connection/protocol.py
RENAMED
|
@@ -109,6 +109,11 @@ class Protocol:
|
|
|
109
109
|
# reader loop and the heartbeat loop both detecting the same drop).
|
|
110
110
|
self._reconnecting = False
|
|
111
111
|
|
|
112
|
+
# Set when the link drops again while a reconnection is already in
|
|
113
|
+
# flight. The detector that saw it cannot start its own reconnection,
|
|
114
|
+
# so the one already running goes round again on its behalf.
|
|
115
|
+
self._redo_requested = False
|
|
116
|
+
|
|
112
117
|
# Told about link transitions; set by whoever owns this protocol.
|
|
113
118
|
self._listener: ConnectionListener | None = None
|
|
114
119
|
|
|
@@ -444,11 +449,17 @@ class Protocol:
|
|
|
444
449
|
standalone task decouples the reconnection from the cancel scope of
|
|
445
450
|
whichever loop detected the drop.
|
|
446
451
|
|
|
447
|
-
Idempotent: calls made while a reconnection is already in flight
|
|
448
|
-
|
|
449
|
-
drop only produce a single reconnection.
|
|
452
|
+
Idempotent: calls made while a reconnection is already in flight do not
|
|
453
|
+
start a second one, so the reader and heartbeat loops racing to report
|
|
454
|
+
the same drop only produce a single reconnection. Such a call is still
|
|
455
|
+
recorded, because it may be reporting a *fresh* drop of the link the
|
|
456
|
+
running reconnection just restored — and the caller cannot retry for
|
|
457
|
+
itself, since the reader loop exits as soon as it has reported.
|
|
450
458
|
"""
|
|
451
|
-
if not self._running
|
|
459
|
+
if not self._running:
|
|
460
|
+
return
|
|
461
|
+
if self._reconnecting:
|
|
462
|
+
self._redo_requested = True
|
|
452
463
|
return
|
|
453
464
|
if self._task_group is None:
|
|
454
465
|
return
|
|
@@ -457,16 +468,25 @@ class Protocol:
|
|
|
457
468
|
logger.warning("Connection lost, attempting to reconnect...")
|
|
458
469
|
|
|
459
470
|
# State tied to the dead link is invalid from this moment on. Report it
|
|
460
|
-
# before reconnecting so nothing observes a session that no longer
|
|
461
|
-
|
|
462
|
-
if self._listener is not None:
|
|
463
|
-
try:
|
|
464
|
-
await self._listener.on_connection_lost()
|
|
465
|
-
except Exception:
|
|
466
|
-
logger.exception("Connection listener failed on disconnect")
|
|
471
|
+
# before reconnecting so nothing observes a session that no longer exists.
|
|
472
|
+
await self._notify_connection_lost()
|
|
467
473
|
|
|
468
474
|
self._task_group.start_soon(self._reconnect_task)
|
|
469
475
|
|
|
476
|
+
async def _notify_connection_lost(self) -> None:
|
|
477
|
+
"""Tell the listener the link is gone.
|
|
478
|
+
|
|
479
|
+
Never lets a failing listener block the reconnection: whatever
|
|
480
|
+
bookkeeping it does matters less than getting the link back.
|
|
481
|
+
"""
|
|
482
|
+
if self._listener is None:
|
|
483
|
+
return
|
|
484
|
+
|
|
485
|
+
try:
|
|
486
|
+
await self._listener.on_connection_lost()
|
|
487
|
+
except Exception:
|
|
488
|
+
logger.exception("Connection listener failed on disconnect")
|
|
489
|
+
|
|
470
490
|
async def _reconnect_task(self) -> None:
|
|
471
491
|
"""Own the full reconnection lifecycle from a standalone task.
|
|
472
492
|
|
|
@@ -475,20 +495,44 @@ class Protocol:
|
|
|
475
495
|
escape: an unhandled error here would propagate into the protocol task
|
|
476
496
|
group and tear down every other task. A terminal reconnection failure is
|
|
477
497
|
instead recorded by stopping the protocol and waking pending requests.
|
|
498
|
+
|
|
499
|
+
Loops rather than running once, so a link that drops again while this is
|
|
500
|
+
working — reported through `handle_disconnect` as `_redo_requested`, the
|
|
501
|
+
only route left once the reader has exited — is reconnected too instead
|
|
502
|
+
of being stranded.
|
|
478
503
|
"""
|
|
479
504
|
try:
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
505
|
+
while True:
|
|
506
|
+
self._redo_requested = False
|
|
507
|
+
|
|
508
|
+
# The reader loop is reading a dead stream; cancel it before we
|
|
509
|
+
# reconnect so a stale reader can't race the fresh one we restart.
|
|
510
|
+
if self._reader_scope is not None:
|
|
511
|
+
self._reader_scope.cancel()
|
|
512
|
+
self._reader_scope = None
|
|
513
|
+
|
|
514
|
+
# Close the old transport (idempotent — the task that detected
|
|
515
|
+
# the drop may have already raced us to it). Tearing down a dead
|
|
516
|
+
# socket can fail on its own, which says nothing about whether
|
|
517
|
+
# reconnecting will work: swallow it here so it can never be
|
|
518
|
+
# mistaken below for an exhausted reconnection.
|
|
519
|
+
try:
|
|
520
|
+
await self._transport.close()
|
|
521
|
+
except Exception:
|
|
522
|
+
logger.debug("Ignoring error while closing dead transport", exc_info=True)
|
|
523
|
+
|
|
524
|
+
await self._reconnect()
|
|
525
|
+
logger.info("Reconnection successful")
|
|
526
|
+
|
|
527
|
+
if not self._redo_requested:
|
|
528
|
+
break
|
|
529
|
+
|
|
530
|
+
logger.warning("Connection lost again while reconnecting, retrying...")
|
|
531
|
+
# The sessions this pass just restored died with the link that
|
|
532
|
+
# dropped underneath it, so report that loss before going round
|
|
533
|
+
# again — nothing else will, since `handle_disconnect` only
|
|
534
|
+
# recorded the drop rather than handling it.
|
|
535
|
+
await self._notify_connection_lost()
|
|
492
536
|
except (CTraderConnectionFailedError, CTraderConnectionClosedError) as e:
|
|
493
537
|
logger.error("Reconnection failed, giving up: %s", e)
|
|
494
538
|
self._running = False
|
|
@@ -501,6 +545,7 @@ class Protocol:
|
|
|
501
545
|
self._fail_pending("Reconnection error")
|
|
502
546
|
finally:
|
|
503
547
|
self._reconnecting = False
|
|
548
|
+
self._redo_requested = False
|
|
504
549
|
|
|
505
550
|
def _fail_pending(self, reason: str) -> None:
|
|
506
551
|
"""Resolve every in-flight request with a connection error."""
|
{ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/src/ctrader_api_client/connection/transport.py
RENAMED
|
@@ -104,7 +104,7 @@ class Transport:
|
|
|
104
104
|
await stream.aclose()
|
|
105
105
|
if close_scope.cancelled_caught:
|
|
106
106
|
logger.debug("Graceful TLS shutdown timed out, forcing close")
|
|
107
|
-
except (OSError, anyio.ClosedResourceError):
|
|
107
|
+
except (OSError, anyio.ClosedResourceError, anyio.BrokenResourceError):
|
|
108
108
|
pass
|
|
109
109
|
|
|
110
110
|
async def send(self, data: bytes) -> None:
|
{ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/src/ctrader_api_client/events/types.py
RENAMED
|
@@ -324,8 +324,14 @@ class ReconnectedEvent:
|
|
|
324
324
|
Use this event for any custom logic that depends on reconnection, such as logging or alerting.
|
|
325
325
|
|
|
326
326
|
Attributes:
|
|
327
|
-
app_auth_restored: Whether app authentication succeeded
|
|
327
|
+
app_auth_restored: Whether app authentication succeeded on the attempt
|
|
328
|
+
this event reports. False is not final: the client keeps retrying
|
|
329
|
+
with backoff, and announces each account it restores afterwards with
|
|
330
|
+
a ReadyEvent. Check `client.auth.is_app_authenticated` for the
|
|
331
|
+
state now.
|
|
328
332
|
restored_accounts: Account IDs that were successfully re-authenticated.
|
|
333
|
+
Empty when app authentication failed, since nothing can be
|
|
334
|
+
authorized before it succeeds — not a report that they were lost.
|
|
329
335
|
failed_accounts: Accounts that failed, as (account_id, error_message) tuples.
|
|
330
336
|
"""
|
|
331
337
|
|