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/main.py
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""ibcontroller's single-instance runner -- everything `cli.py`'s `run`/`init` commands
|
|
2
|
+
need beyond argument parsing, which is deliberately kept there (this module has no
|
|
3
|
+
`typer` dependency, so it stays directly testable/runnable without going through a CLI
|
|
4
|
+
invocation -- `python -m ibcontroller.main` still works).
|
|
5
|
+
|
|
6
|
+
**Config-dir scaffolding (`ensure_config_scaffold`)** Three bundled
|
|
7
|
+
templates ship under `ibcontroller/data/` (`ibcontroller.toml.example`,
|
|
8
|
+
`ibkr_settings.toml.example`, `labels.json.example`) and get copied into `config_dir`
|
|
9
|
+
the first time each is missing -- but only `ibcontroller.toml` is copied under its
|
|
10
|
+
*real* filename. The other two keep their `.example` suffix: `Config.settings_file`
|
|
11
|
+
defaults to `None` (inert until a user points `[settings] file` at a renamed copy) and,
|
|
12
|
+
more importantly, `labels.load_labels(config_dir=...)` applies `{config_dir}/
|
|
13
|
+
labels.json` *unconditionally* the moment that exact filename exists -- auto-creating
|
|
14
|
+
it live would be exactly the silent behavior-change . A user must deliberately rename it
|
|
15
|
+
to opt in.
|
|
16
|
+
|
|
17
|
+
`ibcontroller.toml.example` itself ships with every key commented out, so copying it
|
|
18
|
+
as `ibcontroller.toml` changes nothing versus built-in defaults -- `run_async` still
|
|
19
|
+
calls the real `load_config`, which still raises a clear `ConfigError` for the fields
|
|
20
|
+
that have no default (`trading_mode`/`tws_version`/credentials), guiding a first run
|
|
21
|
+
rather than silently starting with meaningless values.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import asyncio
|
|
27
|
+
import contextlib
|
|
28
|
+
import logging
|
|
29
|
+
import signal
|
|
30
|
+
from collections.abc import Mapping
|
|
31
|
+
from importlib import resources
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
from typing import Any
|
|
34
|
+
|
|
35
|
+
from ibcontroller.app_dirs import resolve_app_dirs
|
|
36
|
+
from ibcontroller.config import load_config
|
|
37
|
+
from ibcontroller.control_loop import ShutdownCause, run_control_loop
|
|
38
|
+
from ibcontroller.labels import load_labels
|
|
39
|
+
|
|
40
|
+
logger = logging.getLogger("ibcontroller.main")
|
|
41
|
+
|
|
42
|
+
# dest filename in config_dir -> bundled resource name under ibcontroller/data/. Only
|
|
43
|
+
# "ibcontroller.toml" is copied under its real, load-bearing name -- see module
|
|
44
|
+
# docstring for why the other two deliberately keep their ".example" suffix.
|
|
45
|
+
_SCAFFOLD_FILES: dict[str, str] = {
|
|
46
|
+
"ibcontroller.toml": "ibcontroller.toml.example",
|
|
47
|
+
"ibkr_settings.toml.example": "ibkr_settings.toml.example",
|
|
48
|
+
"labels.json.example": "labels.json.example",
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _load_jar_path() -> Path:
|
|
53
|
+
"""Resolves the bundled Java agent jar's real path, failing loudly (not
|
|
54
|
+
later, opaquely, inside `launch_instance`) if it isn't actually there --
|
|
55
|
+
e.g. a source checkout where `make` was never run, or a wheel built without
|
|
56
|
+
the jar present at build time (see the Makefile's `dist` target)."""
|
|
57
|
+
|
|
58
|
+
resource = resources.files("ibcontroller").joinpath("ibcontroller-agent.jar")
|
|
59
|
+
jar_path = Path(str(resource))
|
|
60
|
+
if not jar_path.is_file():
|
|
61
|
+
raise RuntimeError(
|
|
62
|
+
f"ibcontroller-agent.jar not found at {jar_path} -- build it first "
|
|
63
|
+
"(`make` from the project root) before running from a source checkout, "
|
|
64
|
+
"or reinstall the package if this is a wheel install."
|
|
65
|
+
)
|
|
66
|
+
return jar_path
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def ensure_config_scaffold(
|
|
70
|
+
config_dir: Path, log_dir: Path, *, force: bool = False
|
|
71
|
+
) -> list[Path]:
|
|
72
|
+
"""Creates `config_dir`/`log_dir` if missing, then copies each of
|
|
73
|
+
`_SCAFFOLD_FILES`'s bundled templates into `config_dir`, one file at a time, only
|
|
74
|
+
when the destination doesn't already exist (or unconditionally when `force=True`,
|
|
75
|
+
e.g. after an ibcontroller upgrade that changed the bundled examples). Returns the
|
|
76
|
+
list of files actually written, so a caller (`cli.py`'s `init`/`run`) can report
|
|
77
|
+
exactly what happened rather than silently overwriting or silently doing nothing."""
|
|
78
|
+
|
|
79
|
+
config_dir.mkdir(parents=True, exist_ok=True)
|
|
80
|
+
log_dir.mkdir(parents=True, exist_ok=True)
|
|
81
|
+
|
|
82
|
+
written: list[Path] = []
|
|
83
|
+
for dest_name, resource_name in _SCAFFOLD_FILES.items():
|
|
84
|
+
dest = config_dir / dest_name
|
|
85
|
+
if dest.exists() and not force:
|
|
86
|
+
continue
|
|
87
|
+
template = resources.files("ibcontroller").joinpath("data", resource_name)
|
|
88
|
+
dest.write_text(template.read_text(encoding="utf-8"), encoding="utf-8")
|
|
89
|
+
written.append(dest)
|
|
90
|
+
return written
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
async def run_async(
|
|
94
|
+
config_dir: Path,
|
|
95
|
+
log_dir: Path,
|
|
96
|
+
*,
|
|
97
|
+
dotenv_path: Path | None = None,
|
|
98
|
+
cli_overrides: Mapping[str, Any] | None = None,
|
|
99
|
+
) -> ShutdownCause:
|
|
100
|
+
"""Loads config/labels for the one instance described by `{config_dir}/
|
|
101
|
+
ibcontroller.toml`, then runs it to completion via `control_loop.run_control_loop`.
|
|
102
|
+
|
|
103
|
+
Scaffolds `config_dir` first (non-forced -- see `ensure_config_scaffold`), so a
|
|
104
|
+
fresh install's first `run` produces the same starter files `init` would, then
|
|
105
|
+
fails with a clear `ConfigError` (not a `FileNotFoundError`) if the required fields
|
|
106
|
+
are still unset.
|
|
107
|
+
|
|
108
|
+
`labels=load_labels(config_dir=config_dir)` is the actual fix for Open item 11 --
|
|
109
|
+
today's `control_loop.run_control_loop`'s own default (`load_labels()`, no
|
|
110
|
+
`config_dir`) never sees a user's `{config_dir}/labels.json` override at all.
|
|
111
|
+
|
|
112
|
+
`cli_overrides`, if given, is passed straight through to `load_config` -- the
|
|
113
|
+
per-invocation `Config` field overrides `cli.py`'s `run` command builds from its
|
|
114
|
+
own `--trading-mode`/`--tws-path`/`--tws-settings-path`/`--instance` options.
|
|
115
|
+
|
|
116
|
+
Ctrl-C (`SIGINT`) and `SIGTERM` both cancel the running task -- the graceful-stop
|
|
117
|
+
contract `control_loop.py`'s own docstring already documents ("cancel this
|
|
118
|
+
coroutine's own task to request a graceful stop... a plain Ctrl-C in a CLI"),
|
|
119
|
+
wired up here for the first time. POSIX only (`loop.add_signal_handler`), matching
|
|
120
|
+
this project's current platform scope."""
|
|
121
|
+
|
|
122
|
+
ensure_config_scaffold(config_dir, log_dir)
|
|
123
|
+
config = load_config(
|
|
124
|
+
config_dir=config_dir,
|
|
125
|
+
log_dir=log_dir,
|
|
126
|
+
dotenv_path=dotenv_path,
|
|
127
|
+
cli_overrides=cli_overrides,
|
|
128
|
+
)
|
|
129
|
+
labels = load_labels(config_dir=config_dir)
|
|
130
|
+
agent_jar_path = _load_jar_path()
|
|
131
|
+
|
|
132
|
+
loop = asyncio.get_running_loop()
|
|
133
|
+
task = asyncio.ensure_future(
|
|
134
|
+
run_control_loop(config, agent_jar_path, labels=labels)
|
|
135
|
+
)
|
|
136
|
+
for sig in (signal.SIGINT, signal.SIGTERM):
|
|
137
|
+
loop.add_signal_handler(sig, task.cancel)
|
|
138
|
+
try:
|
|
139
|
+
return await task
|
|
140
|
+
finally:
|
|
141
|
+
for sig in (signal.SIGINT, signal.SIGTERM):
|
|
142
|
+
with contextlib.suppress(NotImplementedError, ValueError):
|
|
143
|
+
loop.remove_signal_handler(sig)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def main() -> None:
|
|
147
|
+
"""Thin synchronous wrapper for direct invocation (`python -m ibcontroller.main`).
|
|
148
|
+
`cli.py` (the package's real installed entry point, `ibcontroller` on `PATH`) calls
|
|
149
|
+
`run_async` directly instead, so it can catch `ConfigError` and report it as a clean
|
|
150
|
+
CLI error rather than a traceback."""
|
|
151
|
+
config_dir, log_dir = resolve_app_dirs()
|
|
152
|
+
asyncio.run(run_async(config_dir, log_dir))
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
if __name__ == "__main__":
|
|
156
|
+
main()
|
ibcontroller/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,448 @@
|
|
|
1
|
+
"""Matches windows that show up unprompted -- a stray pop-up, an
|
|
2
|
+
existing-session conflict, a login-failure dialog, a warning dialog -- as
|
|
3
|
+
opposed to windows an active sequence (Login, Settings) already expects and
|
|
4
|
+
waits for directly via `actions.wait_for_event`.
|
|
5
|
+
|
|
6
|
+
`RecognizerRegistry` holds an ordered list of `Recognizer`s, checked in
|
|
7
|
+
order against each unprompted window; the first one whose `recognises()`
|
|
8
|
+
returns `True` has its `handle()` awaited. `DeclarativeDismissRecognizer` is
|
|
9
|
+
built from `labels.DismissRule` entries (`control_loop._build_registry`) for
|
|
10
|
+
the common "match text/title, click one button" case; the remaining classes
|
|
11
|
+
below handle cases with real conditional logic that isn't expressible as
|
|
12
|
+
data. Every recogniser acts through `actions.py`'s primitives, the same as
|
|
13
|
+
any other caller.
|
|
14
|
+
|
|
15
|
+
`watch_for_unprompted_windows` runs for the agent's whole lifetime as a
|
|
16
|
+
background task, watching `dispatcher.window_events` for `window_opened`
|
|
17
|
+
events and routing each one through `handle_window_opened`.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import asyncio
|
|
23
|
+
import logging
|
|
24
|
+
import re
|
|
25
|
+
from collections.abc import Callable, Sequence
|
|
26
|
+
from typing import Protocol
|
|
27
|
+
|
|
28
|
+
import eventkit
|
|
29
|
+
|
|
30
|
+
from ibcontroller.actions import click, dump
|
|
31
|
+
from ibcontroller.agent_client import (
|
|
32
|
+
Component,
|
|
33
|
+
WindowEvent,
|
|
34
|
+
WindowGoneError,
|
|
35
|
+
)
|
|
36
|
+
from ibcontroller.config import AcceptIncomingConnections, ExistingSessionAction
|
|
37
|
+
from ibcontroller.dispatch import Dispatcher
|
|
38
|
+
from ibcontroller.labels import (
|
|
39
|
+
AcceptIncomingConnectionLabels,
|
|
40
|
+
DismissRule,
|
|
41
|
+
ExistingSessionLabels,
|
|
42
|
+
LoginFailedLabels,
|
|
43
|
+
TooManyFailedLoginAttemptsLabels,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
logger = logging.getLogger(__name__)
|
|
47
|
+
|
|
48
|
+
_WAIT_PATTERN = re.compile(
|
|
49
|
+
r"Please wait (?:(\d+) minutes? )?(?:& )?(?:(\d+) seconds?)?"
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _parse_wait_seconds(message: str) -> float:
|
|
54
|
+
"""Parses a wait duration out of a "too many failed login attempts"
|
|
55
|
+
message (e.g. "Please wait 4 minutes & 47 seconds"), plus a 3 second
|
|
56
|
+
buffer. Returns just the buffer if `message` doesn't match."""
|
|
57
|
+
match = _WAIT_PATTERN.search(message)
|
|
58
|
+
minutes = int(match.group(1)) if match and match.group(1) else 0
|
|
59
|
+
seconds = int(match.group(2)) if match and match.group(2) else 0
|
|
60
|
+
return float(minutes * 60 + seconds + 3)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class LoginFailedError(Exception):
|
|
64
|
+
"""Raised by `LoginFailedRecognizer.handle` after dismissing a real
|
|
65
|
+
"Login failed" dialog -- signals that a cold restart is needed. Not
|
|
66
|
+
raised for a bug in this recogniser, which propagates as whatever
|
|
67
|
+
exception it actually is."""
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class Recognizer(Protocol):
|
|
71
|
+
def recognises(self, event: WindowEvent, components: list[Component]) -> bool:
|
|
72
|
+
"""Returns whether this recogniser matches `event`. `components` is
|
|
73
|
+
the result of dumping the window (never `None` -- if the window
|
|
74
|
+
closed before the dump could complete, this recogniser is never
|
|
75
|
+
called)."""
|
|
76
|
+
...
|
|
77
|
+
|
|
78
|
+
async def handle(
|
|
79
|
+
self,
|
|
80
|
+
event: WindowEvent,
|
|
81
|
+
components: list[Component],
|
|
82
|
+
dispatcher: Dispatcher,
|
|
83
|
+
) -> None:
|
|
84
|
+
"""Acts on a matched window -- dismissing it, clicking a button, or
|
|
85
|
+
otherwise responding to it."""
|
|
86
|
+
...
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def is_credential_entry(components: list[Component]) -> bool:
|
|
90
|
+
"""Returns whether `components` includes a live, visible credential
|
|
91
|
+
field. Checked before any recogniser runs -- credential fields are never
|
|
92
|
+
handled by this registry, only by Login's own field-fill."""
|
|
93
|
+
return any(c.credential_field and c.showing for c in components)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class DeclarativeDismissRecognizer:
|
|
97
|
+
"""One `Recognizer` per `labels.DismissRule`: matches `rule.match_title`
|
|
98
|
+
against the window's title and/or `rule.match_text` against any
|
|
99
|
+
component's `accessible_name`/`text`, then clicks `rule.click`. If both
|
|
100
|
+
`match_title` and `match_text` are given, both must match. `rule` is
|
|
101
|
+
public so a caller inspecting `RecognizerRegistry.handled` can identify
|
|
102
|
+
which rule fired, by name."""
|
|
103
|
+
|
|
104
|
+
def __init__(self, rule: DismissRule) -> None:
|
|
105
|
+
self.rule = rule
|
|
106
|
+
|
|
107
|
+
def recognises(self, event: WindowEvent, components: list[Component]) -> bool:
|
|
108
|
+
rule = self.rule
|
|
109
|
+
if rule.match_title is not None and rule.match_title not in (
|
|
110
|
+
event.window.title or ""
|
|
111
|
+
):
|
|
112
|
+
return False
|
|
113
|
+
if rule.match_text is not None and not any(
|
|
114
|
+
(c.accessible_name is not None and rule.match_text in c.accessible_name)
|
|
115
|
+
or (c.text is not None and rule.match_text in c.text)
|
|
116
|
+
for c in components
|
|
117
|
+
):
|
|
118
|
+
return False
|
|
119
|
+
return True
|
|
120
|
+
|
|
121
|
+
async def handle(
|
|
122
|
+
self,
|
|
123
|
+
event: WindowEvent,
|
|
124
|
+
components: list[Component],
|
|
125
|
+
dispatcher: Dispatcher,
|
|
126
|
+
) -> None:
|
|
127
|
+
"""Clicks `rule.click`, scoped to `event.window.window_id`."""
|
|
128
|
+
await click(dispatcher, self.rule.click, window_id=event.window.window_id)
|
|
129
|
+
logger.info("dismissed %s (declarative rule)", self.rule.name)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class TooManyFailedLoginAttemptsRecognizer:
|
|
133
|
+
"""Matches the server-side "too many failed login attempts" dialog by
|
|
134
|
+
message text (`text` primarily, `accessible_name` as a fallback).
|
|
135
|
+
Distinct from the 2FA-timeout retry `login.py`'s `LoginManager` handles
|
|
136
|
+
internally (that one is driven by elapsed time, not by recognising a
|
|
137
|
+
window).
|
|
138
|
+
|
|
139
|
+
When `relogin_enabled` is `True`, dismisses the dialog and calls
|
|
140
|
+
`schedule_retry` with the wait time (in seconds) parsed from the
|
|
141
|
+
message. When `False`, the dialog is left untouched for manual
|
|
142
|
+
handling. `schedule_retry` is a plain, synchronous callable -- it's
|
|
143
|
+
expected to schedule the actual retry as a background task, since
|
|
144
|
+
`handle()` returns immediately without waiting out the cooldown."""
|
|
145
|
+
|
|
146
|
+
def __init__(
|
|
147
|
+
self,
|
|
148
|
+
labels: TooManyFailedLoginAttemptsLabels,
|
|
149
|
+
relogin_enabled: bool,
|
|
150
|
+
schedule_retry: Callable[[float], None],
|
|
151
|
+
) -> None:
|
|
152
|
+
self._labels = labels
|
|
153
|
+
self._relogin_enabled = relogin_enabled
|
|
154
|
+
self._schedule_retry = schedule_retry
|
|
155
|
+
|
|
156
|
+
def _matched_text(self, components: list[Component]) -> str | None:
|
|
157
|
+
prefix = self._labels.message_prefix
|
|
158
|
+
for c in components:
|
|
159
|
+
if c.text is not None and prefix in c.text:
|
|
160
|
+
return c.text
|
|
161
|
+
if c.accessible_name is not None and prefix in c.accessible_name:
|
|
162
|
+
return c.accessible_name
|
|
163
|
+
return None
|
|
164
|
+
|
|
165
|
+
def recognises(self, event: WindowEvent, components: list[Component]) -> bool:
|
|
166
|
+
return self._matched_text(components) is not None
|
|
167
|
+
|
|
168
|
+
async def handle(
|
|
169
|
+
self,
|
|
170
|
+
event: WindowEvent,
|
|
171
|
+
components: list[Component],
|
|
172
|
+
dispatcher: Dispatcher,
|
|
173
|
+
) -> None:
|
|
174
|
+
if not self._relogin_enabled:
|
|
175
|
+
logger.warning(
|
|
176
|
+
"too many failed login attempts -- relogin disabled, "
|
|
177
|
+
"dialog left for manual handling"
|
|
178
|
+
)
|
|
179
|
+
return
|
|
180
|
+
message = self._matched_text(components) or ""
|
|
181
|
+
wait_seconds = _parse_wait_seconds(message)
|
|
182
|
+
logger.warning(
|
|
183
|
+
"too many failed login attempts -- retrying in %.0fs", wait_seconds
|
|
184
|
+
)
|
|
185
|
+
await click(
|
|
186
|
+
dispatcher,
|
|
187
|
+
self._labels.dismiss_button,
|
|
188
|
+
window_id=event.window.window_id,
|
|
189
|
+
)
|
|
190
|
+
self._schedule_retry(wait_seconds)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
class LoginFailedRecognizer:
|
|
194
|
+
"""Matches a "Login failed" dialog by window title. Dismisses it and
|
|
195
|
+
raises `LoginFailedError` -- the restart itself isn't this recogniser's
|
|
196
|
+
job, only signalling that one is needed."""
|
|
197
|
+
|
|
198
|
+
def __init__(self, labels: LoginFailedLabels) -> None:
|
|
199
|
+
self._labels = labels
|
|
200
|
+
|
|
201
|
+
def recognises(self, event: WindowEvent, components: list[Component]) -> bool:
|
|
202
|
+
return self._labels.title in (event.window.title or "")
|
|
203
|
+
|
|
204
|
+
async def handle(
|
|
205
|
+
self,
|
|
206
|
+
event: WindowEvent,
|
|
207
|
+
components: list[Component],
|
|
208
|
+
dispatcher: Dispatcher,
|
|
209
|
+
) -> None:
|
|
210
|
+
await click(
|
|
211
|
+
dispatcher,
|
|
212
|
+
self._labels.dismiss_button,
|
|
213
|
+
window_id=event.window.window_id,
|
|
214
|
+
)
|
|
215
|
+
logger.warning("IBController > login failed -- cold restart required")
|
|
216
|
+
raise LoginFailedError("login failed; cold restart required")
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
class ExistingSessionRecognizer:
|
|
220
|
+
"""Matches an existing-session-conflict dialog by window title. `action`
|
|
221
|
+
(`ExistingSessionAction`: manual/secondary/primary/primaryoverride)
|
|
222
|
+
selects how to resolve it; `is_logged_in` reports whether this session
|
|
223
|
+
has already reached `LOGGED_IN`, since the correct response differs
|
|
224
|
+
before vs. after login completes. Stateful: tracks whether a previous
|
|
225
|
+
attempt was already made this process, one instance per running agent."""
|
|
226
|
+
|
|
227
|
+
def __init__(
|
|
228
|
+
self,
|
|
229
|
+
labels: ExistingSessionLabels,
|
|
230
|
+
action: ExistingSessionAction,
|
|
231
|
+
is_logged_in: Callable[[], bool],
|
|
232
|
+
) -> None:
|
|
233
|
+
self._labels = labels
|
|
234
|
+
self._action = action
|
|
235
|
+
self._is_logged_in = is_logged_in
|
|
236
|
+
self._attempted = False
|
|
237
|
+
|
|
238
|
+
def recognises(self, event: WindowEvent, components: list[Component]) -> bool:
|
|
239
|
+
return self._labels.title in (event.window.title or "")
|
|
240
|
+
|
|
241
|
+
async def handle(
|
|
242
|
+
self,
|
|
243
|
+
event: WindowEvent,
|
|
244
|
+
components: list[Component],
|
|
245
|
+
dispatcher: Dispatcher,
|
|
246
|
+
) -> None:
|
|
247
|
+
if self._action is ExistingSessionAction.MANUAL:
|
|
248
|
+
logger.info(
|
|
249
|
+
"IBController > existing session detected -- manual, leaving for"
|
|
250
|
+
" the user"
|
|
251
|
+
)
|
|
252
|
+
return # scenario 1: user must choose -- nothing to do
|
|
253
|
+
if self._action is ExistingSessionAction.SECONDARY:
|
|
254
|
+
# scenario 2: end this session, let the other one proceed
|
|
255
|
+
logger.info(
|
|
256
|
+
"IBController > existing session detected -- secondary, ending"
|
|
257
|
+
" this session"
|
|
258
|
+
)
|
|
259
|
+
await click(
|
|
260
|
+
dispatcher,
|
|
261
|
+
self._labels.cancel_buttons,
|
|
262
|
+
window_id=event.window.window_id,
|
|
263
|
+
)
|
|
264
|
+
return
|
|
265
|
+
if not self._is_logged_in():
|
|
266
|
+
# we are session B (login not yet complete)
|
|
267
|
+
if not self._attempted:
|
|
268
|
+
self._attempted = True
|
|
269
|
+
# scenario 3: don't know session A's type, continue this one
|
|
270
|
+
logger.info(
|
|
271
|
+
"IBController > existing session detected -- first attempt, "
|
|
272
|
+
"continuing this session"
|
|
273
|
+
)
|
|
274
|
+
await click(
|
|
275
|
+
dispatcher,
|
|
276
|
+
self._labels.continue_buttons,
|
|
277
|
+
window_id=event.window.window_id,
|
|
278
|
+
)
|
|
279
|
+
else:
|
|
280
|
+
# scenario 4: session A must be primary/primaryoverride, end this one
|
|
281
|
+
logger.info(
|
|
282
|
+
"IBController > existing session detected -- second attempt, ending"
|
|
283
|
+
" this session"
|
|
284
|
+
)
|
|
285
|
+
await click(
|
|
286
|
+
dispatcher,
|
|
287
|
+
self._labels.cancel_buttons,
|
|
288
|
+
window_id=event.window.window_id,
|
|
289
|
+
)
|
|
290
|
+
elif self._action is ExistingSessionAction.PRIMARY:
|
|
291
|
+
# scenario 5: we are session A, primary -- continue this, let the other exit
|
|
292
|
+
logger.info(
|
|
293
|
+
"IBController > existing session detected -- primary, continuing this"
|
|
294
|
+
" session"
|
|
295
|
+
)
|
|
296
|
+
await click(
|
|
297
|
+
dispatcher,
|
|
298
|
+
self._labels.continue_buttons,
|
|
299
|
+
window_id=event.window.window_id,
|
|
300
|
+
)
|
|
301
|
+
else:
|
|
302
|
+
# scenario 6: we are session A, primaryoverride -- let the other proceed
|
|
303
|
+
logger.info(
|
|
304
|
+
"IBController > existing session detected -- primaryoverride, ending"
|
|
305
|
+
" this session"
|
|
306
|
+
)
|
|
307
|
+
await click(
|
|
308
|
+
dispatcher,
|
|
309
|
+
self._labels.cancel_buttons,
|
|
310
|
+
window_id=event.window.window_id,
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
class AcceptIncomingConnectionsRecognizer:
|
|
315
|
+
"""Matches the "Accept incoming connection" dialog TWS/Gateway shows when
|
|
316
|
+
a client connects on the API socket, by window title (ported from IBC's
|
|
317
|
+
own `AcceptIncomingConnectionDialogHandler`; title matching, not IBC's
|
|
318
|
+
body-label search, to match this project's own convention for every
|
|
319
|
+
other simple dialog here -- confirmed as a valid alternative by ibctl's
|
|
320
|
+
own independent, live-tested handler, which matches on title too).
|
|
321
|
+
`action` (`AcceptIncomingConnections`: manual/accept/reject) selects the
|
|
322
|
+
response; `MANUAL` leaves the dialog for the user, matching IBC's own
|
|
323
|
+
handler."""
|
|
324
|
+
|
|
325
|
+
def __init__(
|
|
326
|
+
self,
|
|
327
|
+
labels: AcceptIncomingConnectionLabels,
|
|
328
|
+
action: AcceptIncomingConnections,
|
|
329
|
+
) -> None:
|
|
330
|
+
self._labels = labels
|
|
331
|
+
self._action = action
|
|
332
|
+
|
|
333
|
+
def recognises(self, event: WindowEvent, components: list[Component]) -> bool:
|
|
334
|
+
return self._labels.title in (event.window.title or "")
|
|
335
|
+
|
|
336
|
+
async def handle(
|
|
337
|
+
self,
|
|
338
|
+
event: WindowEvent,
|
|
339
|
+
components: list[Component],
|
|
340
|
+
dispatcher: Dispatcher,
|
|
341
|
+
) -> None:
|
|
342
|
+
if self._action is AcceptIncomingConnections.MANUAL:
|
|
343
|
+
logger.info(
|
|
344
|
+
"IBController > incoming API connection -- manual, leaving for the user"
|
|
345
|
+
)
|
|
346
|
+
return
|
|
347
|
+
if self._action is AcceptIncomingConnections.ACCEPT:
|
|
348
|
+
await click(
|
|
349
|
+
dispatcher,
|
|
350
|
+
self._labels.accept_buttons,
|
|
351
|
+
window_id=event.window.window_id,
|
|
352
|
+
)
|
|
353
|
+
logger.info("IBController > incoming API connection -- accepted")
|
|
354
|
+
else:
|
|
355
|
+
await click(
|
|
356
|
+
dispatcher,
|
|
357
|
+
self._labels.reject_buttons,
|
|
358
|
+
window_id=event.window.window_id,
|
|
359
|
+
)
|
|
360
|
+
logger.info("IBController > incoming API connection -- rejected")
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
class RecognizerRegistry:
|
|
364
|
+
"""Ordered list of `Recognizer`s, checked in sequence; `dispatch()` runs
|
|
365
|
+
the first one that matches. A bug in a recogniser propagates unchanged,
|
|
366
|
+
not caught here -- there's no plugin/config-rule tier yet to isolate
|
|
367
|
+
failures from."""
|
|
368
|
+
|
|
369
|
+
def __init__(self, registry: Sequence[Recognizer]) -> None:
|
|
370
|
+
self._registry = list(registry)
|
|
371
|
+
self.handled: eventkit.Event = eventkit.Event()
|
|
372
|
+
"""Emits `(recognizer, event)` -- two positional args -- right after
|
|
373
|
+
that recognizer's own `handle()` returns successfully. Lets a caller
|
|
374
|
+
know a specific rule has run (e.g. to know a blocking dialog is now
|
|
375
|
+
gone) before proceeding."""
|
|
376
|
+
|
|
377
|
+
def recognises(self, event: WindowEvent, components: list[Component]) -> bool:
|
|
378
|
+
"""Read-only: whether any registered recogniser matches, without
|
|
379
|
+
running its `handle()`. Used by `diagnostics.watch_for_diagnostics`
|
|
380
|
+
to classify a window as "known"/"unknown" -- the same classification
|
|
381
|
+
`dispatch()` itself uses, exposed without the side effect."""
|
|
382
|
+
return any(r.recognises(event, components) for r in self._registry)
|
|
383
|
+
|
|
384
|
+
async def dispatch(
|
|
385
|
+
self,
|
|
386
|
+
event: WindowEvent,
|
|
387
|
+
components: list[Component],
|
|
388
|
+
dispatcher: Dispatcher,
|
|
389
|
+
) -> bool:
|
|
390
|
+
"""Runs the first matching recogniser's `handle()` against
|
|
391
|
+
`event`/`components`, emitting `handled`. Returns whether anything
|
|
392
|
+
matched."""
|
|
393
|
+
for recognizer in self._registry:
|
|
394
|
+
if recognizer.recognises(event, components):
|
|
395
|
+
await recognizer.handle(event, components, dispatcher)
|
|
396
|
+
self.handled.emit(recognizer, event)
|
|
397
|
+
return True
|
|
398
|
+
return False
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
async def handle_window_opened(
|
|
402
|
+
registry: RecognizerRegistry,
|
|
403
|
+
event: WindowEvent,
|
|
404
|
+
dispatcher: Dispatcher,
|
|
405
|
+
) -> bool:
|
|
406
|
+
"""Dumps `event.window`'s components, then dispatches: refuses (returns
|
|
407
|
+
`False`) if the window shows a live credential field, otherwise routes it
|
|
408
|
+
through `registry.dispatch`. Returns `False` if the window closed before
|
|
409
|
+
it could be dumped (`WindowGoneError`) or if nothing matched."""
|
|
410
|
+
try:
|
|
411
|
+
components = await dump(
|
|
412
|
+
dispatcher, window=event.window.title, window_id=event.window.window_id
|
|
413
|
+
)
|
|
414
|
+
except WindowGoneError:
|
|
415
|
+
return False
|
|
416
|
+
if is_credential_entry(components):
|
|
417
|
+
return False
|
|
418
|
+
return await registry.dispatch(event, components, dispatcher)
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
async def watch_for_unprompted_windows(
|
|
422
|
+
registry: RecognizerRegistry,
|
|
423
|
+
dispatcher: Dispatcher,
|
|
424
|
+
) -> None:
|
|
425
|
+
"""Runs for the agent's whole lifetime: watches `dispatcher.window_events`
|
|
426
|
+
for `window_opened` events and routes each one through
|
|
427
|
+
`handle_window_opened`, logging any that go unhandled. Meant to be
|
|
428
|
+
started via `asyncio.create_task`, not awaited directly -- it never
|
|
429
|
+
returns on its own."""
|
|
430
|
+
queue: asyncio.Queue[WindowEvent] = asyncio.Queue()
|
|
431
|
+
|
|
432
|
+
def on_event(event: WindowEvent) -> None:
|
|
433
|
+
if event.kind == "window_opened":
|
|
434
|
+
queue.put_nowait(event)
|
|
435
|
+
|
|
436
|
+
dispatcher.window_events.connect(on_event)
|
|
437
|
+
try:
|
|
438
|
+
while True:
|
|
439
|
+
event = await queue.get()
|
|
440
|
+
handled = await handle_window_opened(registry, event, dispatcher)
|
|
441
|
+
if not handled:
|
|
442
|
+
logger.info(
|
|
443
|
+
"unhandled unprompted window: %s %r",
|
|
444
|
+
event.window.class_,
|
|
445
|
+
event.window.title,
|
|
446
|
+
)
|
|
447
|
+
finally:
|
|
448
|
+
dispatcher.window_events.disconnect(on_event)
|