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/main.py ADDED
@@ -0,0 +1,156 @@
1
+ """ibcontroller's single-instance runner -- everything `cli.py`'s `run`/`init` commands
2
+ need beyond argument parsing, which is deliberately kept there (this module has no
3
+ `typer` dependency, so it stays directly testable/runnable without going through a CLI
4
+ invocation -- `python -m ibcontroller.main` still works).
5
+
6
+ **Config-dir scaffolding (`ensure_config_scaffold`)** Three bundled
7
+ templates ship under `ibcontroller/data/` (`ibcontroller.toml.example`,
8
+ `ibkr_settings.toml.example`, `labels.json.example`) and get copied into `config_dir`
9
+ the first time each is missing -- but only `ibcontroller.toml` is copied under its
10
+ *real* filename. The other two keep their `.example` suffix: `Config.settings_file`
11
+ defaults to `None` (inert until a user points `[settings] file` at a renamed copy) and,
12
+ more importantly, `labels.load_labels(config_dir=...)` applies `{config_dir}/
13
+ labels.json` *unconditionally* the moment that exact filename exists -- auto-creating
14
+ it live would be exactly the silent behavior-change . A user must deliberately rename it
15
+ to opt in.
16
+
17
+ `ibcontroller.toml.example` itself ships with every key commented out, so copying it
18
+ as `ibcontroller.toml` changes nothing versus built-in defaults -- `run_async` still
19
+ calls the real `load_config`, which still raises a clear `ConfigError` for the fields
20
+ that have no default (`trading_mode`/`tws_version`/credentials), guiding a first run
21
+ rather than silently starting with meaningless values.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import asyncio
27
+ import contextlib
28
+ import logging
29
+ import signal
30
+ from collections.abc import Mapping
31
+ from importlib import resources
32
+ from pathlib import Path
33
+ from typing import Any
34
+
35
+ from ibcontroller.app_dirs import resolve_app_dirs
36
+ from ibcontroller.config import load_config
37
+ from ibcontroller.control_loop import ShutdownCause, run_control_loop
38
+ from ibcontroller.labels import load_labels
39
+
40
+ logger = logging.getLogger("ibcontroller.main")
41
+
42
+ # dest filename in config_dir -> bundled resource name under ibcontroller/data/. Only
43
+ # "ibcontroller.toml" is copied under its real, load-bearing name -- see module
44
+ # docstring for why the other two deliberately keep their ".example" suffix.
45
+ _SCAFFOLD_FILES: dict[str, str] = {
46
+ "ibcontroller.toml": "ibcontroller.toml.example",
47
+ "ibkr_settings.toml.example": "ibkr_settings.toml.example",
48
+ "labels.json.example": "labels.json.example",
49
+ }
50
+
51
+
52
+ def _load_jar_path() -> Path:
53
+ """Resolves the bundled Java agent jar's real path, failing loudly (not
54
+ later, opaquely, inside `launch_instance`) if it isn't actually there --
55
+ e.g. a source checkout where `make` was never run, or a wheel built without
56
+ the jar present at build time (see the Makefile's `dist` target)."""
57
+
58
+ resource = resources.files("ibcontroller").joinpath("ibcontroller-agent.jar")
59
+ jar_path = Path(str(resource))
60
+ if not jar_path.is_file():
61
+ raise RuntimeError(
62
+ f"ibcontroller-agent.jar not found at {jar_path} -- build it first "
63
+ "(`make` from the project root) before running from a source checkout, "
64
+ "or reinstall the package if this is a wheel install."
65
+ )
66
+ return jar_path
67
+
68
+
69
+ def ensure_config_scaffold(
70
+ config_dir: Path, log_dir: Path, *, force: bool = False
71
+ ) -> list[Path]:
72
+ """Creates `config_dir`/`log_dir` if missing, then copies each of
73
+ `_SCAFFOLD_FILES`'s bundled templates into `config_dir`, one file at a time, only
74
+ when the destination doesn't already exist (or unconditionally when `force=True`,
75
+ e.g. after an ibcontroller upgrade that changed the bundled examples). Returns the
76
+ list of files actually written, so a caller (`cli.py`'s `init`/`run`) can report
77
+ exactly what happened rather than silently overwriting or silently doing nothing."""
78
+
79
+ config_dir.mkdir(parents=True, exist_ok=True)
80
+ log_dir.mkdir(parents=True, exist_ok=True)
81
+
82
+ written: list[Path] = []
83
+ for dest_name, resource_name in _SCAFFOLD_FILES.items():
84
+ dest = config_dir / dest_name
85
+ if dest.exists() and not force:
86
+ continue
87
+ template = resources.files("ibcontroller").joinpath("data", resource_name)
88
+ dest.write_text(template.read_text(encoding="utf-8"), encoding="utf-8")
89
+ written.append(dest)
90
+ return written
91
+
92
+
93
+ async def run_async(
94
+ config_dir: Path,
95
+ log_dir: Path,
96
+ *,
97
+ dotenv_path: Path | None = None,
98
+ cli_overrides: Mapping[str, Any] | None = None,
99
+ ) -> ShutdownCause:
100
+ """Loads config/labels for the one instance described by `{config_dir}/
101
+ ibcontroller.toml`, then runs it to completion via `control_loop.run_control_loop`.
102
+
103
+ Scaffolds `config_dir` first (non-forced -- see `ensure_config_scaffold`), so a
104
+ fresh install's first `run` produces the same starter files `init` would, then
105
+ fails with a clear `ConfigError` (not a `FileNotFoundError`) if the required fields
106
+ are still unset.
107
+
108
+ `labels=load_labels(config_dir=config_dir)` is the actual fix for Open item 11 --
109
+ today's `control_loop.run_control_loop`'s own default (`load_labels()`, no
110
+ `config_dir`) never sees a user's `{config_dir}/labels.json` override at all.
111
+
112
+ `cli_overrides`, if given, is passed straight through to `load_config` -- the
113
+ per-invocation `Config` field overrides `cli.py`'s `run` command builds from its
114
+ own `--trading-mode`/`--tws-path`/`--tws-settings-path`/`--instance` options.
115
+
116
+ Ctrl-C (`SIGINT`) and `SIGTERM` both cancel the running task -- the graceful-stop
117
+ contract `control_loop.py`'s own docstring already documents ("cancel this
118
+ coroutine's own task to request a graceful stop... a plain Ctrl-C in a CLI"),
119
+ wired up here for the first time. POSIX only (`loop.add_signal_handler`), matching
120
+ this project's current platform scope."""
121
+
122
+ ensure_config_scaffold(config_dir, log_dir)
123
+ config = load_config(
124
+ config_dir=config_dir,
125
+ log_dir=log_dir,
126
+ dotenv_path=dotenv_path,
127
+ cli_overrides=cli_overrides,
128
+ )
129
+ labels = load_labels(config_dir=config_dir)
130
+ agent_jar_path = _load_jar_path()
131
+
132
+ loop = asyncio.get_running_loop()
133
+ task = asyncio.ensure_future(
134
+ run_control_loop(config, agent_jar_path, labels=labels)
135
+ )
136
+ for sig in (signal.SIGINT, signal.SIGTERM):
137
+ loop.add_signal_handler(sig, task.cancel)
138
+ try:
139
+ return await task
140
+ finally:
141
+ for sig in (signal.SIGINT, signal.SIGTERM):
142
+ with contextlib.suppress(NotImplementedError, ValueError):
143
+ loop.remove_signal_handler(sig)
144
+
145
+
146
+ def main() -> None:
147
+ """Thin synchronous wrapper for direct invocation (`python -m ibcontroller.main`).
148
+ `cli.py` (the package's real installed entry point, `ibcontroller` on `PATH`) calls
149
+ `run_async` directly instead, so it can catch `ConfigError` and report it as a clean
150
+ CLI error rather than a traceback."""
151
+ config_dir, log_dir = resolve_app_dirs()
152
+ asyncio.run(run_async(config_dir, log_dir))
153
+
154
+
155
+ if __name__ == "__main__":
156
+ main()
ibcontroller/py.typed ADDED
File without changes
@@ -0,0 +1,448 @@
1
+ """Matches windows that show up unprompted -- a stray pop-up, an
2
+ existing-session conflict, a login-failure dialog, a warning dialog -- as
3
+ opposed to windows an active sequence (Login, Settings) already expects and
4
+ waits for directly via `actions.wait_for_event`.
5
+
6
+ `RecognizerRegistry` holds an ordered list of `Recognizer`s, checked in
7
+ order against each unprompted window; the first one whose `recognises()`
8
+ returns `True` has its `handle()` awaited. `DeclarativeDismissRecognizer` is
9
+ built from `labels.DismissRule` entries (`control_loop._build_registry`) for
10
+ the common "match text/title, click one button" case; the remaining classes
11
+ below handle cases with real conditional logic that isn't expressible as
12
+ data. Every recogniser acts through `actions.py`'s primitives, the same as
13
+ any other caller.
14
+
15
+ `watch_for_unprompted_windows` runs for the agent's whole lifetime as a
16
+ background task, watching `dispatcher.window_events` for `window_opened`
17
+ events and routing each one through `handle_window_opened`.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import asyncio
23
+ import logging
24
+ import re
25
+ from collections.abc import Callable, Sequence
26
+ from typing import Protocol
27
+
28
+ import eventkit
29
+
30
+ from ibcontroller.actions import click, dump
31
+ from ibcontroller.agent_client import (
32
+ Component,
33
+ WindowEvent,
34
+ WindowGoneError,
35
+ )
36
+ from ibcontroller.config import AcceptIncomingConnections, ExistingSessionAction
37
+ from ibcontroller.dispatch import Dispatcher
38
+ from ibcontroller.labels import (
39
+ AcceptIncomingConnectionLabels,
40
+ DismissRule,
41
+ ExistingSessionLabels,
42
+ LoginFailedLabels,
43
+ TooManyFailedLoginAttemptsLabels,
44
+ )
45
+
46
+ logger = logging.getLogger(__name__)
47
+
48
+ _WAIT_PATTERN = re.compile(
49
+ r"Please wait (?:(\d+) minutes? )?(?:& )?(?:(\d+) seconds?)?"
50
+ )
51
+
52
+
53
+ def _parse_wait_seconds(message: str) -> float:
54
+ """Parses a wait duration out of a "too many failed login attempts"
55
+ message (e.g. "Please wait 4 minutes & 47 seconds"), plus a 3 second
56
+ buffer. Returns just the buffer if `message` doesn't match."""
57
+ match = _WAIT_PATTERN.search(message)
58
+ minutes = int(match.group(1)) if match and match.group(1) else 0
59
+ seconds = int(match.group(2)) if match and match.group(2) else 0
60
+ return float(minutes * 60 + seconds + 3)
61
+
62
+
63
+ class LoginFailedError(Exception):
64
+ """Raised by `LoginFailedRecognizer.handle` after dismissing a real
65
+ "Login failed" dialog -- signals that a cold restart is needed. Not
66
+ raised for a bug in this recogniser, which propagates as whatever
67
+ exception it actually is."""
68
+
69
+
70
+ class Recognizer(Protocol):
71
+ def recognises(self, event: WindowEvent, components: list[Component]) -> bool:
72
+ """Returns whether this recogniser matches `event`. `components` is
73
+ the result of dumping the window (never `None` -- if the window
74
+ closed before the dump could complete, this recogniser is never
75
+ called)."""
76
+ ...
77
+
78
+ async def handle(
79
+ self,
80
+ event: WindowEvent,
81
+ components: list[Component],
82
+ dispatcher: Dispatcher,
83
+ ) -> None:
84
+ """Acts on a matched window -- dismissing it, clicking a button, or
85
+ otherwise responding to it."""
86
+ ...
87
+
88
+
89
+ def is_credential_entry(components: list[Component]) -> bool:
90
+ """Returns whether `components` includes a live, visible credential
91
+ field. Checked before any recogniser runs -- credential fields are never
92
+ handled by this registry, only by Login's own field-fill."""
93
+ return any(c.credential_field and c.showing for c in components)
94
+
95
+
96
+ class DeclarativeDismissRecognizer:
97
+ """One `Recognizer` per `labels.DismissRule`: matches `rule.match_title`
98
+ against the window's title and/or `rule.match_text` against any
99
+ component's `accessible_name`/`text`, then clicks `rule.click`. If both
100
+ `match_title` and `match_text` are given, both must match. `rule` is
101
+ public so a caller inspecting `RecognizerRegistry.handled` can identify
102
+ which rule fired, by name."""
103
+
104
+ def __init__(self, rule: DismissRule) -> None:
105
+ self.rule = rule
106
+
107
+ def recognises(self, event: WindowEvent, components: list[Component]) -> bool:
108
+ rule = self.rule
109
+ if rule.match_title is not None and rule.match_title not in (
110
+ event.window.title or ""
111
+ ):
112
+ return False
113
+ if rule.match_text is not None and not any(
114
+ (c.accessible_name is not None and rule.match_text in c.accessible_name)
115
+ or (c.text is not None and rule.match_text in c.text)
116
+ for c in components
117
+ ):
118
+ return False
119
+ return True
120
+
121
+ async def handle(
122
+ self,
123
+ event: WindowEvent,
124
+ components: list[Component],
125
+ dispatcher: Dispatcher,
126
+ ) -> None:
127
+ """Clicks `rule.click`, scoped to `event.window.window_id`."""
128
+ await click(dispatcher, self.rule.click, window_id=event.window.window_id)
129
+ logger.info("dismissed %s (declarative rule)", self.rule.name)
130
+
131
+
132
+ class TooManyFailedLoginAttemptsRecognizer:
133
+ """Matches the server-side "too many failed login attempts" dialog by
134
+ message text (`text` primarily, `accessible_name` as a fallback).
135
+ Distinct from the 2FA-timeout retry `login.py`'s `LoginManager` handles
136
+ internally (that one is driven by elapsed time, not by recognising a
137
+ window).
138
+
139
+ When `relogin_enabled` is `True`, dismisses the dialog and calls
140
+ `schedule_retry` with the wait time (in seconds) parsed from the
141
+ message. When `False`, the dialog is left untouched for manual
142
+ handling. `schedule_retry` is a plain, synchronous callable -- it's
143
+ expected to schedule the actual retry as a background task, since
144
+ `handle()` returns immediately without waiting out the cooldown."""
145
+
146
+ def __init__(
147
+ self,
148
+ labels: TooManyFailedLoginAttemptsLabels,
149
+ relogin_enabled: bool,
150
+ schedule_retry: Callable[[float], None],
151
+ ) -> None:
152
+ self._labels = labels
153
+ self._relogin_enabled = relogin_enabled
154
+ self._schedule_retry = schedule_retry
155
+
156
+ def _matched_text(self, components: list[Component]) -> str | None:
157
+ prefix = self._labels.message_prefix
158
+ for c in components:
159
+ if c.text is not None and prefix in c.text:
160
+ return c.text
161
+ if c.accessible_name is not None and prefix in c.accessible_name:
162
+ return c.accessible_name
163
+ return None
164
+
165
+ def recognises(self, event: WindowEvent, components: list[Component]) -> bool:
166
+ return self._matched_text(components) is not None
167
+
168
+ async def handle(
169
+ self,
170
+ event: WindowEvent,
171
+ components: list[Component],
172
+ dispatcher: Dispatcher,
173
+ ) -> None:
174
+ if not self._relogin_enabled:
175
+ logger.warning(
176
+ "too many failed login attempts -- relogin disabled, "
177
+ "dialog left for manual handling"
178
+ )
179
+ return
180
+ message = self._matched_text(components) or ""
181
+ wait_seconds = _parse_wait_seconds(message)
182
+ logger.warning(
183
+ "too many failed login attempts -- retrying in %.0fs", wait_seconds
184
+ )
185
+ await click(
186
+ dispatcher,
187
+ self._labels.dismiss_button,
188
+ window_id=event.window.window_id,
189
+ )
190
+ self._schedule_retry(wait_seconds)
191
+
192
+
193
+ class LoginFailedRecognizer:
194
+ """Matches a "Login failed" dialog by window title. Dismisses it and
195
+ raises `LoginFailedError` -- the restart itself isn't this recogniser's
196
+ job, only signalling that one is needed."""
197
+
198
+ def __init__(self, labels: LoginFailedLabels) -> None:
199
+ self._labels = labels
200
+
201
+ def recognises(self, event: WindowEvent, components: list[Component]) -> bool:
202
+ return self._labels.title in (event.window.title or "")
203
+
204
+ async def handle(
205
+ self,
206
+ event: WindowEvent,
207
+ components: list[Component],
208
+ dispatcher: Dispatcher,
209
+ ) -> None:
210
+ await click(
211
+ dispatcher,
212
+ self._labels.dismiss_button,
213
+ window_id=event.window.window_id,
214
+ )
215
+ logger.warning("IBController > login failed -- cold restart required")
216
+ raise LoginFailedError("login failed; cold restart required")
217
+
218
+
219
+ class ExistingSessionRecognizer:
220
+ """Matches an existing-session-conflict dialog by window title. `action`
221
+ (`ExistingSessionAction`: manual/secondary/primary/primaryoverride)
222
+ selects how to resolve it; `is_logged_in` reports whether this session
223
+ has already reached `LOGGED_IN`, since the correct response differs
224
+ before vs. after login completes. Stateful: tracks whether a previous
225
+ attempt was already made this process, one instance per running agent."""
226
+
227
+ def __init__(
228
+ self,
229
+ labels: ExistingSessionLabels,
230
+ action: ExistingSessionAction,
231
+ is_logged_in: Callable[[], bool],
232
+ ) -> None:
233
+ self._labels = labels
234
+ self._action = action
235
+ self._is_logged_in = is_logged_in
236
+ self._attempted = False
237
+
238
+ def recognises(self, event: WindowEvent, components: list[Component]) -> bool:
239
+ return self._labels.title in (event.window.title or "")
240
+
241
+ async def handle(
242
+ self,
243
+ event: WindowEvent,
244
+ components: list[Component],
245
+ dispatcher: Dispatcher,
246
+ ) -> None:
247
+ if self._action is ExistingSessionAction.MANUAL:
248
+ logger.info(
249
+ "IBController > existing session detected -- manual, leaving for"
250
+ " the user"
251
+ )
252
+ return # scenario 1: user must choose -- nothing to do
253
+ if self._action is ExistingSessionAction.SECONDARY:
254
+ # scenario 2: end this session, let the other one proceed
255
+ logger.info(
256
+ "IBController > existing session detected -- secondary, ending"
257
+ " this session"
258
+ )
259
+ await click(
260
+ dispatcher,
261
+ self._labels.cancel_buttons,
262
+ window_id=event.window.window_id,
263
+ )
264
+ return
265
+ if not self._is_logged_in():
266
+ # we are session B (login not yet complete)
267
+ if not self._attempted:
268
+ self._attempted = True
269
+ # scenario 3: don't know session A's type, continue this one
270
+ logger.info(
271
+ "IBController > existing session detected -- first attempt, "
272
+ "continuing this session"
273
+ )
274
+ await click(
275
+ dispatcher,
276
+ self._labels.continue_buttons,
277
+ window_id=event.window.window_id,
278
+ )
279
+ else:
280
+ # scenario 4: session A must be primary/primaryoverride, end this one
281
+ logger.info(
282
+ "IBController > existing session detected -- second attempt, ending"
283
+ " this session"
284
+ )
285
+ await click(
286
+ dispatcher,
287
+ self._labels.cancel_buttons,
288
+ window_id=event.window.window_id,
289
+ )
290
+ elif self._action is ExistingSessionAction.PRIMARY:
291
+ # scenario 5: we are session A, primary -- continue this, let the other exit
292
+ logger.info(
293
+ "IBController > existing session detected -- primary, continuing this"
294
+ " session"
295
+ )
296
+ await click(
297
+ dispatcher,
298
+ self._labels.continue_buttons,
299
+ window_id=event.window.window_id,
300
+ )
301
+ else:
302
+ # scenario 6: we are session A, primaryoverride -- let the other proceed
303
+ logger.info(
304
+ "IBController > existing session detected -- primaryoverride, ending"
305
+ " this session"
306
+ )
307
+ await click(
308
+ dispatcher,
309
+ self._labels.cancel_buttons,
310
+ window_id=event.window.window_id,
311
+ )
312
+
313
+
314
+ class AcceptIncomingConnectionsRecognizer:
315
+ """Matches the "Accept incoming connection" dialog TWS/Gateway shows when
316
+ a client connects on the API socket, by window title (ported from IBC's
317
+ own `AcceptIncomingConnectionDialogHandler`; title matching, not IBC's
318
+ body-label search, to match this project's own convention for every
319
+ other simple dialog here -- confirmed as a valid alternative by ibctl's
320
+ own independent, live-tested handler, which matches on title too).
321
+ `action` (`AcceptIncomingConnections`: manual/accept/reject) selects the
322
+ response; `MANUAL` leaves the dialog for the user, matching IBC's own
323
+ handler."""
324
+
325
+ def __init__(
326
+ self,
327
+ labels: AcceptIncomingConnectionLabels,
328
+ action: AcceptIncomingConnections,
329
+ ) -> None:
330
+ self._labels = labels
331
+ self._action = action
332
+
333
+ def recognises(self, event: WindowEvent, components: list[Component]) -> bool:
334
+ return self._labels.title in (event.window.title or "")
335
+
336
+ async def handle(
337
+ self,
338
+ event: WindowEvent,
339
+ components: list[Component],
340
+ dispatcher: Dispatcher,
341
+ ) -> None:
342
+ if self._action is AcceptIncomingConnections.MANUAL:
343
+ logger.info(
344
+ "IBController > incoming API connection -- manual, leaving for the user"
345
+ )
346
+ return
347
+ if self._action is AcceptIncomingConnections.ACCEPT:
348
+ await click(
349
+ dispatcher,
350
+ self._labels.accept_buttons,
351
+ window_id=event.window.window_id,
352
+ )
353
+ logger.info("IBController > incoming API connection -- accepted")
354
+ else:
355
+ await click(
356
+ dispatcher,
357
+ self._labels.reject_buttons,
358
+ window_id=event.window.window_id,
359
+ )
360
+ logger.info("IBController > incoming API connection -- rejected")
361
+
362
+
363
+ class RecognizerRegistry:
364
+ """Ordered list of `Recognizer`s, checked in sequence; `dispatch()` runs
365
+ the first one that matches. A bug in a recogniser propagates unchanged,
366
+ not caught here -- there's no plugin/config-rule tier yet to isolate
367
+ failures from."""
368
+
369
+ def __init__(self, registry: Sequence[Recognizer]) -> None:
370
+ self._registry = list(registry)
371
+ self.handled: eventkit.Event = eventkit.Event()
372
+ """Emits `(recognizer, event)` -- two positional args -- right after
373
+ that recognizer's own `handle()` returns successfully. Lets a caller
374
+ know a specific rule has run (e.g. to know a blocking dialog is now
375
+ gone) before proceeding."""
376
+
377
+ def recognises(self, event: WindowEvent, components: list[Component]) -> bool:
378
+ """Read-only: whether any registered recogniser matches, without
379
+ running its `handle()`. Used by `diagnostics.watch_for_diagnostics`
380
+ to classify a window as "known"/"unknown" -- the same classification
381
+ `dispatch()` itself uses, exposed without the side effect."""
382
+ return any(r.recognises(event, components) for r in self._registry)
383
+
384
+ async def dispatch(
385
+ self,
386
+ event: WindowEvent,
387
+ components: list[Component],
388
+ dispatcher: Dispatcher,
389
+ ) -> bool:
390
+ """Runs the first matching recogniser's `handle()` against
391
+ `event`/`components`, emitting `handled`. Returns whether anything
392
+ matched."""
393
+ for recognizer in self._registry:
394
+ if recognizer.recognises(event, components):
395
+ await recognizer.handle(event, components, dispatcher)
396
+ self.handled.emit(recognizer, event)
397
+ return True
398
+ return False
399
+
400
+
401
+ async def handle_window_opened(
402
+ registry: RecognizerRegistry,
403
+ event: WindowEvent,
404
+ dispatcher: Dispatcher,
405
+ ) -> bool:
406
+ """Dumps `event.window`'s components, then dispatches: refuses (returns
407
+ `False`) if the window shows a live credential field, otherwise routes it
408
+ through `registry.dispatch`. Returns `False` if the window closed before
409
+ it could be dumped (`WindowGoneError`) or if nothing matched."""
410
+ try:
411
+ components = await dump(
412
+ dispatcher, window=event.window.title, window_id=event.window.window_id
413
+ )
414
+ except WindowGoneError:
415
+ return False
416
+ if is_credential_entry(components):
417
+ return False
418
+ return await registry.dispatch(event, components, dispatcher)
419
+
420
+
421
+ async def watch_for_unprompted_windows(
422
+ registry: RecognizerRegistry,
423
+ dispatcher: Dispatcher,
424
+ ) -> None:
425
+ """Runs for the agent's whole lifetime: watches `dispatcher.window_events`
426
+ for `window_opened` events and routes each one through
427
+ `handle_window_opened`, logging any that go unhandled. Meant to be
428
+ started via `asyncio.create_task`, not awaited directly -- it never
429
+ returns on its own."""
430
+ queue: asyncio.Queue[WindowEvent] = asyncio.Queue()
431
+
432
+ def on_event(event: WindowEvent) -> None:
433
+ if event.kind == "window_opened":
434
+ queue.put_nowait(event)
435
+
436
+ dispatcher.window_events.connect(on_event)
437
+ try:
438
+ while True:
439
+ event = await queue.get()
440
+ handled = await handle_window_opened(registry, event, dispatcher)
441
+ if not handled:
442
+ logger.info(
443
+ "unhandled unprompted window: %s %r",
444
+ event.window.class_,
445
+ event.window.title,
446
+ )
447
+ finally:
448
+ dispatcher.window_events.disconnect(on_event)