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,466 @@
1
+ """Wire client for the Java agent's two Unix sockets: a command socket
2
+ (request/response) and an event socket (server push). This module owns the
3
+ transport and the typed (`cattrs`/`attrs`) message schemas only -- reading
4
+ one JSON line, building one, and turning the result into a typed object or
5
+ a typed exception.
6
+
7
+ Command scheduling, event fan-out, and tracing are `dispatch.py`'s job, one
8
+ layer up: it calls the typed methods below and consumes
9
+ `AgentEventConnection.messages()`, without needing to know anything about
10
+ JSON shapes or error codes.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import asyncio
16
+ import json
17
+ from collections.abc import AsyncIterator, Mapping
18
+ from pathlib import Path
19
+ from typing import Any
20
+
21
+ import attrs
22
+ import cattrs
23
+ from cattrs.gen import make_dict_structure_fn, override
24
+ from typed_settings.types import Secret
25
+
26
+ converter = cattrs.Converter()
27
+
28
+
29
+ class AgentClientError(Exception):
30
+ """Base class for every error this module raises."""
31
+
32
+
33
+ class ElementNotFoundError(AgentClientError):
34
+ """The target component could not be found (agent error code `not_found`)."""
35
+
36
+
37
+ class CredentialRefusedError(AgentClientError):
38
+ """The agent refused to return a credential field's value (error code
39
+ `refused_credential_field`), e.g. `get_text` on a password field."""
40
+
41
+
42
+ class BadRequestError(AgentClientError):
43
+ """The agent reported a malformed request (error code `bad_request`)."""
44
+
45
+
46
+ class UnknownCommandError(AgentClientError):
47
+ """The agent does not recognise the command (error code `unknown_command`)."""
48
+
49
+
50
+ class AgentError(AgentClientError):
51
+ """A generic agent-side error (error code `agent_error`), or any error code
52
+ this client doesn't recognise."""
53
+
54
+
55
+ class WindowGoneError(AgentClientError):
56
+ """The window referenced by `window_id` no longer exists -- it closed
57
+ between the caller learning about it and the command reaching the agent.
58
+ Distinct from `ElementNotFoundError`, which means the target label itself
59
+ doesn't exist within a (still open) window."""
60
+
61
+
62
+ class ProtocolError(AgentClientError):
63
+ """A transport-level failure: a malformed line, a closed connection, or a
64
+ response missing the `ok` field -- not an error the agent itself reported."""
65
+
66
+
67
+ # Large enough for a dump() response from a window with many components; a
68
+ # dump() response is JSON, not raw binary, so this bounds well above what any
69
+ # realistic component tree would produce.
70
+ _STREAM_READ_LIMIT = 8 * 1024 * 1024
71
+
72
+ _ERROR_CODES: dict[str, type[AgentClientError]] = {
73
+ "not_found": ElementNotFoundError,
74
+ "refused_credential_field": CredentialRefusedError,
75
+ "bad_request": BadRequestError,
76
+ "unknown_command": UnknownCommandError,
77
+ "agent_error": AgentError,
78
+ "window_gone": WindowGoneError,
79
+ }
80
+
81
+
82
+ @attrs.define(frozen=True)
83
+ class PingResult:
84
+ version: str
85
+ uptime_s: int
86
+
87
+
88
+ @attrs.define(frozen=True)
89
+ class Component:
90
+ """One entry from `dump`'s `components` list. Every field but `class_` is
91
+ optional -- the agent only includes a field when it has something to
92
+ report. `text_truncated`/`text_length` indicate `text` was cut to a
93
+ maximum length, rather than silently returning a truncated value."""
94
+
95
+ class_: str
96
+ name: str | None = None
97
+ accessible_name: str | None = None
98
+ credential_field: bool = False
99
+ enabled: bool = False
100
+ showing: bool = False
101
+ visible: bool = False
102
+ selected: bool | None = None
103
+ parent_class: str | None = None
104
+ text: str | None = None
105
+ text_truncated: bool = False
106
+ text_length: int | None = None
107
+
108
+
109
+ @attrs.define(frozen=True)
110
+ class WindowInfo:
111
+ """Identifies one window. `window_id` is the agent's opaque, per-window ID
112
+ -- pass it back on a later `click`/`set_text`/`toggle`/`expand_tree`/
113
+ `set_text_near_label` to scope that command to this exact window instead
114
+ of an agent-side search across every open window."""
115
+
116
+ class_: str
117
+ title: str | None = None
118
+ window_id: str | None = None
119
+
120
+
121
+ converter.register_structure_hook(
122
+ Component,
123
+ make_dict_structure_fn(Component, converter, class_=override(rename="class")),
124
+ )
125
+ converter.register_structure_hook(
126
+ WindowInfo,
127
+ make_dict_structure_fn(WindowInfo, converter, class_=override(rename="class")),
128
+ )
129
+
130
+
131
+ @attrs.define(frozen=True)
132
+ class Hello:
133
+ protocol_version: int
134
+
135
+
136
+ @attrs.define(frozen=True)
137
+ class Snapshot:
138
+ windows: list[WindowInfo]
139
+
140
+
141
+ @attrs.define(frozen=True)
142
+ class WindowEvent:
143
+ """One event from the event socket. `kind` is `window_opened` or
144
+ `window_closed`."""
145
+
146
+ seq: int
147
+ kind: str
148
+ window: WindowInfo
149
+
150
+
151
+ @attrs.define(frozen=True)
152
+ class Overflow:
153
+ from_seq: int
154
+
155
+
156
+ @attrs.define(frozen=True)
157
+ class Keepalive:
158
+ ts: int
159
+
160
+
161
+ AgentEventMessage = Hello | Snapshot | WindowEvent | Overflow | Keepalive
162
+
163
+ _EVENT_MESSAGE_TYPES: dict[str, type[AgentEventMessage]] = {
164
+ "hello": Hello,
165
+ "snapshot": Snapshot,
166
+ "event": WindowEvent,
167
+ "overflow": Overflow,
168
+ "keepalive": Keepalive,
169
+ }
170
+
171
+
172
+ def parse_event_message(raw: Mapping[str, Any]) -> AgentEventMessage:
173
+ """Structures a raw event message dict into its typed `AgentEventMessage`
174
+ subclass, chosen by its `type` field. Raises `ProtocolError` if `type` is
175
+ missing or unrecognised."""
176
+ msg_type = raw.get("type")
177
+ cls = _EVENT_MESSAGE_TYPES.get(msg_type) if isinstance(msg_type, str) else None
178
+ if cls is None:
179
+ raise ProtocolError(f"unknown event message type: {msg_type!r}")
180
+ return converter.structure(raw, cls)
181
+
182
+
183
+ def derive_event_socket_path(command_socket_path: str | Path) -> Path:
184
+ """Derives the event socket path from `command_socket_path`:
185
+ `<prefix>-cmd.sock` -> `<prefix>-events.sock`, or `<path>.events` if the
186
+ command socket path doesn't follow that naming convention."""
187
+ path = Path(command_socket_path)
188
+ suffix = "-cmd.sock"
189
+ name = path.name
190
+ if name.endswith(suffix):
191
+ new_name = name[: -len(suffix)] + "-events.sock"
192
+ return path.with_name(new_name)
193
+ return path.with_name(name + ".events")
194
+
195
+
196
+ class AgentCommandConnection:
197
+ """One persistent connection to the agent's command socket: send one JSON
198
+ line, read one JSON line back, per command. The wire protocol has no
199
+ request IDs, so requests are serialized with a lock -- one write/read
200
+ pair completes before the next one starts."""
201
+
202
+ def __init__(self, path: str | Path) -> None:
203
+ self._path = str(path)
204
+ self._reader: asyncio.StreamReader | None = None
205
+ self._writer: asyncio.StreamWriter | None = None
206
+ self._lock = asyncio.Lock()
207
+
208
+ async def connect(self) -> None:
209
+ self._reader, self._writer = await asyncio.open_unix_connection(
210
+ path=self._path, limit=_STREAM_READ_LIMIT
211
+ )
212
+
213
+ async def close(self) -> None:
214
+ writer, self._writer, self._reader = self._writer, None, None
215
+ if writer is not None:
216
+ writer.close()
217
+ try:
218
+ await writer.wait_closed()
219
+ except OSError:
220
+ pass
221
+
222
+ async def __aenter__(self) -> AgentCommandConnection:
223
+ await self.connect()
224
+ return self
225
+
226
+ async def __aexit__(self, *exc_info: object) -> None:
227
+ await self.close()
228
+
229
+ async def _request(self, payload: Mapping[str, Any]) -> dict[str, Any]:
230
+ """Sends `payload` as one JSON line and returns the parsed response
231
+ dict. Raises `ProtocolError` for transport-level failures (not
232
+ connected, connection closed, malformed line, a response missing the
233
+ `ok` field)."""
234
+ if self._reader is None or self._writer is None:
235
+ raise ProtocolError("not connected")
236
+ line = json.dumps(payload) + "\n"
237
+ async with self._lock:
238
+ self._writer.write(line.encode("utf-8"))
239
+ await self._writer.drain()
240
+ raw = await self._reader.readline()
241
+ if not raw:
242
+ raise ProtocolError("agent closed the command connection")
243
+ try:
244
+ response = json.loads(raw)
245
+ except json.JSONDecodeError as exc:
246
+ raise ProtocolError(f"malformed response line: {raw!r}") from exc
247
+ if not isinstance(response, dict) or "ok" not in response:
248
+ raise ProtocolError(f"unexpected response shape: {response!r}")
249
+ return response
250
+
251
+ @staticmethod
252
+ def _raise_for_error(response: Mapping[str, Any]) -> None:
253
+ """Raises the typed `AgentClientError` subclass matching `response`'s
254
+ `error` code (`AgentError` if the code isn't recognised)."""
255
+ code = response.get("error", "agent_error")
256
+ detail = str(response.get("detail", ""))
257
+ raise _ERROR_CODES.get(code, AgentError)(detail)
258
+
259
+ async def ping(self) -> PingResult:
260
+ """Pings the agent. Returns its version and uptime."""
261
+ response = await self._request({"cmd": "ping"})
262
+ if not response["ok"]:
263
+ self._raise_for_error(response)
264
+ return converter.structure(response, PingResult)
265
+
266
+ async def dump(
267
+ self, window: str | None = None, window_id: str | None = None
268
+ ) -> list[Component]:
269
+ """Returns the current component tree, optionally scoped by `window`
270
+ (a title substring) or `window_id` (an exact, opaque ID, taking
271
+ precedence when both are given). Raises `WindowGoneError` if
272
+ `window_id` no longer resolves to an open window."""
273
+ payload: dict[str, Any] = {"cmd": "dump"}
274
+ if window is not None:
275
+ payload["window"] = window
276
+ if window_id is not None:
277
+ payload["window_id"] = window_id
278
+ response = await self._request(payload)
279
+ if not response["ok"]:
280
+ self._raise_for_error(response)
281
+ return [converter.structure(c, Component) for c in response["components"]]
282
+
283
+ async def get_text(self, target: str) -> str:
284
+ """Returns the text of the component identified by `target`. Raises
285
+ `CredentialRefusedError` if `target` is a password field."""
286
+ response = await self._request({"cmd": "get_text", "target": target})
287
+ if not response["ok"]:
288
+ self._raise_for_error(response)
289
+ return str(response["value"])
290
+
291
+ async def set_text(
292
+ self, target: str, value: str | Secret, window_id: str | None = None
293
+ ) -> None:
294
+ """Sets the text of the component identified by `target`. `value` may
295
+ be a `Secret` (credentials) or a plain string -- a `Secret` is
296
+ unwrapped here, immediately before building the request, the one
297
+ point where a credential becomes a bare string. `window_id`, if
298
+ given, scopes the search to one window. Raises `WindowGoneError` if
299
+ `window_id` no longer resolves to an open window."""
300
+ real_value = value.get_secret_value() if isinstance(value, Secret) else value
301
+ payload: dict[str, Any] = {
302
+ "cmd": "set_text",
303
+ "target": target,
304
+ "value": real_value,
305
+ }
306
+ if window_id is not None:
307
+ payload["window_id"] = window_id
308
+ response = await self._request(payload)
309
+ if not response["ok"]:
310
+ self._raise_for_error(response)
311
+
312
+ async def set_checkbox(
313
+ self, target: str, checked: bool, window_id: str | None = None
314
+ ) -> None:
315
+ """Sets the checked state of the component identified by `target`.
316
+ `window_id`, if given, scopes the search to one window."""
317
+ payload: dict[str, Any] = {
318
+ "cmd": "set_checkbox",
319
+ "target": target,
320
+ "checked": checked,
321
+ }
322
+ if window_id is not None:
323
+ payload["window_id"] = window_id
324
+ response = await self._request(payload)
325
+ if not response["ok"]:
326
+ self._raise_for_error(response)
327
+
328
+ async def click(self, target: str, window_id: str | None = None) -> None:
329
+ """Clicks the component identified by `target`. Fire-and-forget on
330
+ the agent side: this only confirms the agent accepted the command,
331
+ not that the click's effect has landed. `window_id`, if given, scopes
332
+ the search to one window."""
333
+ payload: dict[str, Any] = {"cmd": "click", "target": target}
334
+ if window_id is not None:
335
+ payload["window_id"] = window_id
336
+ response = await self._request(payload)
337
+ if not response["ok"]:
338
+ self._raise_for_error(response)
339
+
340
+ async def set_text_near_label(
341
+ self,
342
+ label: str,
343
+ index: int,
344
+ value: str | Secret,
345
+ window_id: str | None = None,
346
+ ) -> None:
347
+ """Sets the text of the `index`-th text field found within the
348
+ container of the component labelled `label` -- for fields with no
349
+ accessible name of their own. `value` may be a `Secret` or a plain
350
+ string, same unwrap contract as `set_text`. `window_id`, if given,
351
+ scopes the search to one window."""
352
+ real_value = value.get_secret_value() if isinstance(value, Secret) else value
353
+ payload: dict[str, Any] = {
354
+ "cmd": "set_text_near_label",
355
+ "label": label,
356
+ "index": index,
357
+ "value": real_value,
358
+ }
359
+ if window_id is not None:
360
+ payload["window_id"] = window_id
361
+ response = await self._request(payload)
362
+ if not response["ok"]:
363
+ self._raise_for_error(response)
364
+
365
+ async def navigate_menu(self, path: str, window_id: str | None = None) -> bool:
366
+ """Walks a menu path (e.g. `"File/Close"`) and clicks the item at the
367
+ end. `click` cannot reach menu items -- a menu's dropdown lives
368
+ outside the ordinary component tree while closed. Fire-and-forget
369
+ once a click is actually queued.
370
+
371
+ `window_id`, if given, resolves directly to that window's own menu
372
+ bar (matching `menu_item_exists`'s scoping) instead of the agent's
373
+ own global "first displayable frame with a menu bar" search (#40) --
374
+ `None` (the default) keeps that global search, for callers that
375
+ don't have a validated main-window id to give (e.g. Gateway, whose
376
+ `login.py` path never captures one, see `LoginManager.main_window_id`).
377
+
378
+ Returns whether the item was actually clicked: `False` means the
379
+ resolved item exists but is currently disabled (a retriable
380
+ condition, e.g. a blocking dialog still open), distinct from
381
+ `ElementNotFoundError` (the path itself doesn't resolve to anything).
382
+ `actions.navigate_menu` owns the retry loop this return value is
383
+ for."""
384
+ payload: dict[str, Any] = {"cmd": "navigate_menu", "path": path}
385
+ if window_id is not None:
386
+ payload["window_id"] = window_id
387
+ response = await self._request(payload)
388
+ if not response["ok"]:
389
+ self._raise_for_error(response)
390
+ return bool(response.get("clicked", True))
391
+
392
+ async def expand_tree(self, path: str, window_id: str | None = None) -> None:
393
+ """Selects a section of the Global Configuration dialog's tree (e.g.
394
+ `"API/Settings"`). Unlike `navigate_menu`/`click`, this can be
395
+ trusted immediately -- selecting a tree node only swaps an
396
+ already-visible panel, it can't open a new modal. `window_id`, if
397
+ given, resolves directly to that dialog instead of searching by
398
+ title."""
399
+ payload: dict[str, Any] = {"cmd": "expand_tree", "path": path}
400
+ if window_id is not None:
401
+ payload["window_id"] = window_id
402
+ response = await self._request(payload)
403
+ if not response["ok"]:
404
+ self._raise_for_error(response)
405
+
406
+ async def menu_item_exists(self, path: str, window_id: str) -> tuple[bool, bool]:
407
+ """Reports whether `path` (e.g. `"Help/About Gateway"`) resolves to a
408
+ real menu item under `window_id`'s menu bar, and whether it's
409
+ currently enabled, without clicking it. Returns `(exists, enabled)`.
410
+ `window_id` is required -- diagnostic use, not general scoping."""
411
+ response = await self._request(
412
+ {"cmd": "menu_item_exists", "path": path, "window_id": window_id}
413
+ )
414
+ if not response["ok"]:
415
+ self._raise_for_error(response)
416
+ return bool(response["exists"]), bool(response["enabled"])
417
+
418
+
419
+ class AgentEventConnection:
420
+ """One connection to the agent's event socket. Pure server push --
421
+ `messages()` is a plain async iterator over whatever the agent sends, in
422
+ order. Fan-out and filtering are `dispatch.py`'s job, not this class's."""
423
+
424
+ def __init__(self, path: str | Path) -> None:
425
+ self._path = str(path)
426
+ self._reader: asyncio.StreamReader | None = None
427
+ self._writer: asyncio.StreamWriter | None = None
428
+
429
+ async def connect(self) -> None:
430
+ self._reader, self._writer = await asyncio.open_unix_connection(
431
+ path=self._path, limit=_STREAM_READ_LIMIT
432
+ )
433
+
434
+ async def close(self) -> None:
435
+ writer, self._writer, self._reader = self._writer, None, None
436
+ if writer is not None:
437
+ writer.close()
438
+ try:
439
+ await writer.wait_closed()
440
+ except OSError:
441
+ pass
442
+
443
+ async def __aenter__(self) -> AgentEventConnection:
444
+ await self.connect()
445
+ return self
446
+
447
+ async def __aexit__(self, *exc_info: object) -> None:
448
+ await self.close()
449
+
450
+ async def messages(self) -> AsyncIterator[AgentEventMessage]:
451
+ """Yields each event message from the agent, in order, until the
452
+ connection closes. Raises `ProtocolError` if not connected, or on a
453
+ malformed line."""
454
+ if self._reader is None:
455
+ raise ProtocolError("not connected")
456
+ while True:
457
+ raw = await self._reader.readline()
458
+ if not raw:
459
+ return
460
+ try:
461
+ data = json.loads(raw)
462
+ except json.JSONDecodeError as exc:
463
+ raise ProtocolError(f"malformed event line: {raw!r}") from exc
464
+ if not isinstance(data, dict):
465
+ raise ProtocolError(f"unexpected event shape: {data!r}")
466
+ yield parse_event_message(data)
@@ -0,0 +1,44 @@
1
+ """Where ibcontroller's own files live on disk when not told otherwise explicitly.
2
+ The config file's default location, and where `config.py`'s
3
+ `log_dir` (which also hosts trace files when tracing is enabled) defaults to.
4
+
5
+ **"Docker mode":** `IBC_APP_DIR`, if set, replaces platformdirs' own per-OS
6
+ convention (`~/Library/Application Support`, `%APPDATA%`, `$XDG_CONFIG_HOME`, ...)
7
+ outright -- a container wants one predictable mounted volume with a couple of
8
+ subdirectories in it, not an OS convention designed for a desktop user account.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import os
14
+ from collections.abc import Mapping
15
+ from pathlib import Path
16
+
17
+ import platformdirs
18
+
19
+ _APP_NAME = "ibcontroller"
20
+
21
+
22
+ def resolve_app_dirs(env: Mapping[str, str] | None = None) -> tuple[Path, Path]:
23
+ """
24
+ Returns `(config_dir, log_dir)`. Single source of truth for where our files live.
25
+ """
26
+ env = os.environ if env is None else env
27
+ app_dir = env.get("IBC_APP_DIR")
28
+ if app_dir:
29
+ base = Path(app_dir).expanduser()
30
+ return base / "config", base / "log"
31
+ dirs = platformdirs.PlatformDirs(_APP_NAME, appauthor=False)
32
+ return Path(dirs.user_config_dir).expanduser(), Path(dirs.user_log_dir).expanduser()
33
+
34
+
35
+ def resolve_runtime_dir(env: Mapping[str, str] | None = None) -> Path:
36
+ """
37
+ Where the agent's own Unix domain sockets live.
38
+ """
39
+ env = os.environ if env is None else env
40
+ app_dir = env.get("IBC_APP_DIR")
41
+ if app_dir:
42
+ return Path(app_dir).expanduser() / "run"
43
+ dirs = platformdirs.PlatformDirs(_APP_NAME, appauthor=False)
44
+ return Path(dirs.user_runtime_dir).expanduser()