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/dispatch.py
ADDED
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
"""Serializes access to one running agent instance: events in, commands out,
|
|
2
|
+
both serialized through one `Dispatcher`. Built on `agent_client.py`'s typed
|
|
3
|
+
connections -- this module only ever handles `PingResult`/`Component`/
|
|
4
|
+
`WindowEvent` and the rest of that module's vocabulary, never a raw line or
|
|
5
|
+
dict itself.
|
|
6
|
+
|
|
7
|
+
One `Dispatcher` per running agent instance (live and paper are separate
|
|
8
|
+
objects, each with its own connections, queue, and event stream) -- nothing
|
|
9
|
+
here is a module-level singleton.
|
|
10
|
+
|
|
11
|
+
**Trace mode**: pass `instance` (the same name `logging_setup.configure_trace`
|
|
12
|
+
was called with) to emit every command and every raw event message as NDJSON
|
|
13
|
+
to `cmd-{instance}.jsonl`/`events-{instance}.jsonl`, via the
|
|
14
|
+
`ibcontroller.trace.{instance}.{cmd,event}` loggers. This module only builds
|
|
15
|
+
the JSON line and calls `logger.debug(line)` -- the files, handlers, and
|
|
16
|
+
listener threads are owned by `logging_setup.py`, so nothing here ever
|
|
17
|
+
blocks the event loop on a write.
|
|
18
|
+
|
|
19
|
+
Event arrivals are also logged at DEBUG in the normal app log (one
|
|
20
|
+
human-readable line per message, via `_describe_event`), independent of
|
|
21
|
+
trace mode.
|
|
22
|
+
|
|
23
|
+
Credentials never reach this file as plaintext: `Config.username`/`password`
|
|
24
|
+
are `Secret`, not `str`; `agent_client.set_text` is the only place that ever
|
|
25
|
+
unwraps one, so nothing downstream (including this module) ever sees the
|
|
26
|
+
bare value. A `Secret` passed as a `set_text` argument serializes as
|
|
27
|
+
`"Secret('<redacted>')"` via this file's existing `repr` fallback.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import asyncio
|
|
33
|
+
import functools
|
|
34
|
+
import json
|
|
35
|
+
import logging
|
|
36
|
+
from collections.abc import Awaitable, Callable
|
|
37
|
+
from datetime import UTC, datetime
|
|
38
|
+
from typing import Any, TypeVar
|
|
39
|
+
|
|
40
|
+
import eventkit
|
|
41
|
+
|
|
42
|
+
from ibcontroller.agent_client import (
|
|
43
|
+
AgentClientError,
|
|
44
|
+
AgentCommandConnection,
|
|
45
|
+
AgentEventConnection,
|
|
46
|
+
AgentEventMessage,
|
|
47
|
+
Hello,
|
|
48
|
+
Keepalive,
|
|
49
|
+
Overflow,
|
|
50
|
+
Snapshot,
|
|
51
|
+
WindowEvent,
|
|
52
|
+
converter,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
logger = logging.getLogger(__name__)
|
|
56
|
+
|
|
57
|
+
T = TypeVar("T")
|
|
58
|
+
|
|
59
|
+
_CommandCall = Callable[[], Awaitable[T]]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _now_iso() -> str:
|
|
63
|
+
return datetime.now(UTC).isoformat(timespec="milliseconds")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _describe_call(call: _CommandCall[object]) -> dict[str, Any]:
|
|
67
|
+
"""Best-effort trace-file description of `call`. Only a
|
|
68
|
+
`functools.partial` of a bound `AgentCommandConnection` method (the
|
|
69
|
+
documented convention for `send_command` calls) can actually be named
|
|
70
|
+
and have its arguments logged."""
|
|
71
|
+
if isinstance(call, functools.partial):
|
|
72
|
+
return {
|
|
73
|
+
"cmd": getattr(call.func, "__name__", repr(call.func)),
|
|
74
|
+
"args": list(call.args),
|
|
75
|
+
}
|
|
76
|
+
return {"cmd": getattr(call, "__name__", repr(call))}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _safe_unstructure(value: object) -> object:
|
|
80
|
+
"""Unstructures `value` for the trace file, falling back to `repr` for
|
|
81
|
+
anything `cattrs` can't handle -- trace serialization must never be the
|
|
82
|
+
reason a real command fails."""
|
|
83
|
+
try:
|
|
84
|
+
return converter.unstructure(value)
|
|
85
|
+
except Exception:
|
|
86
|
+
return repr(value)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _describe_event(msg: AgentEventMessage) -> str:
|
|
90
|
+
"""Returns one human-readable line describing `msg`, for the DEBUG app
|
|
91
|
+
log (`_event_reader`). The raw JSON of the same message still lives in
|
|
92
|
+
the trace file when tracing is enabled. Event content is window metadata
|
|
93
|
+
only (class/title/window_id/seq), never field values, so it's always
|
|
94
|
+
safe to log."""
|
|
95
|
+
if isinstance(msg, Hello):
|
|
96
|
+
return f"event: hello protocol_version={msg.protocol_version}"
|
|
97
|
+
if isinstance(msg, Snapshot):
|
|
98
|
+
return f"event: snapshot windows={len(msg.windows)}"
|
|
99
|
+
if isinstance(msg, WindowEvent):
|
|
100
|
+
win = msg.window
|
|
101
|
+
title = f" title={win.title!r}" if win.title else ""
|
|
102
|
+
wid = f" window_id={win.window_id}" if win.window_id else ""
|
|
103
|
+
return f"event: {msg.kind} class={win.class_}{title}{wid} seq={msg.seq}"
|
|
104
|
+
if isinstance(msg, Overflow):
|
|
105
|
+
return f"event: overflow from_seq={msg.from_seq}"
|
|
106
|
+
if isinstance(msg, Keepalive):
|
|
107
|
+
return f"event: keepalive ts={msg.ts}"
|
|
108
|
+
return f"event: {type(msg).__name__}"
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class Dispatcher:
|
|
112
|
+
"""Owns one command socket connection and one event socket connection
|
|
113
|
+
for a single running agent instance.
|
|
114
|
+
|
|
115
|
+
`window_events` carries `WindowEvent` messages only -- `Hello`/`Snapshot`
|
|
116
|
+
are connection-lifecycle markers and `Keepalive` is a liveness signal,
|
|
117
|
+
none of the three are forwarded. `Overflow` is logged as a warning (real
|
|
118
|
+
event loss on the agent's own side), not silently dropped. All four
|
|
119
|
+
still reach the trace file when tracing is enabled (see the module
|
|
120
|
+
docstring) -- that stream mirrors raw traffic, unlike `window_events`.
|
|
121
|
+
"""
|
|
122
|
+
|
|
123
|
+
def __init__(
|
|
124
|
+
self,
|
|
125
|
+
command_conn: AgentCommandConnection,
|
|
126
|
+
event_conn: AgentEventConnection,
|
|
127
|
+
*,
|
|
128
|
+
instance: str | None = None,
|
|
129
|
+
) -> None:
|
|
130
|
+
"""`command_conn`/`event_conn` are the two socket connections this
|
|
131
|
+
Dispatcher owns. `instance`, if given, enables trace mode under that
|
|
132
|
+
name (see the module docstring)."""
|
|
133
|
+
self._command_conn = command_conn
|
|
134
|
+
self._event_conn = event_conn
|
|
135
|
+
self._command_queue: asyncio.Queue[
|
|
136
|
+
tuple[_CommandCall[object], asyncio.Future[object], int]
|
|
137
|
+
] = asyncio.Queue()
|
|
138
|
+
self.window_events: eventkit.Event = eventkit.Event()
|
|
139
|
+
self._tasks: list[asyncio.Task[None]] = []
|
|
140
|
+
self._trace_cmd_logger: logging.Logger | None = None
|
|
141
|
+
self._trace_event_logger: logging.Logger | None = None
|
|
142
|
+
# A single gate covering both streams: configure_trace sets both to
|
|
143
|
+
# the same level, so the .cmd logger's level is the reliable answer.
|
|
144
|
+
# Resolved once at construction -- configure_trace must have run
|
|
145
|
+
# (with this instance's name) before the Dispatcher is built.
|
|
146
|
+
self._trace_enabled = False
|
|
147
|
+
if instance is not None:
|
|
148
|
+
prefix = f"ibcontroller.trace.{instance}"
|
|
149
|
+
self._trace_cmd_logger = logging.getLogger(f"{prefix}.cmd")
|
|
150
|
+
self._trace_event_logger = logging.getLogger(f"{prefix}.event")
|
|
151
|
+
self._trace_enabled = self._trace_cmd_logger.isEnabledFor(logging.DEBUG)
|
|
152
|
+
self._trace_seq = 0
|
|
153
|
+
|
|
154
|
+
async def start(self) -> None:
|
|
155
|
+
"""Connects both sockets and starts the event-reader/command-worker
|
|
156
|
+
coroutines as background tasks. Call once; not idempotent."""
|
|
157
|
+
await self._command_conn.connect()
|
|
158
|
+
await self._event_conn.connect()
|
|
159
|
+
self._tasks = [
|
|
160
|
+
asyncio.create_task(self._event_reader(), name="ibcontroller-event-reader"),
|
|
161
|
+
asyncio.create_task(
|
|
162
|
+
self._command_worker(), name="ibcontroller-command-worker"
|
|
163
|
+
),
|
|
164
|
+
]
|
|
165
|
+
|
|
166
|
+
@property
|
|
167
|
+
def command_conn(self) -> AgentCommandConnection:
|
|
168
|
+
"""The `AgentCommandConnection` this Dispatcher owns -- lets
|
|
169
|
+
`actions.py`'s functions build `send_command` calls against it
|
|
170
|
+
without threading a second, always-the-same reference alongside
|
|
171
|
+
`dispatcher`."""
|
|
172
|
+
return self._command_conn
|
|
173
|
+
|
|
174
|
+
@property
|
|
175
|
+
def tasks(self) -> tuple[asyncio.Task[None], ...]:
|
|
176
|
+
"""The background event-reader/command-worker tasks, for an external
|
|
177
|
+
supervisor to watch for either one ending unexpectedly (e.g. the
|
|
178
|
+
agent process died, or Gateway closed) -- a connection loss ends
|
|
179
|
+
`_event_reader`'s loop with no exception, nothing to catch here."""
|
|
180
|
+
return tuple(self._tasks)
|
|
181
|
+
|
|
182
|
+
async def stop(self) -> None:
|
|
183
|
+
"""Cancels both background tasks and closes both connections. Any
|
|
184
|
+
command still queued or in flight is abandoned -- its
|
|
185
|
+
`send_command` caller sees a `CancelledError`, not a result. Trace
|
|
186
|
+
streams are `logging_setup.py`'s concern, not this layer's; the
|
|
187
|
+
caller drains those separately via `logging_setup.stop_logging()`."""
|
|
188
|
+
for task in self._tasks:
|
|
189
|
+
task.cancel()
|
|
190
|
+
for task in self._tasks:
|
|
191
|
+
try:
|
|
192
|
+
await task
|
|
193
|
+
except asyncio.CancelledError:
|
|
194
|
+
pass
|
|
195
|
+
await self._command_conn.close()
|
|
196
|
+
await self._event_conn.close()
|
|
197
|
+
|
|
198
|
+
async def send_command(self, call: _CommandCall[T]) -> T:
|
|
199
|
+
"""Queues `call` and waits for the single `_command_worker` to run
|
|
200
|
+
it, never overlapping another command on the same connection (the
|
|
201
|
+
wire protocol has no request IDs). `call` should be a
|
|
202
|
+
`functools.partial` of a bound `AgentCommandConnection` method, e.g.
|
|
203
|
+
`functools.partial(dispatcher.command_conn.set_text, "u", "x")` --
|
|
204
|
+
the trace file can only name the command and its arguments when it
|
|
205
|
+
can introspect `call.func`/`call.args`."""
|
|
206
|
+
loop = asyncio.get_running_loop()
|
|
207
|
+
done: asyncio.Future[T] = loop.create_future()
|
|
208
|
+
self._trace_seq += 1
|
|
209
|
+
seq = self._trace_seq
|
|
210
|
+
self._trace_cmd({"seq": seq, "direction": "sent", **_describe_call(call)})
|
|
211
|
+
# The queue is declared over `object` so one queue can carry every command's
|
|
212
|
+
# differently-typed result; a per-call `T` narrows correctly at the type
|
|
213
|
+
# checker's boundary here but not through the queue's own invariant generic.
|
|
214
|
+
await self._command_queue.put((call, done, seq)) # type: ignore[arg-type]
|
|
215
|
+
return await done
|
|
216
|
+
|
|
217
|
+
async def _event_reader(self) -> None:
|
|
218
|
+
"""Reads events from `AgentEventConnection.messages()`, emits each
|
|
219
|
+
`WindowEvent` on `window_events`, and logs every message at DEBUG
|
|
220
|
+
and to the trace file. Deliberately doesn't catch anything -- a
|
|
221
|
+
malformed line (`ProtocolError`) means the connection is desynced,
|
|
222
|
+
not a transient hiccup; the task ending is the correct signal for
|
|
223
|
+
whatever supervises this instance to notice and act on."""
|
|
224
|
+
async for msg in self._event_conn.messages():
|
|
225
|
+
logger.debug("%s", _describe_event(msg))
|
|
226
|
+
self._trace_event(
|
|
227
|
+
{"class": type(msg).__name__, **_safe_unstructure(msg)} # type: ignore[dict-item]
|
|
228
|
+
)
|
|
229
|
+
if isinstance(msg, WindowEvent):
|
|
230
|
+
self.window_events.emit(msg)
|
|
231
|
+
elif isinstance(msg, Overflow):
|
|
232
|
+
logger.warning(
|
|
233
|
+
"agent event queue overflow, events lost from seq=%s",
|
|
234
|
+
msg.from_seq,
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
async def _command_worker(self) -> None:
|
|
238
|
+
"""Runs one queued command at a time, resolving its future with the
|
|
239
|
+
result or the raised exception. Catches broad `Exception`
|
|
240
|
+
deliberately -- one bad command must not kill this task, which
|
|
241
|
+
would silently stop every future command for this instance from
|
|
242
|
+
ever being processed."""
|
|
243
|
+
while True:
|
|
244
|
+
call, done, seq = await self._command_queue.get()
|
|
245
|
+
if done.cancelled():
|
|
246
|
+
continue
|
|
247
|
+
try:
|
|
248
|
+
result = await call()
|
|
249
|
+
except Exception as exc:
|
|
250
|
+
code = (
|
|
251
|
+
exc.__class__.__name__
|
|
252
|
+
if isinstance(exc, AgentClientError)
|
|
253
|
+
else None
|
|
254
|
+
)
|
|
255
|
+
self._trace_cmd(
|
|
256
|
+
{"seq": seq, "direction": "error", "error": code or repr(exc)}
|
|
257
|
+
)
|
|
258
|
+
if not done.cancelled():
|
|
259
|
+
done.set_exception(exc)
|
|
260
|
+
else:
|
|
261
|
+
self._trace_cmd(
|
|
262
|
+
{
|
|
263
|
+
"seq": seq,
|
|
264
|
+
"direction": "result",
|
|
265
|
+
"result": _safe_unstructure(result),
|
|
266
|
+
}
|
|
267
|
+
)
|
|
268
|
+
if not done.cancelled():
|
|
269
|
+
done.set_result(result)
|
|
270
|
+
|
|
271
|
+
def _trace_cmd(self, payload: dict[str, Any]) -> None:
|
|
272
|
+
self._trace(self._trace_cmd_logger, payload)
|
|
273
|
+
|
|
274
|
+
def _trace_event(self, payload: dict[str, Any]) -> None:
|
|
275
|
+
self._trace(self._trace_event_logger, payload)
|
|
276
|
+
|
|
277
|
+
def _trace(
|
|
278
|
+
self, trace_logger: logging.Logger | None, payload: dict[str, Any]
|
|
279
|
+
) -> None:
|
|
280
|
+
if not self._trace_enabled or trace_logger is None:
|
|
281
|
+
return
|
|
282
|
+
# "_ts" (not "ts"): a real message field can be named "ts" too
|
|
283
|
+
# (Keepalive's own payload has one) -- a flat merge with a matching
|
|
284
|
+
# key would silently overwrite this trace-line's own timestamp.
|
|
285
|
+
line = json.dumps({"_ts": _now_iso(), **payload}, default=repr)
|
|
286
|
+
# A DEBUG emit into the instance-scoped trace logger; the write itself
|
|
287
|
+
# happens on `logging_setup`'s listener thread, never on this event loop.
|
|
288
|
+
trace_logger.debug(line)
|
|
Binary file
|
ibcontroller/labels.py
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
"""Window/button labels the built-in recognisers (`recognisers.py`, L5) match
|
|
2
|
+
against -- externalized to JSON rather than hardcoded. If IBKR renames a
|
|
3
|
+
button in a future release, a user patches this file instead of waiting for a
|
|
4
|
+
new ibcontroller release or forking Python code.
|
|
5
|
+
|
|
6
|
+
A bundled default (`data/labels.json`, shipped with the package so
|
|
7
|
+
ibcontroller works out of the box) is layered under an optional
|
|
8
|
+
`labels.json` in `app_dirs.resolve_app_dirs()`'s `config_dir` -- merged one
|
|
9
|
+
field at a time (`{"login": {"gateway_titles": [...]}}` overrides just that
|
|
10
|
+
one field, leaving every other label and domain untouched), not a full
|
|
11
|
+
replacement. `dismiss_rules` (below) is a list, not a nested object, so an
|
|
12
|
+
override replaces the whole list, not one entry within it -- a user
|
|
13
|
+
extending it copies the bundled entries into their own override file rather
|
|
14
|
+
than appending.
|
|
15
|
+
|
|
16
|
+
`dismiss_rules` is a real exception to "this file is just labels, not config
|
|
17
|
+
rules": each entry is a full declarative `Recognizer` (see `DismissRule`'s
|
|
18
|
+
own docstring), not just a name-to-text mapping the way every other group
|
|
19
|
+
here is. It stays here to reuse this module's bundled-default-plus-override
|
|
20
|
+
loader, rather than a second loader elsewhere. A *config rule* in the wider
|
|
21
|
+
sense (arbitrary match -> arbitrary `ACTIONS` sequence, user-authored,
|
|
22
|
+
per-deployment) is still a different, larger thing than a `DismissRule`
|
|
23
|
+
(fixed shape: one match, one click)."""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import json
|
|
28
|
+
from collections.abc import Mapping
|
|
29
|
+
from importlib import resources
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
from typing import Any
|
|
32
|
+
|
|
33
|
+
import attrs
|
|
34
|
+
import cattrs
|
|
35
|
+
|
|
36
|
+
converter = cattrs.Converter()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@attrs.define(frozen=True)
|
|
40
|
+
class LoginLabels:
|
|
41
|
+
gateway_titles: list[str]
|
|
42
|
+
tws_titles: list[str]
|
|
43
|
+
login_buttons: list[str]
|
|
44
|
+
api_type_ib_api: str
|
|
45
|
+
api_type_fix: str
|
|
46
|
+
trading_mode_live: str
|
|
47
|
+
trading_mode_paper: str
|
|
48
|
+
username_field: str
|
|
49
|
+
password_field: str
|
|
50
|
+
starting_application_title: str
|
|
51
|
+
"""Gateway only. The splash/"Authenticating..." frame's *final* title, read
|
|
52
|
+
at `WINDOW_CLOSED` time, is the Gateway login-completion signal --
|
|
53
|
+
the main window opening is not a valid signal, since it renders fully
|
|
54
|
+
before authentication (including 2FA) actually finishes. See
|
|
55
|
+
`login.py`'s `_wait_for_outcome_gateway`."""
|
|
56
|
+
tws_main_window_menu_item: str
|
|
57
|
+
"""TWS only. A `File > Lock Application` menu item existing on a
|
|
58
|
+
newly-opened top-level window is the TWS login-completion signal -- TWS's
|
|
59
|
+
real main window title is account-specific, so a title match can't be
|
|
60
|
+
used here the way it can for Gateway. See `login.py`'s
|
|
61
|
+
`_wait_for_outcome_tws`."""
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@attrs.define(frozen=True)
|
|
65
|
+
class MfaLabels:
|
|
66
|
+
title: str
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@attrs.define(frozen=True)
|
|
70
|
+
class ExistingSessionLabels:
|
|
71
|
+
title: str
|
|
72
|
+
continue_buttons: list[str]
|
|
73
|
+
cancel_buttons: list[str]
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@attrs.define(frozen=True)
|
|
77
|
+
class AcceptIncomingConnectionLabels:
|
|
78
|
+
"""`accept_buttons` tries "OK" then "Yes" in order; `reject_buttons` is
|
|
79
|
+
"No"."""
|
|
80
|
+
|
|
81
|
+
title: str
|
|
82
|
+
accept_buttons: list[str]
|
|
83
|
+
reject_buttons: list[str]
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@attrs.define(frozen=True)
|
|
87
|
+
class LoginFailedLabels:
|
|
88
|
+
title: str
|
|
89
|
+
dismiss_button: str
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class LabelsError(Exception):
|
|
93
|
+
"""A malformed entry in `labels.json` (bundled or override) -- e.g. a
|
|
94
|
+
`DismissRule` with neither `match_title` nor `match_text` set."""
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@attrs.define(frozen=True)
|
|
98
|
+
class DismissRule:
|
|
99
|
+
"""One declarative "just dismiss this" recogniser: a dialog that's always
|
|
100
|
+
recognised the same simple way (title-contains and/or text-contains) and
|
|
101
|
+
always dismissed the same simple way (click one button).
|
|
102
|
+
|
|
103
|
+
`recognisers.DeclarativeDismissRecognizer` implements the `Recognizer`
|
|
104
|
+
Protocol generically for every rule here.
|
|
105
|
+
|
|
106
|
+
At least one of `match_title`/`match_text` is required (checked in
|
|
107
|
+
`__attrs_post_init__`, so a malformed rule fails at `load_labels()` time,
|
|
108
|
+
not silently at first dispatch). If both are given, **both** must match
|
|
109
|
+
(AND, not OR), to avoid over-matching an unrelated dialog that happens to
|
|
110
|
+
share only one of the two conditions. `match_text` checks every
|
|
111
|
+
component's `text` *and* `accessible_name`, since which field a label's
|
|
112
|
+
text lands in depends on the real Swing component type -- `JLabel` text
|
|
113
|
+
only ever surfaces via `accessible_name`."""
|
|
114
|
+
|
|
115
|
+
name: str
|
|
116
|
+
click: str
|
|
117
|
+
match_title: str | None = None
|
|
118
|
+
match_text: str | None = None
|
|
119
|
+
|
|
120
|
+
def __attrs_post_init__(self) -> None:
|
|
121
|
+
if self.match_title is None and self.match_text is None:
|
|
122
|
+
raise LabelsError(
|
|
123
|
+
f"dismiss_rules entry {self.name!r} needs at least one of "
|
|
124
|
+
"'match_title'/'match_text'"
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@attrs.define(frozen=True)
|
|
129
|
+
class TooManyFailedLoginAttemptsLabels:
|
|
130
|
+
"""`message_prefix` matches the start of the dialog text ("Too many
|
|
131
|
+
failed login attempts. Please wait N minute(s) & M second(s) before
|
|
132
|
+
attempting to re-login again."), not the window title."""
|
|
133
|
+
|
|
134
|
+
message_prefix: str
|
|
135
|
+
dismiss_button: str
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@attrs.define(frozen=True)
|
|
139
|
+
class SettingsLabels:
|
|
140
|
+
"""Labels for the Global Configuration dialog (see `settings.py`).
|
|
141
|
+
|
|
142
|
+
`gateway_menu_path` is Gateway's `Configure/Settings`. TWS has no
|
|
143
|
+
`Configure` menu at all; `open_settings_dialog` uses `tws_menu_path`
|
|
144
|
+
(`File/Global Configuration...`, Mosaic layout) -- live-confirmed
|
|
145
|
+
2026-09-16 as the real path, after `tws_menu_path_classic`
|
|
146
|
+
(`Edit/Global Configuration...`) produced a clean `ElementNotFoundError`
|
|
147
|
+
every run (Mosaic has no `Edit/Global Configuration...` item; earlier
|
|
148
|
+
mistaken for #40's wrong-frame race). `tws_menu_path_classic` is defined
|
|
149
|
+
but unused -- a real Classic/Mosaic fallback matching IBC's own
|
|
150
|
+
`GetConfigDialogTask` order is tracked separately as #39.
|
|
151
|
+
|
|
152
|
+
`controls` is a flat name -> widget-label map, deliberately decoupled
|
|
153
|
+
from tree paths (which stay literal on each `settings.SettingEntry` in
|
|
154
|
+
`ibkr_settings.toml`): this dict exists to version-proof against IBKR
|
|
155
|
+
renaming a *label*, not to describe a setting's navigation.
|
|
156
|
+
|
|
157
|
+
`api_precautions_*` covers the API/Precautions panel. The control set is
|
|
158
|
+
account/permission dependent (e.g. market-data entitlements), so a given
|
|
159
|
+
deployment may not have every entry here, and a future account could
|
|
160
|
+
expose controls not listed here.
|
|
161
|
+
|
|
162
|
+
`splash_title_marker` matches the splash/"Starting application..." frame
|
|
163
|
+
that must close before `Configure/Settings` is safely reachable --
|
|
164
|
+
opening the menu too early can silently fail to open the dialog.
|
|
165
|
+
`settings.py`'s `open_settings_dialog` waits for this signal first.
|
|
166
|
+
|
|
167
|
+
`ok_button` is what `close_settings_dialog` clicks to commit and close
|
|
168
|
+
the dialog -- `"OK"` has Swing's standard apply-and-close semantics,
|
|
169
|
+
unlike `"Apply"`, which commits without closing. `apply_button`/
|
|
170
|
+
`cancel_button` are kept for callers that need those semantics
|
|
171
|
+
explicitly."""
|
|
172
|
+
|
|
173
|
+
gateway_menu_path: str
|
|
174
|
+
tws_menu_path: str
|
|
175
|
+
tws_menu_path_classic: str
|
|
176
|
+
dialog_title_marker: str
|
|
177
|
+
apply_button: str
|
|
178
|
+
cancel_button: str
|
|
179
|
+
ok_button: str
|
|
180
|
+
splash_title_marker: str
|
|
181
|
+
controls: dict[str, str]
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
@attrs.define(frozen=True)
|
|
185
|
+
class ShutdownLabels:
|
|
186
|
+
"""The graceful shutdown menu item: Gateway's `File > Close` or TWS's
|
|
187
|
+
`File > Exit`. See `launcher.clean_shutdown`."""
|
|
188
|
+
|
|
189
|
+
gateway_menu_path: str
|
|
190
|
+
tws_menu_path: str
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
@attrs.define(frozen=True)
|
|
194
|
+
class Labels:
|
|
195
|
+
login: LoginLabels
|
|
196
|
+
mfa: MfaLabels
|
|
197
|
+
existing_session: ExistingSessionLabels
|
|
198
|
+
accept_incoming_connection: AcceptIncomingConnectionLabels
|
|
199
|
+
login_failed: LoginFailedLabels
|
|
200
|
+
too_many_failed_login_attempts: TooManyFailedLoginAttemptsLabels
|
|
201
|
+
settings: SettingsLabels
|
|
202
|
+
shutdown: ShutdownLabels
|
|
203
|
+
dismiss_rules: list[DismissRule] = attrs.field(factory=list)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _deep_merge(base: Mapping[str, Any], override: Mapping[str, Any]) -> dict[str, Any]:
|
|
207
|
+
"""`override` wins per-field, not per-domain -- a user's file only needs to name
|
|
208
|
+
the one label that changed, not repeat every sibling field or every other domain
|
|
209
|
+
untouched."""
|
|
210
|
+
merged = dict(base)
|
|
211
|
+
for key, value in override.items():
|
|
212
|
+
existing = merged.get(key)
|
|
213
|
+
if isinstance(existing, Mapping) and isinstance(value, Mapping):
|
|
214
|
+
merged[key] = _deep_merge(existing, value)
|
|
215
|
+
else:
|
|
216
|
+
merged[key] = value
|
|
217
|
+
return merged
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _load_bundled_default() -> dict[str, Any]:
|
|
221
|
+
raw = (
|
|
222
|
+
resources.files("ibcontroller")
|
|
223
|
+
.joinpath("data", "labels.json")
|
|
224
|
+
.read_text(encoding="utf-8")
|
|
225
|
+
)
|
|
226
|
+
return json.loads(raw)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def load_labels(config_dir: str | Path | None = None) -> Labels:
|
|
230
|
+
"""Loads the bundled default, then merges an optional `{config_dir}/labels.json`
|
|
231
|
+
override on top if one exists. `config_dir` is typically
|
|
232
|
+
`app_dirs.resolve_app_dirs()`'s first element -- passed explicitly rather than
|
|
233
|
+
resolved here, matching
|
|
234
|
+
`config.py`'s own convention of callers resolving app dirs once and threading the
|
|
235
|
+
result through."""
|
|
236
|
+
data = _load_bundled_default()
|
|
237
|
+
if config_dir is not None:
|
|
238
|
+
override_path = Path(config_dir) / "labels.json"
|
|
239
|
+
if override_path.is_file():
|
|
240
|
+
override_data = json.loads(override_path.read_text(encoding="utf-8"))
|
|
241
|
+
data = _deep_merge(data, override_data)
|
|
242
|
+
return converter.structure(data, Labels)
|