py-ib-controller 0.0.0__py3-none-any.whl
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.
- ibcontroller/__init__.py +0 -0
- ibcontroller/actions.py +273 -0
- ibcontroller/agent_client.py +466 -0
- ibcontroller/app_dirs.py +44 -0
- ibcontroller/cli.py +211 -0
- ibcontroller/config.py +410 -0
- ibcontroller/control_loop.py +636 -0
- ibcontroller/data/builtin_settings.toml +37 -0
- ibcontroller/data/ibcontroller.toml.example +178 -0
- ibcontroller/data/ibkr_settings.toml.example +135 -0
- ibcontroller/data/labels.json +81 -0
- ibcontroller/data/labels.json.example +44 -0
- ibcontroller/diagnostics.py +119 -0
- ibcontroller/dispatch.py +288 -0
- ibcontroller/ibcontroller-agent.jar +0 -0
- ibcontroller/labels.py +242 -0
- ibcontroller/launcher.py +909 -0
- ibcontroller/logging_setup.py +277 -0
- ibcontroller/login.py +578 -0
- ibcontroller/main.py +156 -0
- ibcontroller/py.typed +0 -0
- ibcontroller/recognisers.py +448 -0
- ibcontroller/schedule.py +157 -0
- ibcontroller/settings.py +554 -0
- py_ib_controller-0.0.0.dist-info/METADATA +490 -0
- py_ib_controller-0.0.0.dist-info/RECORD +30 -0
- py_ib_controller-0.0.0.dist-info/WHEEL +5 -0
- py_ib_controller-0.0.0.dist-info/entry_points.txt +2 -0
- py_ib_controller-0.0.0.dist-info/licenses/LICENSE.txt +176 -0
- py_ib_controller-0.0.0.dist-info/top_level.txt +1 -0
ibcontroller/login.py
ADDED
|
@@ -0,0 +1,578 @@
|
|
|
1
|
+
"""The Login domain's own state machine. `LoginState` has six states:
|
|
2
|
+
`LOGGED_OUT`, `AWAITING_CREDENTIALS`, `LOGGING_IN`, `MFA_IN_PROGRESS`,
|
|
3
|
+
`LOGGED_IN`, `LOGIN_FAILED`.
|
|
4
|
+
|
|
5
|
+
**Distinguishing the login frame from the main window, given both can share
|
|
6
|
+
the same title.** This module records the login frame's own window *class*
|
|
7
|
+
the moment it's first recognised, and treats a later window sharing the same
|
|
8
|
+
title but a *different* class as the main window -- a relative comparison,
|
|
9
|
+
not a hardcoded class string, since title alone can't tell the two apart.
|
|
10
|
+
|
|
11
|
+
**Restart check, before anything else.** If Gateway/TWS's own `autorestart`
|
|
12
|
+
marker file is present in its settings directory, this module skips straight
|
|
13
|
+
to waiting for the outcome instead of filling credentials, matching an
|
|
14
|
+
automatic relogin already in progress.
|
|
15
|
+
|
|
16
|
+
**Existing-session/login-failed/non-brokerage dialogs are `recognisers.py`'s
|
|
17
|
+
job, not this module's** -- `LoginManager` never dispatches to that registry
|
|
18
|
+
itself. Its own `wait_for_event` calls are narrowed to only the two outcomes
|
|
19
|
+
it actually cares about (MFA, the main window); anything else is left
|
|
20
|
+
entirely to the separate, always-on `recognisers.watch_for_unprompted_windows`
|
|
21
|
+
background task, so a real dialog occurrence is never handled twice at once.
|
|
22
|
+
|
|
23
|
+
**Two distinct timeout/retry mechanisms, not one:**
|
|
24
|
+
|
|
25
|
+
1. An MFA-timeout watchdog, internal to this module (`_after_mfa_closed_*`):
|
|
26
|
+
measures elapsed time since login started against
|
|
27
|
+
`mfa_timeout` (IB's own budget for completing
|
|
28
|
+
2FA). If 2FA closed within that budget and `relogin_after_mfa_timeout` is
|
|
29
|
+
enabled, arms a second, shorter watchdog
|
|
30
|
+
(`mfa_exit_interval`) and raises `LoginError` if
|
|
31
|
+
login still hasn't completed by then -- there's no restart primitive at
|
|
32
|
+
this layer yet. If 2FA's own timeout expired while its dialog was still
|
|
33
|
+
open, retries the whole login after a fixed `_IBC_RELOGIN_DELAY_SECONDS`.
|
|
34
|
+
2. `recognisers.TooManyFailedLoginAttemptsRecognizer`, a real server-side
|
|
35
|
+
rate-limit dialog, unrelated to the MFA timing above. Its `handle()`
|
|
36
|
+
calls `LoginManager.schedule_retry`, which re-fills credentials, resubmits,
|
|
37
|
+
and waits for the outcome independently -- it doesn't assume the original
|
|
38
|
+
`run()` call is still alive to notice the result.
|
|
39
|
+
|
|
40
|
+
**Gateway and TWS need different `LOGGED_IN` signals.** Gateway's main
|
|
41
|
+
window already exists, with an enabled menu, before login completes -- so
|
|
42
|
+
"main window opened" can't be Gateway's completion signal the way it is for
|
|
43
|
+
TWS. `_wait_for_outcome` dispatches on `config.program`:
|
|
44
|
+
`_wait_for_outcome_tws` waits for a newly opened window to carry a
|
|
45
|
+
`File > Lock Application` menu item; `_wait_for_outcome_gateway` waits for
|
|
46
|
+
the splash/"Authenticating..." window's own `WINDOW_CLOSED` event instead,
|
|
47
|
+
the one signal that's correct in both trading modes without needing to know
|
|
48
|
+
which one it is.
|
|
49
|
+
|
|
50
|
+
**Not live-validated against real IBKR servers, unlike everything else in
|
|
51
|
+
this file.** Triggering IB's real 180-second 2FA timeout, or "too many
|
|
52
|
+
failed login attempts", both carry real account-level risk for no safe
|
|
53
|
+
benefit -- built and unit-tested against synthetic timing/dialogs instead.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
from __future__ import annotations
|
|
57
|
+
|
|
58
|
+
import asyncio
|
|
59
|
+
import contextlib
|
|
60
|
+
import logging
|
|
61
|
+
import time
|
|
62
|
+
from enum import Enum, auto
|
|
63
|
+
from pathlib import Path
|
|
64
|
+
|
|
65
|
+
from anyio import Path as AsyncPath
|
|
66
|
+
|
|
67
|
+
from ibcontroller.actions import (
|
|
68
|
+
click,
|
|
69
|
+
menu_item_exists,
|
|
70
|
+
type_text,
|
|
71
|
+
wait_for_event,
|
|
72
|
+
)
|
|
73
|
+
from ibcontroller.agent_client import WindowEvent
|
|
74
|
+
from ibcontroller.config import Config, TradingMode
|
|
75
|
+
from ibcontroller.dispatch import Dispatcher
|
|
76
|
+
from ibcontroller.labels import Labels
|
|
77
|
+
|
|
78
|
+
logger = logging.getLogger(__name__)
|
|
79
|
+
|
|
80
|
+
# IBC's own hardcoded constant (LoginManager.java's
|
|
81
|
+
# secondFactorAuthenticationDialogClosed), not a config value -- kept literal
|
|
82
|
+
# here for the same reason IBC never exposed it as one.
|
|
83
|
+
_IBC_RELOGIN_DELAY_SECONDS = 5.0
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class LoginState(Enum):
|
|
87
|
+
LOGGED_OUT = auto()
|
|
88
|
+
AWAITING_CREDENTIALS = auto()
|
|
89
|
+
LOGGING_IN = auto()
|
|
90
|
+
MFA_IN_PROGRESS = auto()
|
|
91
|
+
LOGGED_IN = auto()
|
|
92
|
+
LOGIN_FAILED = auto()
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class LoginError(Exception):
|
|
96
|
+
"""The login frame itself was never recognised within the timeout, or some
|
|
97
|
+
other condition this module can't recover from on its own."""
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
async def find_autorestart_hash(settings_dir: str | Path) -> str | None:
|
|
101
|
+
"""Locates Gateway/TWS's own `autorestart` marker file under
|
|
102
|
+
`settings_dir` and returns the account-hash subdirectory name it lives
|
|
103
|
+
in (`<settings_dir>/<hash>/autorestart`) -- the value passed on the JVM
|
|
104
|
+
command line as `-Drestart=<hash>` on relaunch. Returns `None` if zero
|
|
105
|
+
or more than one marker file is found: with more than one, there's no
|
|
106
|
+
way to tell which is the right one, so both are left in place (not
|
|
107
|
+
deleted) and the caller falls back to a full credential fill.
|
|
108
|
+
|
|
109
|
+
The recursive scan runs off the event loop (`anyio.Path`, one thread
|
|
110
|
+
hop per call) -- called repeatedly in a tight retry loop
|
|
111
|
+
(`control_loop._is_restart_with_grace`) concurrent with the Dispatcher's
|
|
112
|
+
own event/command tasks, which a blocking `pathlib` scan would stall."""
|
|
113
|
+
found = [p async for p in AsyncPath(settings_dir).rglob("autorestart")]
|
|
114
|
+
if len(found) != 1:
|
|
115
|
+
return None
|
|
116
|
+
return found[0].parent.name
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
async def is_restart(settings_dir: str | Path) -> bool:
|
|
120
|
+
"""Returns whether exactly one `autorestart` marker file exists under
|
|
121
|
+
`settings_dir` (via `find_autorestart_hash`)."""
|
|
122
|
+
return await find_autorestart_hash(settings_dir) is not None
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class LoginManager:
|
|
126
|
+
"""One instance per running agent -- this module's own state, not shared
|
|
127
|
+
across instances, matching every other per-instance object in this
|
|
128
|
+
project (`Dispatcher`, the recogniser registry)."""
|
|
129
|
+
|
|
130
|
+
def __init__(
|
|
131
|
+
self,
|
|
132
|
+
config: Config,
|
|
133
|
+
labels: Labels,
|
|
134
|
+
dispatcher: Dispatcher,
|
|
135
|
+
*,
|
|
136
|
+
settings_dir: str | Path,
|
|
137
|
+
restart_expected: bool | None = None,
|
|
138
|
+
) -> None:
|
|
139
|
+
"""`config`/`labels`/`dispatcher`: this instance's configuration,
|
|
140
|
+
labels, and `Dispatcher`. `settings_dir`: the Gateway/TWS settings
|
|
141
|
+
directory, checked for the autorestart marker. `restart_expected`:
|
|
142
|
+
`None` (default) checks `is_restart(settings_dir)` directly at
|
|
143
|
+
`run()` time; `True`/`False` overrides that check -- for callers
|
|
144
|
+
(`control_loop.py`) that already know the answer from having
|
|
145
|
+
computed their own restart hash moments earlier, avoiding a race
|
|
146
|
+
against Gateway's own consumption of the marker file."""
|
|
147
|
+
self._config = config
|
|
148
|
+
self._labels = labels
|
|
149
|
+
self._dispatcher = dispatcher
|
|
150
|
+
self._settings_dir = settings_dir
|
|
151
|
+
self._restart_expected = restart_expected
|
|
152
|
+
self.state = LoginState.LOGGED_OUT
|
|
153
|
+
self._login_frame_class: str | None = None
|
|
154
|
+
self._login_frame_window_id: str | None = None
|
|
155
|
+
self._login_start_time: float | None = None
|
|
156
|
+
self._retry_task: asyncio.Task[None] | None = None
|
|
157
|
+
self.main_window_id: str | None = None
|
|
158
|
+
|
|
159
|
+
def is_logged_in(self) -> bool:
|
|
160
|
+
"""Returns whether `state` is `LOGGED_IN`. Supplied to
|
|
161
|
+
`ExistingSessionRecognizer` (`recognisers.py`) as its
|
|
162
|
+
`is_logged_in` callable -- that recogniser reacts to login state, it
|
|
163
|
+
doesn't own it."""
|
|
164
|
+
return self.state is LoginState.LOGGED_IN
|
|
165
|
+
|
|
166
|
+
def _gateway_or_tws_titles(self) -> list[str]:
|
|
167
|
+
return (
|
|
168
|
+
self._labels.login.gateway_titles
|
|
169
|
+
if self._config.program.lower() == "gateway"
|
|
170
|
+
else self._labels.login.tws_titles
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
async def run(
|
|
174
|
+
self,
|
|
175
|
+
*,
|
|
176
|
+
login_timeout: float | None = None,
|
|
177
|
+
outcome_timeout: float | None = None,
|
|
178
|
+
) -> None:
|
|
179
|
+
"""Drives the whole Login domain sequence, end to end: waits for the
|
|
180
|
+
login frame, skips credential fill if a restart is already in
|
|
181
|
+
progress, otherwise fills and submits credentials, then waits for
|
|
182
|
+
the outcome. Raises `LoginError`/`recognisers.LoginFailedError`/
|
|
183
|
+
`TimeoutError` on failure; returns normally once `state` is
|
|
184
|
+
`LOGGED_IN`. `login_timeout`/`outcome_timeout` default from
|
|
185
|
+
`Config.login_dialog_display_timeout`/
|
|
186
|
+
`mfa_timeout` when not given explicitly."""
|
|
187
|
+
login_timeout = (
|
|
188
|
+
login_timeout
|
|
189
|
+
if login_timeout is not None
|
|
190
|
+
else self._config.login_dialog_display_timeout
|
|
191
|
+
)
|
|
192
|
+
outcome_timeout = (
|
|
193
|
+
outcome_timeout if outcome_timeout is not None else self._config.mfa_timeout
|
|
194
|
+
)
|
|
195
|
+
titles = self._gateway_or_tws_titles()
|
|
196
|
+
try:
|
|
197
|
+
login_event = await wait_for_event(
|
|
198
|
+
self._dispatcher,
|
|
199
|
+
"window_opened",
|
|
200
|
+
lambda e: e.window.title in titles,
|
|
201
|
+
timeout=login_timeout,
|
|
202
|
+
)
|
|
203
|
+
except TimeoutError as exc:
|
|
204
|
+
raise LoginError(
|
|
205
|
+
f"login frame never appeared within {login_timeout}s "
|
|
206
|
+
f"(expected one of {titles})"
|
|
207
|
+
) from exc
|
|
208
|
+
self._login_frame_class = login_event.window.class_
|
|
209
|
+
self._login_frame_window_id = login_event.window.window_id
|
|
210
|
+
|
|
211
|
+
if self._restart_expected is not None:
|
|
212
|
+
restart = self._restart_expected
|
|
213
|
+
reason = "restart_expected flag"
|
|
214
|
+
else:
|
|
215
|
+
restart = await is_restart(self._settings_dir)
|
|
216
|
+
reason = "autorestart marker file"
|
|
217
|
+
if restart:
|
|
218
|
+
logger.info("IBController > %s -- skipping credential fill", reason)
|
|
219
|
+
self.state = LoginState.LOGGING_IN
|
|
220
|
+
self._login_start_time = time.monotonic()
|
|
221
|
+
await self._wait_for_outcome(outcome_timeout)
|
|
222
|
+
return
|
|
223
|
+
|
|
224
|
+
await self._fill_credentials_and_submit()
|
|
225
|
+
await self._wait_for_outcome(outcome_timeout)
|
|
226
|
+
|
|
227
|
+
async def _fill_credentials(self) -> None:
|
|
228
|
+
"""Fills the trading-mode/username/password fields on the login
|
|
229
|
+
frame, scoped to `self._login_frame_window_id` (the login frame's
|
|
230
|
+
FIX Login and IB API sections both carry Username/Password pairs
|
|
231
|
+
with identical accessible names, so an unscoped search would be
|
|
232
|
+
ambiguous). On Gateway, first clicks the API-type toggle to select
|
|
233
|
+
IB API -- TWS has no such control, so that click is skipped there."""
|
|
234
|
+
labels = self._labels.login
|
|
235
|
+
window_id = self._login_frame_window_id
|
|
236
|
+
if self._config.program.lower() == "gateway":
|
|
237
|
+
await click(
|
|
238
|
+
self._dispatcher,
|
|
239
|
+
labels.api_type_ib_api,
|
|
240
|
+
window_id=window_id,
|
|
241
|
+
)
|
|
242
|
+
trading_mode_label = (
|
|
243
|
+
labels.trading_mode_live
|
|
244
|
+
if self._config.trading_mode is TradingMode.LIVE
|
|
245
|
+
else labels.trading_mode_paper
|
|
246
|
+
)
|
|
247
|
+
await click(self._dispatcher, trading_mode_label, window_id=window_id)
|
|
248
|
+
await type_text(
|
|
249
|
+
self._dispatcher,
|
|
250
|
+
labels.username_field,
|
|
251
|
+
self._config.userid,
|
|
252
|
+
window_id=window_id,
|
|
253
|
+
)
|
|
254
|
+
await type_text(
|
|
255
|
+
self._dispatcher,
|
|
256
|
+
labels.password_field,
|
|
257
|
+
self._config.password,
|
|
258
|
+
window_id=window_id,
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
async def _fill_credentials_and_submit(self) -> None:
|
|
262
|
+
"""Fills credentials, then clicks the login button. Factored out of
|
|
263
|
+
`run()` so a retry can redo just this part on an already-open login
|
|
264
|
+
frame, without re-waiting for a fresh login-frame `window_opened`
|
|
265
|
+
event that will never re-fire for a frame that's already open."""
|
|
266
|
+
self.state = LoginState.AWAITING_CREDENTIALS
|
|
267
|
+
await self._fill_credentials()
|
|
268
|
+
self.state = LoginState.LOGGING_IN
|
|
269
|
+
self._login_start_time = time.monotonic()
|
|
270
|
+
await click(
|
|
271
|
+
self._dispatcher,
|
|
272
|
+
self._labels.login.login_buttons,
|
|
273
|
+
window_id=self._login_frame_window_id,
|
|
274
|
+
)
|
|
275
|
+
logger.info("IBController > credentials submitted, awaiting outcome")
|
|
276
|
+
|
|
277
|
+
async def retry_login(
|
|
278
|
+
self, wait_seconds: float, *, outcome_timeout: float | None = None
|
|
279
|
+
) -> None:
|
|
280
|
+
"""Waits `wait_seconds`, re-submits credentials, then independently
|
|
281
|
+
waits for the outcome -- doesn't assume whichever `run()` call
|
|
282
|
+
originally started this session is still alive to notice the
|
|
283
|
+
result. Redundant but harmless if the original wait is still alive
|
|
284
|
+
too -- both independently converge on the same `LOGGED_IN` state."""
|
|
285
|
+
await asyncio.sleep(wait_seconds)
|
|
286
|
+
await self._fill_credentials_and_submit()
|
|
287
|
+
await self._wait_for_outcome(
|
|
288
|
+
outcome_timeout if outcome_timeout is not None else self._config.mfa_timeout
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
def schedule_retry(self, wait_seconds: float) -> None:
|
|
292
|
+
"""Synchronous callback given to `TooManyFailedLoginAttemptsRecognizer`
|
|
293
|
+
(`recognisers.py`): fires `retry_login` as a background task after
|
|
294
|
+
`wait_seconds`, so the caller (running inside
|
|
295
|
+
`watch_for_unprompted_windows`'s own loop) never blocks on the
|
|
296
|
+
cooldown itself. Keeps a reference on `self` so the task isn't
|
|
297
|
+
garbage-collected mid-flight."""
|
|
298
|
+
self._retry_task = asyncio.ensure_future(self.retry_login(wait_seconds))
|
|
299
|
+
|
|
300
|
+
async def _wait_for_outcome(self, outcome_timeout: float) -> None:
|
|
301
|
+
"""Waits for the login outcome, dispatching on `config.program` --
|
|
302
|
+
Gateway and TWS need different completion signals (see the module
|
|
303
|
+
docstring). Every nested wait bounded by `outcome_timeout` itself
|
|
304
|
+
(as opposed to `mfa_exit_interval`, already
|
|
305
|
+
wrapped where it's used) converges here, so a raw `TimeoutError` is
|
|
306
|
+
caught once, in this one place, and turned into `LoginError` --
|
|
307
|
+
matching every other timeout in this module and keeping
|
|
308
|
+
`cli.py`'s clean operational-error reporting intact."""
|
|
309
|
+
try:
|
|
310
|
+
if self._config.program.lower() == "gateway":
|
|
311
|
+
await self._wait_for_outcome_gateway(outcome_timeout)
|
|
312
|
+
else:
|
|
313
|
+
await self._wait_for_outcome_tws(outcome_timeout)
|
|
314
|
+
except TimeoutError as exc:
|
|
315
|
+
raise LoginError(
|
|
316
|
+
f"login did not complete within {outcome_timeout}s of "
|
|
317
|
+
"credentials being submitted "
|
|
318
|
+
"(mfa_timeout)"
|
|
319
|
+
) from exc
|
|
320
|
+
|
|
321
|
+
async def _wait_for_outcome_tws(self, timeout: float | None = None) -> None:
|
|
322
|
+
"""Loops over `window_opened` events (other than the login frame's
|
|
323
|
+
own class) until one carries a `File > Lock Application` menu item
|
|
324
|
+
(the main window, checked via `menu_item_exists`) or is the MFA
|
|
325
|
+
dialog. On MFA, waits for it to close, then delegates to
|
|
326
|
+
`_after_mfa_closed_tws`.
|
|
327
|
+
|
|
328
|
+
`timeout` defaults to `None` (unbounded) only as a safety net for a
|
|
329
|
+
direct call with no argument -- every real call site passes an
|
|
330
|
+
explicit bound: `_wait_for_outcome` passes `outcome_timeout`,
|
|
331
|
+
`_after_mfa_closed_tws` passes either that same budget (its
|
|
332
|
+
unconditional "keep waiting" fallbacks) or its own shorter
|
|
333
|
+
`mfa_exit_interval` watchdog, tracked as
|
|
334
|
+
one deadline across every loop iteration -- including the MFA-closed
|
|
335
|
+
wait, which uses `deadline`'s own remaining time rather than a fresh
|
|
336
|
+
`timeout`, matching IBC's `LoginManager.secondFactorAuthenticationDialogClosed`
|
|
337
|
+
(elapsed measured from one fixed login-start anchor, never re-armed
|
|
338
|
+
per stage).
|
|
339
|
+
|
|
340
|
+
Deliberately does *not* fall back to the shared recogniser registry
|
|
341
|
+
for anything else it sees (existing-session, login-failed,
|
|
342
|
+
non-brokerage, a stray pop-up, an intermediate window like
|
|
343
|
+
"Downloading settings from server") -- those are left entirely to
|
|
344
|
+
the separate, always-on `recognisers.watch_for_unprompted_windows`
|
|
345
|
+
background task."""
|
|
346
|
+
mfa_title = self._labels.mfa.title
|
|
347
|
+
deadline = None if timeout is None else time.monotonic() + timeout
|
|
348
|
+
|
|
349
|
+
while True:
|
|
350
|
+
remaining = (
|
|
351
|
+
None if deadline is None else max(0.0, deadline - time.monotonic())
|
|
352
|
+
)
|
|
353
|
+
event = await wait_for_event(
|
|
354
|
+
self._dispatcher,
|
|
355
|
+
"window_opened",
|
|
356
|
+
lambda e: e.window.class_ != self._login_frame_class,
|
|
357
|
+
timeout=remaining,
|
|
358
|
+
)
|
|
359
|
+
if event.window.title == mfa_title:
|
|
360
|
+
self.state = LoginState.MFA_IN_PROGRESS
|
|
361
|
+
logger.info("IBController > second factor authentication in progress")
|
|
362
|
+
await wait_for_event(
|
|
363
|
+
self._dispatcher,
|
|
364
|
+
"window_closed",
|
|
365
|
+
lambda e: e.window.title == mfa_title,
|
|
366
|
+
timeout=(
|
|
367
|
+
None
|
|
368
|
+
if deadline is None
|
|
369
|
+
else max(0.0, deadline - time.monotonic())
|
|
370
|
+
),
|
|
371
|
+
)
|
|
372
|
+
await self._after_mfa_closed_tws()
|
|
373
|
+
return
|
|
374
|
+
|
|
375
|
+
if event.window.window_id is None:
|
|
376
|
+
# No window_id to scope menu_item_exists to (real
|
|
377
|
+
# window_opened events always carry one -- confirmed live --
|
|
378
|
+
# but WindowInfo's own type doesn't guarantee it); can't be
|
|
379
|
+
# the main window without one, keep looping.
|
|
380
|
+
continue
|
|
381
|
+
|
|
382
|
+
exists, _enabled = await menu_item_exists(
|
|
383
|
+
self._dispatcher,
|
|
384
|
+
self._labels.login.tws_main_window_menu_item,
|
|
385
|
+
window_id=event.window.window_id,
|
|
386
|
+
)
|
|
387
|
+
if exists:
|
|
388
|
+
self.state = LoginState.LOGGED_IN
|
|
389
|
+
# Keep the window_id this same check just validated (#40) --
|
|
390
|
+
# Gateway's path below never sets this (no ambiguity there,
|
|
391
|
+
# see _wait_for_outcome_gateway's docstring), so consumers
|
|
392
|
+
# must treat None as "no scoping available, fall back".
|
|
393
|
+
self.main_window_id = event.window.window_id
|
|
394
|
+
logger.info("IBController > login completed")
|
|
395
|
+
return
|
|
396
|
+
# Not the main window (an intermediate dialog, e.g. "Downloading
|
|
397
|
+
# settings from server") -- keep looping for the next candidate.
|
|
398
|
+
|
|
399
|
+
async def _after_mfa_closed_tws(self) -> None:
|
|
400
|
+
"""Measures elapsed time since login started against
|
|
401
|
+
`mfa_timeout` (IB's own real limit for
|
|
402
|
+
completing 2FA, default 180s).
|
|
403
|
+
|
|
404
|
+
If 2FA closed within that budget: waits for the outcome again, bounded
|
|
405
|
+
by that same `mfa_timeout` budget (an
|
|
406
|
+
unconditional wait if `relogin_after_mfa_timeout` is off), or arms a
|
|
407
|
+
shorter `mfa_exit_interval` watchdog and
|
|
408
|
+
raises `LoginError` if login still hasn't completed by then -- there
|
|
409
|
+
is no restart primitive at this layer yet.
|
|
410
|
+
|
|
411
|
+
If 2FA's own timeout already expired while its dialog was still
|
|
412
|
+
open: retries the whole login after `_IBC_RELOGIN_DELAY_SECONDS`, or
|
|
413
|
+
keeps waiting for the outcome (same bounded budget) if retry is
|
|
414
|
+
disabled."""
|
|
415
|
+
elapsed = time.monotonic() - (self._login_start_time or time.monotonic())
|
|
416
|
+
|
|
417
|
+
if elapsed < self._config.mfa_timeout:
|
|
418
|
+
# 2FA was handled within IB's own budget -- authentication should
|
|
419
|
+
# be under way.
|
|
420
|
+
if not self._config.relogin_after_mfa_timeout:
|
|
421
|
+
await self._wait_for_outcome_tws(self._config.mfa_timeout)
|
|
422
|
+
return
|
|
423
|
+
# A second, shorter watchdog: if login still hasn't completed by
|
|
424
|
+
# now, we have no restart primitive at this layer (Management/L1,
|
|
425
|
+
# not built), so this raises a clear signal instead.
|
|
426
|
+
try:
|
|
427
|
+
await self._wait_for_outcome_tws(self._config.mfa_exit_interval)
|
|
428
|
+
except TimeoutError as exc:
|
|
429
|
+
raise LoginError(
|
|
430
|
+
"login did not complete within "
|
|
431
|
+
f"{self._config.mfa_exit_interval}s "
|
|
432
|
+
"of second factor authentication completing (IBC's own "
|
|
433
|
+
"SecondFactorAuthenticationExitInterval watchdog)"
|
|
434
|
+
) from exc
|
|
435
|
+
return
|
|
436
|
+
|
|
437
|
+
# 2FA's own IB-side timeout expired while the dialog was still open --
|
|
438
|
+
# the user answered too slowly.
|
|
439
|
+
if not self._config.relogin_after_mfa_timeout:
|
|
440
|
+
logger.info(
|
|
441
|
+
"IBController > re-login after second factor authentication timeout not"
|
|
442
|
+
" required"
|
|
443
|
+
)
|
|
444
|
+
# Our own run() is a bounded coroutine the caller awaits for a
|
|
445
|
+
# definitive outcome (returns normally once state is LOGGED_IN)
|
|
446
|
+
# -- returning here instead would complete run() while state
|
|
447
|
+
# stays stuck at MFA_IN_PROGRESS, so keep waiting for the
|
|
448
|
+
# outcome, same as the "on-time, relogin disabled" branch above.
|
|
449
|
+
await self._wait_for_outcome_tws(self._config.mfa_timeout)
|
|
450
|
+
return
|
|
451
|
+
logger.info(
|
|
452
|
+
"IBController > re-login after second factor authentication timeout in %ss",
|
|
453
|
+
_IBC_RELOGIN_DELAY_SECONDS,
|
|
454
|
+
)
|
|
455
|
+
await self.retry_login(_IBC_RELOGIN_DELAY_SECONDS)
|
|
456
|
+
|
|
457
|
+
async def _wait_for_outcome_gateway(self, outcome_timeout: float) -> None:
|
|
458
|
+
"""Waits for the splash/"Authenticating..." window's own
|
|
459
|
+
`WINDOW_CLOSED` event (matched by its final title,
|
|
460
|
+
"Starting application...") -- Gateway's real login-completion
|
|
461
|
+
signal, since its main window already exists, with an enabled menu,
|
|
462
|
+
before login finishes, so "main window opened" can't be used here.
|
|
463
|
+
|
|
464
|
+
An MFA dialog opening/closing along the way only updates `state` and
|
|
465
|
+
arms `_after_mfa_closed_gateway`'s watchdog -- unlike the TWS path,
|
|
466
|
+
it is never itself a terminal signal. Runs two concurrent
|
|
467
|
+
`wait_for_event` waits (splash-closed, MFA-opened), since they're
|
|
468
|
+
different event kinds (`window_closed` vs `window_opened`); both are
|
|
469
|
+
always cancelled in `finally` before this method returns, whichever
|
|
470
|
+
path was actually taken.
|
|
471
|
+
|
|
472
|
+
`deadline`, tracked once from entry, matches `_wait_for_outcome_tws`'s
|
|
473
|
+
own `deadline`/`remaining` pattern: the MFA-closed wait below uses
|
|
474
|
+
what's left of `outcome_timeout`, not a fresh copy of it -- MFA
|
|
475
|
+
opening late in the budget must not grant a second full budget's
|
|
476
|
+
worth of extra waiting."""
|
|
477
|
+
splash_title = self._labels.login.starting_application_title
|
|
478
|
+
mfa_title = self._labels.mfa.title
|
|
479
|
+
deadline = time.monotonic() + outcome_timeout
|
|
480
|
+
|
|
481
|
+
splash_closed = asyncio.ensure_future(
|
|
482
|
+
wait_for_event(
|
|
483
|
+
self._dispatcher,
|
|
484
|
+
"window_closed",
|
|
485
|
+
lambda e: e.window.title == splash_title,
|
|
486
|
+
timeout=outcome_timeout,
|
|
487
|
+
)
|
|
488
|
+
)
|
|
489
|
+
mfa_opened = asyncio.ensure_future(
|
|
490
|
+
wait_for_event(
|
|
491
|
+
self._dispatcher,
|
|
492
|
+
"window_opened",
|
|
493
|
+
lambda e: e.window.title == mfa_title,
|
|
494
|
+
timeout=outcome_timeout,
|
|
495
|
+
)
|
|
496
|
+
)
|
|
497
|
+
pending: set[asyncio.Task[WindowEvent]] = {splash_closed, mfa_opened}
|
|
498
|
+
try:
|
|
499
|
+
while splash_closed in pending:
|
|
500
|
+
done, pending = await asyncio.wait(
|
|
501
|
+
pending, return_when=asyncio.FIRST_COMPLETED
|
|
502
|
+
)
|
|
503
|
+
if mfa_opened not in done:
|
|
504
|
+
continue
|
|
505
|
+
mfa_exc = mfa_opened.exception()
|
|
506
|
+
if mfa_exc is not None:
|
|
507
|
+
if not isinstance(mfa_exc, TimeoutError):
|
|
508
|
+
raise mfa_exc
|
|
509
|
+
# MFA simply never appeared within outcome_timeout (the
|
|
510
|
+
# normal paper case, and any Gateway login that doesn't
|
|
511
|
+
# need it) -- keep waiting on splash_closed alone.
|
|
512
|
+
continue
|
|
513
|
+
self.state = LoginState.MFA_IN_PROGRESS
|
|
514
|
+
logger.info("IBController > second factor authentication in progress")
|
|
515
|
+
await wait_for_event(
|
|
516
|
+
self._dispatcher,
|
|
517
|
+
"window_closed",
|
|
518
|
+
lambda e: e.window.title == mfa_title,
|
|
519
|
+
timeout=max(0.0, deadline - time.monotonic()),
|
|
520
|
+
)
|
|
521
|
+
await self._after_mfa_closed_gateway(splash_closed, outcome_timeout)
|
|
522
|
+
await splash_closed
|
|
523
|
+
finally:
|
|
524
|
+
for task in (mfa_opened, splash_closed):
|
|
525
|
+
if not task.done():
|
|
526
|
+
task.cancel()
|
|
527
|
+
for task in (mfa_opened, splash_closed):
|
|
528
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
529
|
+
await task
|
|
530
|
+
|
|
531
|
+
self.state = LoginState.LOGGED_IN
|
|
532
|
+
logger.info("IBController > login completed")
|
|
533
|
+
|
|
534
|
+
async def _after_mfa_closed_gateway(
|
|
535
|
+
self, splash_closed: asyncio.Task[WindowEvent], outcome_timeout: float
|
|
536
|
+
) -> None:
|
|
537
|
+
"""Gateway variant of `_after_mfa_closed_tws` -- never recurses into
|
|
538
|
+
a fresh wait: `_wait_for_outcome_gateway`'s own `splash_closed`
|
|
539
|
+
future is already pending and will resolve on its own once Gateway
|
|
540
|
+
actually finishes authenticating. Only arms the shorter
|
|
541
|
+
exit-interval watchdog (raising `LoginError` on timeout, if
|
|
542
|
+
`relogin_after_mfa_timeout` is enabled) or schedules a retry.
|
|
543
|
+
`asyncio.shield` on the watchdog wait is deliberate: a timeout there
|
|
544
|
+
must not cancel `splash_closed` itself, since the caller's own loop
|
|
545
|
+
still owns and awaits that future regardless of what this method
|
|
546
|
+
does."""
|
|
547
|
+
elapsed = time.monotonic() - (self._login_start_time or time.monotonic())
|
|
548
|
+
|
|
549
|
+
if elapsed < self._config.mfa_timeout:
|
|
550
|
+
if not self._config.relogin_after_mfa_timeout:
|
|
551
|
+
return
|
|
552
|
+
try:
|
|
553
|
+
await asyncio.wait_for(
|
|
554
|
+
asyncio.shield(splash_closed),
|
|
555
|
+
timeout=self._config.mfa_exit_interval,
|
|
556
|
+
)
|
|
557
|
+
except TimeoutError as exc:
|
|
558
|
+
raise LoginError(
|
|
559
|
+
"login did not complete within "
|
|
560
|
+
f"{self._config.mfa_exit_interval}s "
|
|
561
|
+
"of second factor authentication completing (IBC's own "
|
|
562
|
+
"SecondFactorAuthenticationExitInterval watchdog)"
|
|
563
|
+
) from exc
|
|
564
|
+
return
|
|
565
|
+
|
|
566
|
+
if not self._config.relogin_after_mfa_timeout:
|
|
567
|
+
logger.info(
|
|
568
|
+
"IBController > re-login after second factor authentication "
|
|
569
|
+
"timeout not required"
|
|
570
|
+
)
|
|
571
|
+
return
|
|
572
|
+
logger.info(
|
|
573
|
+
"IBController > re-login after second factor authentication timeout in %ss",
|
|
574
|
+
_IBC_RELOGIN_DELAY_SECONDS,
|
|
575
|
+
)
|
|
576
|
+
await self.retry_login(
|
|
577
|
+
_IBC_RELOGIN_DELAY_SECONDS, outcome_timeout=outcome_timeout
|
|
578
|
+
)
|