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,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