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.
@@ -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)