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.
File without changes
@@ -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."""