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/__init__.py
ADDED
|
File without changes
|
ibcontroller/actions.py
ADDED
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
"""The shared action vocabulary every decision-maker (built-in recognisers,
|
|
2
|
+
config rules, plugins, delegate responses) acts through -- a config rule's
|
|
3
|
+
action string, a delegate's JSON, and a plugin's direct Python call all
|
|
4
|
+
resolve to the same function.
|
|
5
|
+
|
|
6
|
+
Each function is `async def foo(dispatcher, ...)` and runs one command
|
|
7
|
+
through `dispatcher.send_command`, reached via `dispatcher.command_conn` (an
|
|
8
|
+
`AgentCommandConnection`, see `agent_client.py`). Errors are
|
|
9
|
+
`agent_client.py`'s typed exceptions (`ElementNotFoundError`,
|
|
10
|
+
`CredentialRefusedError`, `BadRequestError`, `UnknownCommandError`,
|
|
11
|
+
`AgentError`, `ProtocolError`), propagated unchanged -- this module does not
|
|
12
|
+
re-translate them, except that `click`'s candidate-label fallback below
|
|
13
|
+
branches on `ElementNotFoundError` specifically.
|
|
14
|
+
|
|
15
|
+
`ACTIONS` maps action names to these functions, for callers that select an
|
|
16
|
+
action by name (config rules, delegate JSON, plugins) rather than calling it
|
|
17
|
+
directly. `wait_for_event` is not in `ACTIONS` -- it's a caller-side
|
|
18
|
+
primitive, paired directly with `click`/`navigate_menu` by whoever needs to
|
|
19
|
+
know an action's effect has happened.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import asyncio
|
|
25
|
+
import functools
|
|
26
|
+
from collections.abc import Awaitable, Callable, Sequence
|
|
27
|
+
from typing import Any, cast
|
|
28
|
+
|
|
29
|
+
from typed_settings.types import Secret
|
|
30
|
+
|
|
31
|
+
from ibcontroller.agent_client import (
|
|
32
|
+
Component,
|
|
33
|
+
ElementNotFoundError,
|
|
34
|
+
WindowEvent,
|
|
35
|
+
)
|
|
36
|
+
from ibcontroller.dispatch import Dispatcher
|
|
37
|
+
|
|
38
|
+
_DEFAULT_DISMISS_LABELS = ("OK", "Close", "Dismiss")
|
|
39
|
+
|
|
40
|
+
_MENU_RETRY_INTERVAL = 0.25
|
|
41
|
+
"""Delay, in seconds, between `navigate_menu` retries while the resolved
|
|
42
|
+
menu item is disabled."""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
async def _forward(dispatcher: Dispatcher, method: str, *args: object) -> Any:
|
|
46
|
+
"""Runs one `AgentCommandConnection` method (named by `method`) through
|
|
47
|
+
the dispatcher's command queue. Shared body for every action below that
|
|
48
|
+
adds no logic beyond that."""
|
|
49
|
+
return await dispatcher.send_command(
|
|
50
|
+
functools.partial(getattr(dispatcher.command_conn, method), *args)
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
async def type_text(
|
|
55
|
+
dispatcher: Dispatcher,
|
|
56
|
+
field: str,
|
|
57
|
+
value: str | Secret,
|
|
58
|
+
*,
|
|
59
|
+
window_id: str | None = None,
|
|
60
|
+
) -> None:
|
|
61
|
+
"""Sets `field`'s text to `value`. `value` may be a `Secret`
|
|
62
|
+
(credentials) or a plain string -- only ever unwrapped inside
|
|
63
|
+
`agent_client.set_text`. `window_id`, if given, scopes the action to one
|
|
64
|
+
window."""
|
|
65
|
+
await _forward(dispatcher, "set_text", field, value, window_id)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
async def click(
|
|
69
|
+
dispatcher: Dispatcher,
|
|
70
|
+
labels: str | Sequence[str],
|
|
71
|
+
*,
|
|
72
|
+
window_id: str | None = None,
|
|
73
|
+
) -> None:
|
|
74
|
+
"""Clicks the first of `labels` that resolves. `labels` may be a single
|
|
75
|
+
label or an ordered list of fallback candidates. Only
|
|
76
|
+
`ElementNotFoundError` moves on to the next candidate; any other error
|
|
77
|
+
propagates immediately.
|
|
78
|
+
|
|
79
|
+
Fire-and-forget: confirms the agent accepted the command, not that its
|
|
80
|
+
effect has happened yet -- pair with `wait_for_event` when the caller
|
|
81
|
+
needs to know. `window_id`, if given, scopes the action to one window."""
|
|
82
|
+
candidates = [labels] if isinstance(labels, str) else list(labels)
|
|
83
|
+
last_error: ElementNotFoundError | None = None
|
|
84
|
+
for label in candidates:
|
|
85
|
+
try:
|
|
86
|
+
await dispatcher.send_command(
|
|
87
|
+
functools.partial(dispatcher.command_conn.click, label, window_id)
|
|
88
|
+
)
|
|
89
|
+
return
|
|
90
|
+
except ElementNotFoundError as exc:
|
|
91
|
+
last_error = exc
|
|
92
|
+
assert last_error is not None # candidates is never empty in practice
|
|
93
|
+
raise last_error
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
async def toggle(
|
|
97
|
+
dispatcher: Dispatcher,
|
|
98
|
+
label: str,
|
|
99
|
+
checked: bool,
|
|
100
|
+
*,
|
|
101
|
+
window_id: str | None = None,
|
|
102
|
+
) -> None:
|
|
103
|
+
"""Sets `label`'s checkbox to `checked`. Idempotent on the agent side --
|
|
104
|
+
only clicks if the current state differs from `checked`. `window_id`, if
|
|
105
|
+
given, scopes the action to one window."""
|
|
106
|
+
await _forward(dispatcher, "set_checkbox", label, checked, window_id)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
async def dismiss(
|
|
110
|
+
dispatcher: Dispatcher,
|
|
111
|
+
labels: str | Sequence[str] | None = None,
|
|
112
|
+
*,
|
|
113
|
+
window_id: str | None = None,
|
|
114
|
+
) -> None:
|
|
115
|
+
"""Clicks `labels` to dismiss a generic pop-up, defaulting to common
|
|
116
|
+
OK/Close/Dismiss labels when the caller doesn't have a more specific
|
|
117
|
+
one. `window_id`, if given, scopes the action to one window."""
|
|
118
|
+
await click(
|
|
119
|
+
dispatcher,
|
|
120
|
+
labels if labels is not None else _DEFAULT_DISMISS_LABELS,
|
|
121
|
+
window_id=window_id,
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
async def read_text(dispatcher: Dispatcher, field: str) -> str:
|
|
126
|
+
"""Returns the text of `field`. Raises `CredentialRefusedError` if
|
|
127
|
+
`field` is a password field."""
|
|
128
|
+
return await _forward(dispatcher, "get_text", field)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
async def type_text_near_label(
|
|
132
|
+
dispatcher: Dispatcher,
|
|
133
|
+
label: str,
|
|
134
|
+
index: int,
|
|
135
|
+
value: str | Secret,
|
|
136
|
+
*,
|
|
137
|
+
window_id: str | None = None,
|
|
138
|
+
) -> None:
|
|
139
|
+
"""Sets the text of the `index`-th field found near `label`, for fields
|
|
140
|
+
with no accessible name of their own. `value` may be a `Secret` or a
|
|
141
|
+
plain string, same contract as `type_text`. `window_id`, if given,
|
|
142
|
+
scopes the action to one window."""
|
|
143
|
+
await _forward(dispatcher, "set_text_near_label", label, index, value, window_id)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
async def navigate_menu(
|
|
147
|
+
dispatcher: Dispatcher,
|
|
148
|
+
path: str,
|
|
149
|
+
*,
|
|
150
|
+
timeout: float = 30.0,
|
|
151
|
+
window_id: str | None = None,
|
|
152
|
+
) -> None:
|
|
153
|
+
"""Walks menu `path` (e.g. `"File/Close"`) and clicks the item at the
|
|
154
|
+
end. Use this for menu items -- `click` only reaches components in the
|
|
155
|
+
ordinary component tree, not menu dropdowns.
|
|
156
|
+
|
|
157
|
+
`window_id`, when given, scopes the lookup to that window's own menu
|
|
158
|
+
bar (matching `menu_item_exists`'s scoping) instead of the agent's
|
|
159
|
+
global "first displayable frame with a menu bar" search -- the wrong
|
|
160
|
+
frame can otherwise be picked when more than one displayable frame
|
|
161
|
+
carries a menu bar at once (#40; `open_settings_dialog` passes
|
|
162
|
+
`LoginManager.main_window_id`, once `login.py` has validated it, for
|
|
163
|
+
exactly this reason). `None` (the default) keeps the old global search.
|
|
164
|
+
|
|
165
|
+
Retries every `_MENU_RETRY_INTERVAL` seconds while the resolved item is
|
|
166
|
+
disabled (e.g. a blocking dialog is still open) or not yet resolvable at
|
|
167
|
+
all (e.g. TWS is still populating its menubar right after login -- a
|
|
168
|
+
real race, not a wrong path: IBC avoids it upstream by gating the whole
|
|
169
|
+
config-dialog step on `MainWindowManager.getMainWindow()`/
|
|
170
|
+
`SessionManager.awaitReady()` before ever touching the menu; this
|
|
171
|
+
project's equivalent is `LoginManager.main_window_id` plus this same
|
|
172
|
+
retry as a bounded-poll substitute for IBC's blocking wait, since a
|
|
173
|
+
single asyncio loop doesn't need a `Future`-based one), up to `timeout`
|
|
174
|
+
seconds, then re-raises the last failure (`ElementNotFoundError` or
|
|
175
|
+
`TimeoutError`)."""
|
|
176
|
+
loop = asyncio.get_running_loop()
|
|
177
|
+
deadline = loop.time() + timeout
|
|
178
|
+
while True:
|
|
179
|
+
try:
|
|
180
|
+
clicked = await dispatcher.send_command(
|
|
181
|
+
functools.partial(
|
|
182
|
+
dispatcher.command_conn.navigate_menu, path, window_id
|
|
183
|
+
)
|
|
184
|
+
)
|
|
185
|
+
except ElementNotFoundError:
|
|
186
|
+
if loop.time() >= deadline:
|
|
187
|
+
raise
|
|
188
|
+
await asyncio.sleep(_MENU_RETRY_INTERVAL)
|
|
189
|
+
continue
|
|
190
|
+
if clicked:
|
|
191
|
+
return
|
|
192
|
+
if loop.time() >= deadline:
|
|
193
|
+
raise TimeoutError(f"menu item at {path!r} still disabled after {timeout}s")
|
|
194
|
+
await asyncio.sleep(_MENU_RETRY_INTERVAL)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
async def expand_tree(
|
|
198
|
+
dispatcher: Dispatcher,
|
|
199
|
+
path: str,
|
|
200
|
+
*,
|
|
201
|
+
window_id: str | None = None,
|
|
202
|
+
) -> None:
|
|
203
|
+
"""Selects a section of the Global Configuration dialog's tree (e.g.
|
|
204
|
+
`"API/Settings"`). `window_id`, if given, resolves directly to that
|
|
205
|
+
dialog instead of searching by title."""
|
|
206
|
+
await _forward(dispatcher, "expand_tree", path, window_id)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
async def menu_item_exists(
|
|
210
|
+
dispatcher: Dispatcher,
|
|
211
|
+
path: str,
|
|
212
|
+
*,
|
|
213
|
+
window_id: str,
|
|
214
|
+
) -> tuple[bool, bool]:
|
|
215
|
+
"""Reports whether menu item `path` exists and is enabled under
|
|
216
|
+
`window_id`, without clicking it. Returns `(exists, enabled)`.
|
|
217
|
+
Diagnostic use -- e.g. checking readiness, or researching a new
|
|
218
|
+
declarative recogniser/setting before it's authored."""
|
|
219
|
+
return await _forward(dispatcher, "menu_item_exists", path, window_id)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
async def dump(
|
|
223
|
+
dispatcher: Dispatcher,
|
|
224
|
+
*,
|
|
225
|
+
window: str | None = None,
|
|
226
|
+
window_id: str | None = None,
|
|
227
|
+
) -> list[Component]:
|
|
228
|
+
"""Returns the current component tree, optionally scoped by `window` (a
|
|
229
|
+
title substring) or `window_id` (an exact window, taking precedence when
|
|
230
|
+
both are given). Read-only."""
|
|
231
|
+
return await _forward(dispatcher, "dump", window, window_id)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
async def wait_for_event(
|
|
235
|
+
dispatcher: Dispatcher,
|
|
236
|
+
kind: str,
|
|
237
|
+
matches: Callable[[WindowEvent], bool] = lambda _event: True,
|
|
238
|
+
*,
|
|
239
|
+
timeout: float | None,
|
|
240
|
+
) -> WindowEvent:
|
|
241
|
+
"""Waits for the next `dispatcher.window_events` event of the given
|
|
242
|
+
`kind` for which `matches(event)` is true, up to `timeout` seconds
|
|
243
|
+
(`None` for no timeout). Pairs with `click`/`navigate_menu` where a
|
|
244
|
+
caller needs to know an action's effect has happened, not just that the
|
|
245
|
+
request was sent. Raises `asyncio.TimeoutError` if no matching event
|
|
246
|
+
arrives in time."""
|
|
247
|
+
filtered = dispatcher.window_events.filter(
|
|
248
|
+
lambda event: event.kind == kind and matches(event)
|
|
249
|
+
)
|
|
250
|
+
try:
|
|
251
|
+
# window_events only ever emits WindowEvent (dispatch.py's
|
|
252
|
+
# _event_reader), so this narrows what's already true at runtime.
|
|
253
|
+
return cast(WindowEvent, await asyncio.wait_for(filtered, timeout=timeout))
|
|
254
|
+
finally:
|
|
255
|
+
dispatcher.window_events.disconnect(
|
|
256
|
+
filtered.on_source, filtered.on_source_error, filtered.on_source_done
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
ACTIONS: dict[str, Callable[..., Awaitable[object]]] = {
|
|
261
|
+
"type_text": type_text,
|
|
262
|
+
"click": click,
|
|
263
|
+
"toggle": toggle,
|
|
264
|
+
"dismiss": dismiss,
|
|
265
|
+
"read_text": read_text,
|
|
266
|
+
"navigate_menu": navigate_menu,
|
|
267
|
+
"expand_tree": expand_tree,
|
|
268
|
+
"type_text_near_label": type_text_near_label,
|
|
269
|
+
"menu_item_exists": menu_item_exists,
|
|
270
|
+
"dump": dump,
|
|
271
|
+
}
|
|
272
|
+
"""Name -> function table for callers (config rules, delegate JSON, plugins)
|
|
273
|
+
that select an action by name rather than calling it directly."""
|