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/settings.py
ADDED
|
@@ -0,0 +1,554 @@
|
|
|
1
|
+
"""settings.py -- L5's Settings domain, alongside `login.py` (Login) and the
|
|
2
|
+
not-yet-built `management.py` (Management).
|
|
3
|
+
|
|
4
|
+
Drives IBKR's Global Configuration dialog: open it, navigate to a section,
|
|
5
|
+
set a value, repeat for every declared setting, then click "OK" once to
|
|
6
|
+
commit and close. A plain sequence, not a state machine -- built on the same
|
|
7
|
+
`actions.py` primitives (`navigate_menu`, `expand_tree`, `toggle`, `type_text`,
|
|
8
|
+
`type_text_near_label`, `click`) every other domain uses.
|
|
9
|
+
|
|
10
|
+
Settings are declared in TOML, not code (`SettingEntry`/`SettingsFile`, loaded
|
|
11
|
+
via `load_settings_file`/`load_builtin_settings_file`). Two tiers:
|
|
12
|
+
|
|
13
|
+
- **Built-in** (`data/builtin_settings.toml`, always loaded): `read_only_api`
|
|
14
|
+
and `auto_restart_time`, the load-bearing settings this project's own
|
|
15
|
+
"Scope, deliberately" section calls out. Sourced from `Config` fields, so
|
|
16
|
+
they apply from `ibcontroller.toml` alone, no extra file needed.
|
|
17
|
+
- **User** (`ibkr_settings.toml`, via `Config.settings_file`, opt-in): anything
|
|
18
|
+
else a deployment wants to set, e.g. the `api_precautions_*` controls.
|
|
19
|
+
|
|
20
|
+
`merge_settings_files` combines them: a user entry whose `value_from_config`
|
|
21
|
+
matches a built-in entry replaces it (e.g. to correct a control that moved in
|
|
22
|
+
a newer TWS/Gateway release); everything else is unioned in, built-ins first.
|
|
23
|
+
|
|
24
|
+
Each `SettingEntry` names its target via `label` (a literal widget label) or
|
|
25
|
+
`label_ref` (resolved through `labels.settings.controls`, version-proofed),
|
|
26
|
+
and its value via a literal `value` or `value_from_config` (an attribute
|
|
27
|
+
looked up on `Config` at apply time). A value of `None`, from either source,
|
|
28
|
+
means "leave this setting alone" -- the deliberate three-state (set/clear/
|
|
29
|
+
leave-unchanged) enable/disable mechanism, matching IBC's own `config.ini`
|
|
30
|
+
semantics for yes/no settings. `_SETTINGS_ELIGIBLE_FIELDS` is the closed set
|
|
31
|
+
of `Config` fields a `value_from_config` may name; adding a new built-in
|
|
32
|
+
setting means extending this set and adding the `Config` field together.
|
|
33
|
+
|
|
34
|
+
Five actions are supported: `toggle`, `type_text`, `type_text_near_label`,
|
|
35
|
+
`auto_restart_time`, and `auto_logoff_time` (each of the last two, one config
|
|
36
|
+
string driving three underlying writes: the time field, the AM/PM radio, and
|
|
37
|
+
the "Auto restart"/"Auto logoff" radio -- see `_parse_time_ampm`; both share
|
|
38
|
+
the same Lock and Exit radio-button pair, so `builtin_settings.toml` applies
|
|
39
|
+
`auto_logoff_time` before `auto_restart_time` so that, if both are set, the
|
|
40
|
+
last-applied wins the shared radio group, matching IBC's own stated
|
|
41
|
+
precedence). Button-press entries with no value (e.g. IBC's "Reset API order
|
|
42
|
+
ID sequence") and dropdown (`JComboBox`) controls aren't supported yet --
|
|
43
|
+
nothing declared so far needs them.
|
|
44
|
+
|
|
45
|
+
`apply_settings_from_file` applies at instance startup only, before or
|
|
46
|
+
around Login (not from the future control API/L9, which is a separate
|
|
47
|
+
runtime trigger into the same `SettingEntry` mechanism). Each entry's
|
|
48
|
+
failure is caught and logged independently, so one bad entry doesn't abort
|
|
49
|
+
the rest of the file."""
|
|
50
|
+
|
|
51
|
+
from __future__ import annotations
|
|
52
|
+
|
|
53
|
+
import asyncio
|
|
54
|
+
import contextlib
|
|
55
|
+
import logging
|
|
56
|
+
import tomllib
|
|
57
|
+
from datetime import datetime
|
|
58
|
+
from importlib import resources
|
|
59
|
+
from pathlib import Path
|
|
60
|
+
|
|
61
|
+
import attrs
|
|
62
|
+
import cattrs
|
|
63
|
+
from anyio import Path as AsyncPath
|
|
64
|
+
from anyio.to_thread import run_sync
|
|
65
|
+
|
|
66
|
+
from ibcontroller.actions import (
|
|
67
|
+
click,
|
|
68
|
+
dump,
|
|
69
|
+
expand_tree,
|
|
70
|
+
navigate_menu,
|
|
71
|
+
toggle,
|
|
72
|
+
type_text,
|
|
73
|
+
type_text_near_label,
|
|
74
|
+
wait_for_event,
|
|
75
|
+
)
|
|
76
|
+
from ibcontroller.agent_client import ElementNotFoundError
|
|
77
|
+
from ibcontroller.config import Config
|
|
78
|
+
from ibcontroller.dispatch import Dispatcher
|
|
79
|
+
from ibcontroller.labels import Labels, SettingsLabels
|
|
80
|
+
|
|
81
|
+
logger = logging.getLogger(__name__)
|
|
82
|
+
|
|
83
|
+
converter = cattrs.Converter()
|
|
84
|
+
_NOON_HOUR = 12 # datetime.hour is 0-23; below this is AM, at/above is PM
|
|
85
|
+
# Single source of truth for which Config fields a "value_from_config" may
|
|
86
|
+
# name -- both _resolve_value's allow-list and load_settings_file's eager
|
|
87
|
+
# warning read this same set. hasattr(config, ...) alone would let an entry
|
|
88
|
+
# reach any Config attribute, including Secret-wrapped credentials
|
|
89
|
+
# (userid/password). Extend this set (and config.py) when a new built-in
|
|
90
|
+
# setting is added.
|
|
91
|
+
_SETTINGS_ELIGIBLE_FIELDS = frozenset(
|
|
92
|
+
{"read_only_api", "auto_restart_time", "auto_logoff_time"}
|
|
93
|
+
)
|
|
94
|
+
# SettingEntry.value (bool | str | None) has no discriminator cattrs can use
|
|
95
|
+
# on its own -- but tomllib.load() already parses TOML's own real
|
|
96
|
+
# bool/string/absent types into native Python values before cattrs ever sees
|
|
97
|
+
# them, so a plain passthrough is the correct hook.
|
|
98
|
+
converter.register_structure_hook(bool | str | None, lambda v, _: v)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class SettingsError(Exception):
|
|
102
|
+
"""Raised for conditions specific to this module's own sequencing and
|
|
103
|
+
entry resolution: the Global Configuration dialog never appeared, a
|
|
104
|
+
wanted section/setting couldn't be found, or a `SettingEntry` is
|
|
105
|
+
malformed (unknown `action`, neither/both of `label`/`label_ref` given,
|
|
106
|
+
an unresolvable `label_ref`/`value_from_config`). `actions.py`'s own
|
|
107
|
+
typed exceptions (`TimeoutError`, `ElementNotFoundError`) propagate
|
|
108
|
+
unchanged where relevant."""
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@attrs.define(frozen=True)
|
|
112
|
+
class SettingEntry:
|
|
113
|
+
"""One row of `ibkr_settings.toml` -- see the module docstring for the
|
|
114
|
+
`label`/`label_ref`/`value`/`value_from_config` resolution rules."""
|
|
115
|
+
|
|
116
|
+
tree_path: str
|
|
117
|
+
action: str
|
|
118
|
+
label: str | None = None
|
|
119
|
+
label_ref: str | None = None
|
|
120
|
+
value: bool | str | None = None
|
|
121
|
+
value_from_config: str | None = None
|
|
122
|
+
field_index: int = 0
|
|
123
|
+
"""Used only by `"type_text_near_label"` entries, to find a field with
|
|
124
|
+
no accessible name of its own by position within the container below
|
|
125
|
+
the matched label. Ignored by every other action."""
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@attrs.define(frozen=True)
|
|
129
|
+
class SettingsFile:
|
|
130
|
+
settings: list[SettingEntry] = attrs.field(factory=list)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
async def load_settings_file(path: str | Path) -> SettingsFile:
|
|
134
|
+
"""Loads `path` as TOML into a `SettingsFile`. Used for a user's own
|
|
135
|
+
`ibkr_settings.toml` (`Config.settings_file`, `None` unless a caller
|
|
136
|
+
explicitly points at a real file) -- see `load_builtin_settings_file`
|
|
137
|
+
for the always-loaded built-in counterpart.
|
|
138
|
+
|
|
139
|
+
Warns (without raising, and without dropping the entry) for any
|
|
140
|
+
`value_from_config` naming a field outside `_SETTINGS_ELIGIBLE_FIELDS`,
|
|
141
|
+
since such an entry can never resolve to a real setting and will always
|
|
142
|
+
be skipped at apply time.
|
|
143
|
+
|
|
144
|
+
Read via `anyio.Path`, off the event loop -- called once per launch/
|
|
145
|
+
restart from `control_loop._apply_declarative_settings`, concurrently
|
|
146
|
+
with the Dispatcher's own event/command tasks while the Configuration
|
|
147
|
+
dialog is open on the live Gateway.
|
|
148
|
+
|
|
149
|
+
Raises `SettingsError` (never a raw `OSError`/`tomllib.TOMLDecodeError`/
|
|
150
|
+
cattrs error) for a missing/unreadable file, invalid TOML, or a
|
|
151
|
+
structure that doesn't match `SettingsFile` -- `control_loop` catches
|
|
152
|
+
this specifically to fall back to built-in-only settings rather than
|
|
153
|
+
losing the whole settings step to one bad user file."""
|
|
154
|
+
try:
|
|
155
|
+
raw = await AsyncPath(Path(path).expanduser()).read_bytes()
|
|
156
|
+
except OSError as exc:
|
|
157
|
+
raise SettingsError(f"could not read settings file {path}: {exc}") from exc
|
|
158
|
+
try:
|
|
159
|
+
data = tomllib.loads(raw.decode("utf-8"))
|
|
160
|
+
except tomllib.TOMLDecodeError as exc:
|
|
161
|
+
raise SettingsError(f"settings file {path} is not valid TOML: {exc}") from exc
|
|
162
|
+
try:
|
|
163
|
+
settings_file = converter.structure(data, SettingsFile)
|
|
164
|
+
except cattrs.errors.ClassValidationError as exc:
|
|
165
|
+
raise SettingsError(
|
|
166
|
+
f"settings file {path} has an invalid structure: {exc}"
|
|
167
|
+
) from exc
|
|
168
|
+
for entry in settings_file.settings:
|
|
169
|
+
if (
|
|
170
|
+
entry.value_from_config is not None
|
|
171
|
+
and entry.value_from_config not in _SETTINGS_ELIGIBLE_FIELDS
|
|
172
|
+
):
|
|
173
|
+
logger.warning(
|
|
174
|
+
"IBController > settings: %s entry %s (%s) has "
|
|
175
|
+
"value_from_config=%r, which is not a real ibcontroller.toml "
|
|
176
|
+
"field -- this entry will never apply. Use a literal 'value' "
|
|
177
|
+
"instead, or check the README's Settings table.",
|
|
178
|
+
path,
|
|
179
|
+
entry.tree_path,
|
|
180
|
+
entry.label or entry.label_ref,
|
|
181
|
+
entry.value_from_config,
|
|
182
|
+
)
|
|
183
|
+
return settings_file
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
async def load_builtin_settings_file() -> SettingsFile:
|
|
187
|
+
"""Loads the bundled `data/builtin_settings.toml` -- `read_only_api`/
|
|
188
|
+
`auto_restart_time`, always parsed and always applied regardless of
|
|
189
|
+
whether `Config.settings_file` is set.
|
|
190
|
+
|
|
191
|
+
`resources.files(...)` returns an `importlib.resources` `Traversable`,
|
|
192
|
+
not necessarily a real `pathlib.Path` (e.g. inside a zipped wheel), so
|
|
193
|
+
this can't use `anyio.Path` directly -- `anyio.to_thread.run_sync` wraps
|
|
194
|
+
the whole `read_bytes()` call off the event loop instead, same reasoning
|
|
195
|
+
as `launcher.build_launch_plan`'s own wrap."""
|
|
196
|
+
resource = resources.files("ibcontroller").joinpath("data", "builtin_settings.toml")
|
|
197
|
+
raw = await run_sync(resource.read_bytes)
|
|
198
|
+
data = tomllib.loads(raw.decode("utf-8"))
|
|
199
|
+
return converter.structure(data, SettingsFile)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def merge_settings_files(
|
|
203
|
+
builtin: SettingsFile, user: SettingsFile | None
|
|
204
|
+
) -> list[SettingEntry]:
|
|
205
|
+
"""Combines `builtin`'s always-on entries with `user`'s optional
|
|
206
|
+
entries. A user entry whose `value_from_config` matches a built-in
|
|
207
|
+
entry's *replaces* it (the override case); every other entry (an
|
|
208
|
+
un-overridden built-in, or any other user entry, including one with no
|
|
209
|
+
`value_from_config` set at all) is unioned in, built-ins first in their
|
|
210
|
+
own order, then the user's remaining entries in theirs."""
|
|
211
|
+
user_entries = user.settings if user is not None else []
|
|
212
|
+
user_by_key = {
|
|
213
|
+
entry.value_from_config: entry
|
|
214
|
+
for entry in user_entries
|
|
215
|
+
if entry.value_from_config is not None
|
|
216
|
+
}
|
|
217
|
+
merged: list[SettingEntry] = []
|
|
218
|
+
overridden_keys: set[str] = set()
|
|
219
|
+
for entry in builtin.settings:
|
|
220
|
+
key = entry.value_from_config
|
|
221
|
+
if key is not None and key in user_by_key:
|
|
222
|
+
merged.append(user_by_key[key])
|
|
223
|
+
overridden_keys.add(key)
|
|
224
|
+
else:
|
|
225
|
+
merged.append(entry)
|
|
226
|
+
for entry in user_entries:
|
|
227
|
+
if entry.value_from_config in overridden_keys:
|
|
228
|
+
continue # already merged in above, in the built-in's own slot
|
|
229
|
+
merged.append(entry)
|
|
230
|
+
return merged
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _resolve_control(labels: SettingsLabels, name: str) -> str:
|
|
234
|
+
try:
|
|
235
|
+
return labels.controls[name]
|
|
236
|
+
except KeyError:
|
|
237
|
+
raise SettingsError(f"settings.controls has no entry named {name!r}") from None
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _resolve_label(entry: SettingEntry, labels: SettingsLabels) -> str:
|
|
241
|
+
if entry.label is not None:
|
|
242
|
+
return entry.label
|
|
243
|
+
if entry.label_ref is not None:
|
|
244
|
+
return _resolve_control(labels, entry.label_ref)
|
|
245
|
+
raise SettingsError("a SettingEntry needs either 'label' or 'label_ref'")
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _parse_time_ampm(value: str, *, setting_name: str) -> tuple[str, str]:
|
|
249
|
+
"""Parses an `"hh:mm AM/PM"` string (e.g. `"08:00 AM"`) into `(time,
|
|
250
|
+
ampm)`, where `time` is the zero-padded 12-hour `"HH:MM"` string for the
|
|
251
|
+
time field and `ampm` is `"AM"`/`"PM"` for the radio pair. Raises
|
|
252
|
+
`SettingsError` on a malformed value. Shared by both `"auto_restart_time"`
|
|
253
|
+
and `"auto_logoff_time"` -- `setting_name` only names which one in the
|
|
254
|
+
error message."""
|
|
255
|
+
try:
|
|
256
|
+
parsed = datetime.strptime(value, "%I:%M %p")
|
|
257
|
+
except ValueError:
|
|
258
|
+
raise SettingsError(
|
|
259
|
+
f"{setting_name} setting must be hh:mm AM or hh:mm PM, for "
|
|
260
|
+
f'example "09:30 AM" or "04:00 PM", got {value!r}'
|
|
261
|
+
) from None
|
|
262
|
+
return parsed.strftime("%I:%M"), ("AM" if parsed.hour < _NOON_HOUR else "PM")
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _resolve_value(entry: SettingEntry, config: Config) -> bool | str | None:
|
|
266
|
+
if entry.value_from_config is not None:
|
|
267
|
+
if entry.value_from_config not in _SETTINGS_ELIGIBLE_FIELDS:
|
|
268
|
+
raise SettingsError(
|
|
269
|
+
f"Config has no field named {entry.value_from_config!r}"
|
|
270
|
+
)
|
|
271
|
+
return getattr(config, entry.value_from_config)
|
|
272
|
+
return entry.value
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
async def _await_menu_ready(
|
|
276
|
+
dispatcher: Dispatcher,
|
|
277
|
+
labels: SettingsLabels,
|
|
278
|
+
*,
|
|
279
|
+
timeout: float,
|
|
280
|
+
) -> None:
|
|
281
|
+
"""Waits for Gateway's own splash frame to close before Configure/
|
|
282
|
+
Settings is touched at all -- Gateway raises an error if that menu is
|
|
283
|
+
accessed before the splash closes, even once the item itself is
|
|
284
|
+
enabled.
|
|
285
|
+
|
|
286
|
+
Check-then-wait, not wait-then-check: the splash may have already
|
|
287
|
+
closed by the time this runs (the common case, since login/2FA/
|
|
288
|
+
non-brokerage-dialog dismissal all take real time first), so a bare
|
|
289
|
+
`wait_for_event` could hang forever waiting for a `window_closed` that
|
|
290
|
+
already happened. Arms the wait first, then checks via
|
|
291
|
+
`dump(window=...)` whether the splash is *currently* still open; if
|
|
292
|
+
not, the armed wait is cancelled and discarded rather than awaited."""
|
|
293
|
+
wait_future = asyncio.ensure_future(
|
|
294
|
+
wait_for_event(
|
|
295
|
+
dispatcher,
|
|
296
|
+
"window_closed",
|
|
297
|
+
lambda e: labels.splash_title_marker in (e.window.title or ""),
|
|
298
|
+
timeout=timeout,
|
|
299
|
+
)
|
|
300
|
+
)
|
|
301
|
+
await asyncio.sleep(0) # let .filter() connect before checking current state
|
|
302
|
+
still_open = await dump(dispatcher, window=labels.splash_title_marker)
|
|
303
|
+
if not still_open:
|
|
304
|
+
wait_future.cancel()
|
|
305
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
306
|
+
await wait_future
|
|
307
|
+
return
|
|
308
|
+
await wait_future
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
async def open_settings_dialog(
|
|
312
|
+
dispatcher: Dispatcher,
|
|
313
|
+
labels: SettingsLabels,
|
|
314
|
+
*,
|
|
315
|
+
program: str = "gateway",
|
|
316
|
+
timeout: float = 180.0,
|
|
317
|
+
main_window_id: str | None = None,
|
|
318
|
+
) -> str | None:
|
|
319
|
+
"""Opens the Global Configuration dialog and waits for it to actually
|
|
320
|
+
appear (matched by the window title containing `labels.dialog_title_marker`,
|
|
321
|
+
never an exact match -- the real title is account/trading-mode-specific).
|
|
322
|
+
|
|
323
|
+
`program` selects `labels.gateway_menu_path` (`"Configure/Settings"`) or,
|
|
324
|
+
for TWS, `labels.tws_menu_path` (`"File/Global Configuration..."`,
|
|
325
|
+
Mosaic layout -- the only layout live-confirmed so far). TWS has no
|
|
326
|
+
`Configure` menu at all. Mosaic-only for now -- Classic
|
|
327
|
+
(`labels.tws_menu_path_classic`, `"Edit/Global Configuration..."`) was
|
|
328
|
+
the original guess, live-disproven 2026-09-16 (Classic's `Edit` menu
|
|
329
|
+
doesn't exist under Mosaic, a clean `ElementNotFoundError` every time,
|
|
330
|
+
not the #40 wrong-frame race it was mistaken for); a real
|
|
331
|
+
Classic/Mosaic fallback is tracked separately as #39.
|
|
332
|
+
|
|
333
|
+
`main_window_id`, when given (`control_loop.py` passes
|
|
334
|
+
`LoginManager.main_window_id`), scopes `navigate_menu` to that exact,
|
|
335
|
+
already-validated main window instead of the agent's own global
|
|
336
|
+
"first displayable frame with a menu bar" guess -- fixes #40, the
|
|
337
|
+
live/paper split traced to that guess landing on the wrong frame.
|
|
338
|
+
`None` (Gateway's path never captures one -- no ambiguity there) keeps
|
|
339
|
+
the old global search.
|
|
340
|
+
|
|
341
|
+
`timeout` bounds the wait for the splash frame to close
|
|
342
|
+
(`_await_menu_ready`), the menu navigation itself (`navigate_menu`
|
|
343
|
+
retries while the resolved item is disabled or not yet resolvable, e.g.
|
|
344
|
+
TWS is still populating its menubar right after login -- a real,
|
|
345
|
+
live-caught race, see #37), and the wait for the dialog to open; needs
|
|
346
|
+
to be long enough to span a real 2FA wait, since Settings may be
|
|
347
|
+
attempted right after login while Gateway is still gated on a human
|
|
348
|
+
approving 2FA -- callers should pass
|
|
349
|
+
`config.mfa_timeout`, not rely on this
|
|
350
|
+
function's own default, to stay in sync with `login.py`'s own timeout.
|
|
351
|
+
|
|
352
|
+
Returns the dialog's own `window_id`, captured from the `window_opened`
|
|
353
|
+
event this function waits for, so every subsequent action against this
|
|
354
|
+
dialog (`apply_settings_from_file`, `close_settings_dialog`) can scope
|
|
355
|
+
to this one window instead of searching every open window. `None` only
|
|
356
|
+
if the recognised event's own title somehow lacked
|
|
357
|
+
`dialog_title_marker` (never happens in practice, since the wait
|
|
358
|
+
predicate already requires it).
|
|
359
|
+
|
|
360
|
+
The dialog-open wait is armed before the menu is touched (so its
|
|
361
|
+
`window_opened` can't be missed), then guaranteed cancelled and drained
|
|
362
|
+
if the menu navigation raises -- otherwise the armed wait leaks to its
|
|
363
|
+
own `timeout` and asyncio complains about an unretrieved exception at GC
|
|
364
|
+
time (#38, a direct consequence of this same gap)."""
|
|
365
|
+
await _await_menu_ready(dispatcher, labels, timeout=timeout)
|
|
366
|
+
wait_future = asyncio.ensure_future(
|
|
367
|
+
wait_for_event(
|
|
368
|
+
dispatcher,
|
|
369
|
+
"window_opened",
|
|
370
|
+
lambda e: labels.dialog_title_marker in (e.window.title or ""),
|
|
371
|
+
timeout=timeout,
|
|
372
|
+
)
|
|
373
|
+
)
|
|
374
|
+
await asyncio.sleep(0) # let the wait's own .filter() connect before we click
|
|
375
|
+
try:
|
|
376
|
+
menu_path = (
|
|
377
|
+
labels.gateway_menu_path
|
|
378
|
+
if program.lower() == "gateway"
|
|
379
|
+
else labels.tws_menu_path
|
|
380
|
+
)
|
|
381
|
+
await navigate_menu(
|
|
382
|
+
dispatcher, menu_path, timeout=timeout, window_id=main_window_id
|
|
383
|
+
)
|
|
384
|
+
except BaseException:
|
|
385
|
+
# wait_future's own `timeout` runs concurrently with the menu-nav
|
|
386
|
+
# attempt above and can expire first -- it may already be done with a
|
|
387
|
+
# `TimeoutError` of its own by the time we get here, not just
|
|
388
|
+
# cancellable, so draining it must discard whatever it produced,
|
|
389
|
+
# not just a `CancelledError`; the real error is `raise`d below.
|
|
390
|
+
wait_future.cancel()
|
|
391
|
+
with contextlib.suppress(Exception, asyncio.CancelledError):
|
|
392
|
+
await wait_future
|
|
393
|
+
raise
|
|
394
|
+
event = await wait_future
|
|
395
|
+
return event.window.window_id
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
async def close_settings_dialog(
|
|
399
|
+
dispatcher: Dispatcher,
|
|
400
|
+
labels: SettingsLabels,
|
|
401
|
+
*,
|
|
402
|
+
window_id: str | None = None,
|
|
403
|
+
) -> None:
|
|
404
|
+
"""Commits every change made since the dialog was opened and closes it,
|
|
405
|
+
via a click on `labels.ok_button` (`"OK"`, not `"Apply"`, which commits
|
|
406
|
+
without closing).
|
|
407
|
+
|
|
408
|
+
`window_id`, if given (normally `open_settings_dialog`'s own return
|
|
409
|
+
value), scopes the click to this one dialog instead of an unscoped
|
|
410
|
+
search that could land on a different, simultaneously-open window's own
|
|
411
|
+
same-labeled button."""
|
|
412
|
+
await click(dispatcher, labels.ok_button, window_id=window_id)
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
async def apply_settings_from_file( # noqa: PLR0912 -- per-entry try/except (below)
|
|
416
|
+
dispatcher: Dispatcher,
|
|
417
|
+
labels: Labels,
|
|
418
|
+
config: Config,
|
|
419
|
+
settings_file: SettingsFile,
|
|
420
|
+
*,
|
|
421
|
+
window_id: str | None = None,
|
|
422
|
+
) -> None:
|
|
423
|
+
"""Applies every entry in `settings_file`, in order, against an already-
|
|
424
|
+
open Configuration dialog -- callers sequence `open_settings_dialog` ->
|
|
425
|
+
`apply_settings_from_file` -> `close_settings_dialog`. An entry whose
|
|
426
|
+
resolved value is `None` (either a literal `value = nothing` or a
|
|
427
|
+
`value_from_config` field that's currently `None`) is skipped entirely
|
|
428
|
+
-- leave-unchanged.
|
|
429
|
+
|
|
430
|
+
`window_id`, if given (normally `open_settings_dialog`'s own return
|
|
431
|
+
value), scopes every action below to the one Configuration dialog it
|
|
432
|
+
opened.
|
|
433
|
+
|
|
434
|
+
Each entry's own `SettingsError`/`ElementNotFoundError` is caught,
|
|
435
|
+
logged, and skipped independently, so one bad entry costs exactly that
|
|
436
|
+
entry, not the ones after it."""
|
|
437
|
+
for entry in settings_file.settings:
|
|
438
|
+
try:
|
|
439
|
+
value = _resolve_value(entry, config)
|
|
440
|
+
if value is None:
|
|
441
|
+
logger.info(
|
|
442
|
+
"IBController > settings: skipping %s (%s) -- value "
|
|
443
|
+
"resolved to None",
|
|
444
|
+
entry.tree_path,
|
|
445
|
+
entry.label or entry.label_ref,
|
|
446
|
+
)
|
|
447
|
+
continue
|
|
448
|
+
await expand_tree(dispatcher, entry.tree_path, window_id=window_id)
|
|
449
|
+
if entry.action in ("auto_restart_time", "auto_logoff_time"):
|
|
450
|
+
# No `label`/`label_ref` to resolve -- unlike every other action,
|
|
451
|
+
# these already know all three underlying controls (the time
|
|
452
|
+
# field, the AM/PM radio pair, the enable radio) via
|
|
453
|
+
# labels.settings.controls, so there's nothing for the entry
|
|
454
|
+
# itself to name.
|
|
455
|
+
if not isinstance(value, str):
|
|
456
|
+
raise SettingsError(
|
|
457
|
+
f"'{entry.action}' entries need a string value, got {value!r}"
|
|
458
|
+
)
|
|
459
|
+
time_str, ampm = _parse_time_ampm(value, setting_name=entry.action)
|
|
460
|
+
logger.info(
|
|
461
|
+
"IBController > settings: applying %s (%s) = %s %s",
|
|
462
|
+
entry.tree_path,
|
|
463
|
+
entry.action,
|
|
464
|
+
time_str,
|
|
465
|
+
ampm,
|
|
466
|
+
)
|
|
467
|
+
restart_time_label = _resolve_control(
|
|
468
|
+
labels.settings, "auto_restart_time_label"
|
|
469
|
+
)
|
|
470
|
+
logoff_time_label = _resolve_control(
|
|
471
|
+
labels.settings, "auto_logoff_time_label"
|
|
472
|
+
)
|
|
473
|
+
ampm_label = _resolve_control(
|
|
474
|
+
labels.settings, "am_radio" if ampm == "AM" else "pm_radio"
|
|
475
|
+
)
|
|
476
|
+
enable_control = (
|
|
477
|
+
"auto_restart_radio"
|
|
478
|
+
if entry.action == "auto_restart_time"
|
|
479
|
+
else "auto_logoff_radio"
|
|
480
|
+
)
|
|
481
|
+
enable_label = _resolve_control(labels.settings, enable_control)
|
|
482
|
+
# The time field's own accessible label reflects whichever of
|
|
483
|
+
# Auto Logoff/Auto Restart is *currently* selected -- a fresh
|
|
484
|
+
# settings dir defaults to "Auto logoff", so the field reads
|
|
485
|
+
# "Set Auto Log Off Time (HH:MM)" until "Auto restart" is
|
|
486
|
+
# actually selected. Tries both labels, since it's the same
|
|
487
|
+
# physical field either way -- write order is time, then
|
|
488
|
+
# AM/PM, then the enable radio last.
|
|
489
|
+
try:
|
|
490
|
+
await type_text_near_label(
|
|
491
|
+
dispatcher,
|
|
492
|
+
logoff_time_label,
|
|
493
|
+
entry.field_index,
|
|
494
|
+
time_str,
|
|
495
|
+
window_id=window_id,
|
|
496
|
+
)
|
|
497
|
+
except ElementNotFoundError:
|
|
498
|
+
await type_text_near_label(
|
|
499
|
+
dispatcher,
|
|
500
|
+
restart_time_label,
|
|
501
|
+
entry.field_index,
|
|
502
|
+
time_str,
|
|
503
|
+
window_id=window_id,
|
|
504
|
+
)
|
|
505
|
+
await toggle(dispatcher, ampm_label, True, window_id=window_id)
|
|
506
|
+
await toggle(dispatcher, enable_label, True, window_id=window_id)
|
|
507
|
+
continue
|
|
508
|
+
label = _resolve_label(entry, labels.settings)
|
|
509
|
+
logger.info(
|
|
510
|
+
"IBController > settings: applying %s (%s) %s = %r",
|
|
511
|
+
entry.tree_path,
|
|
512
|
+
label,
|
|
513
|
+
entry.action,
|
|
514
|
+
value,
|
|
515
|
+
)
|
|
516
|
+
if entry.action == "toggle":
|
|
517
|
+
if not isinstance(value, bool):
|
|
518
|
+
raise SettingsError(
|
|
519
|
+
f"'toggle' entries need a bool value, got {value!r}"
|
|
520
|
+
)
|
|
521
|
+
await toggle(dispatcher, label, value, window_id=window_id)
|
|
522
|
+
elif entry.action == "type_text":
|
|
523
|
+
if not isinstance(value, str):
|
|
524
|
+
raise SettingsError(
|
|
525
|
+
f"'type_text' entries need a string value, got {value!r}"
|
|
526
|
+
)
|
|
527
|
+
await type_text(dispatcher, label, value, window_id=window_id)
|
|
528
|
+
elif entry.action == "type_text_near_label":
|
|
529
|
+
if not isinstance(value, str):
|
|
530
|
+
raise SettingsError(
|
|
531
|
+
"'type_text_near_label' entries need a string "
|
|
532
|
+
f"value, got {value!r}"
|
|
533
|
+
)
|
|
534
|
+
await type_text_near_label(
|
|
535
|
+
dispatcher,
|
|
536
|
+
label,
|
|
537
|
+
entry.field_index,
|
|
538
|
+
value,
|
|
539
|
+
window_id=window_id,
|
|
540
|
+
)
|
|
541
|
+
else:
|
|
542
|
+
raise SettingsError(
|
|
543
|
+
f"unsupported settings action {entry.action!r} -- only "
|
|
544
|
+
"'toggle'/'type_text'/'type_text_near_label'/"
|
|
545
|
+
"'auto_restart_time'/'auto_logoff_time' are supported"
|
|
546
|
+
)
|
|
547
|
+
except (SettingsError, ElementNotFoundError) as exc:
|
|
548
|
+
logger.error(
|
|
549
|
+
"IBController > settings: entry %s (%s) failed -- skipping: %s",
|
|
550
|
+
entry.tree_path,
|
|
551
|
+
entry.label or entry.label_ref,
|
|
552
|
+
exc,
|
|
553
|
+
)
|
|
554
|
+
continue
|