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
|
@@ -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)
|
ibcontroller/app_dirs.py
ADDED
|
@@ -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()
|