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,277 @@
1
+ """Shared logging configuration.
2
+
3
+ **File handlers run on listener threads, not the caller's.** `configure_logging`'s
4
+ app-log file handler, `configure_trace`'s trace handlers, and
5
+ `configure_gateway_stdout`'s launched-process stdout handler all attach to their
6
+ loggers as `logging.handlers.QueueHandler`s, with the real `FileHandler`s owned by
7
+ per-stream `QueueListener` threads. This is the stdlib's canonical answer to
8
+ "don't block the event loop on disk I/O": a coroutine's
9
+ `logger.info(...)`/`logger.debug(...)` is a synchronous `queue.put()`; the file
10
+ write happens on the listener thread. (There is no `asyncio` logging handler in
11
+ the stdlib -- `QueueHandler`/`QueueListener` is the documented replacement, and
12
+ it is what `dispatch.py`'s trace path and `launcher.py`'s stdout-drain both rely
13
+ on to keep their per-line writes off the event loop.) Whichever console
14
+ `StreamHandler`s are attached (gated by `sink`, see below) deliberately stay
15
+ attached directly (synchronous): a tty write is the cheap case, and keeping it
16
+ sync means console output can't be lost in a dying process. `stop_logging()`
17
+ drains and joins the listener threads; call it at the final shutdown path
18
+ (control_loop) and in any test that needs to read a file it wrote.
19
+
20
+ **`sink` ("std" or "file") is exclusive, not additive (gitea #26).** Both
21
+ `configure_logging` and `configure_gateway_stdout` take a `sink` parameter:
22
+ `"std"` attaches only a console `StreamHandler`, `"file"` attaches only the
23
+ queued file handler -- never both. This exists for Docker deployments, where
24
+ output belongs on stdout/stderr (`docker logs`) rather than inside the
25
+ container's filesystem; `configure_gateway_stdout` in particular used to be
26
+ file-only unconditionally, meaning Gateway/TWS's own log lines were invisible
27
+ outside a file no `docker logs` call would ever show. `configure_trace`'s
28
+ NDJSON wire trace is deliberately untouched by this -- it stays file-only
29
+ regardless of `sink`, since it's meant to be tailed as two per-instance files,
30
+ not mixed into a formatted stdout stream.
31
+
32
+ **Sanitization is a separate concern, already solved elsewhere, not this module's
33
+ job.** `secret.py`'s `Secret` protects a value at its own origin (`config.py`'s
34
+ `Config.username`/`password`) -- its `__str__`/`__repr__` already return
35
+ `"<redacted>"`, so a `Secret` passed into any `%s`-style log call
36
+ (`logger.info("login as %s", config.username)`) is already safe by construction,
37
+ the same way it's already safe in `dispatch.py`'s trace line. This module only
38
+ decides where log records go and how they're formatted; it doesn't need to know
39
+ `Secret` exists.
40
+ """
41
+
42
+ from __future__ import annotations
43
+
44
+ import logging
45
+ import logging.handlers
46
+ import queue
47
+ from pathlib import Path
48
+
49
+ _FORMAT = "%(asctime)s %(levelname)s %(name)s: %(message)s"
50
+ # No timestamp/level/name prefix -- for streams where each line already carries
51
+ # its own content and must not be altered: trace files (NDJSON, one line = one
52
+ # JSON object) and the launched process's own raw stdout passthrough (already
53
+ # Gateway/TWS's own formatted log output). The FileHandler's own terminator
54
+ # supplies the trailing newline.
55
+ _MESSAGE_ONLY_FORMAT = "%(message)s"
56
+ # When trace is disabled the loggers are still created (so `dispatch.py`'s
57
+ # `isEnabledFor(DEBUG)` gate is false even if a stale Dispatcher references
58
+ # them) but set above CRITICAL with no handlers.
59
+ _TRACE_LEVEL_DISABLED = logging.CRITICAL + 1
60
+
61
+ # Every live QueueListener, keyed by owner so reconfiguration and shutdown can
62
+ # find exactly the right ones: "app" for the `ibcontroller` logger's file
63
+ # handler, f"trace:{instance}" for one instance's two trace streams,
64
+ # f"stdout:{instance}" for one instance's launched-process stdout passthrough.
65
+ # Keys exist because `configure_logging`/`configure_trace`/
66
+ # `configure_gateway_stdout` are called independently (launch_instance calls
67
+ # all three, back to back); stopping a reconfiguring owner's *own* previous
68
+ # listeners must not stop another owner's.
69
+ _active_listeners: dict[str, list[logging.handlers.QueueListener]] = {}
70
+
71
+
72
+ class _FlushFileHandler(logging.FileHandler):
73
+ """FileHandler that flushes after every record.
74
+
75
+ Needed for the trace files specifically: `cmd-{instance}.jsonl`/
76
+ `events-{instance}.jsonl` exist to be tailed live, and a stock FileHandler
77
+ only flushes when a record is at or above the listener's `flush_level`
78
+ (default: ERROR) or when the buffer fills/wraps out -- at DEBUG level lines
79
+ would otherwise sit in the buffered stream indefinitely. Flushing per record
80
+ restores exactly the write()+flush()-per-line behaviour `dispatch.py`'s old
81
+ hand-rolled trace had, now running on the listener thread instead of the
82
+ event loop."""
83
+
84
+ def emit(self, record: logging.LogRecord) -> None:
85
+ super().emit(record)
86
+ self.flush()
87
+
88
+
89
+ def _stop_listeners(key: str | None = None) -> None:
90
+ """Stop (drain, then join) every QueueListener registered under `key`, or
91
+ every listener when `key` is None; closes each listener's handlers, which
92
+ flushes whatever those handlers still buffered. Idempotent."""
93
+ keys = [key] if key is not None else list(_active_listeners)
94
+ for k in keys:
95
+ for listener in _active_listeners.pop(k, []):
96
+ listener.stop()
97
+ for handler in listener.handlers:
98
+ handler.close()
99
+
100
+
101
+ def _queued_file_handler(
102
+ path: Path, *, key: str, mode: str = "a", fmt: str = _FORMAT
103
+ ) -> logging.handlers.QueueHandler:
104
+ """Build one QueueHandler/QueueListener pair for `path` -- the QueueHandler
105
+ is what callers attach to a logger (a synchronous `queue.put()`), the
106
+ QueueListener owns the real `_FlushFileHandler` and runs it on its own
107
+ thread. The listener is registered under `key` so `_stop_listeners`/reconfig
108
+ can find it."""
109
+ handler = _FlushFileHandler(path, mode=mode, encoding="utf-8")
110
+ handler.setFormatter(logging.Formatter(fmt))
111
+ q = queue.Queue()
112
+ listener = logging.handlers.QueueListener(q, handler)
113
+ listener.start()
114
+ _active_listeners.setdefault(key, []).append(listener)
115
+ return logging.handlers.QueueHandler(q)
116
+
117
+
118
+ def stop_logging() -> None:
119
+ """Drain and shut down every active listener, closing its handler. Safe to
120
+ call any number of times (idempotent). The final shutdown path
121
+ (`control_loop.run_control_loop`) calls this; tests that assert against a
122
+ real log/trace file must call it too, because the records they just logged
123
+ may still be sitting in a listener's queue until the thread drains it."""
124
+ _stop_listeners()
125
+
126
+
127
+ def configure_logging(
128
+ *,
129
+ level: int = logging.INFO,
130
+ log_dir: str | Path | None = None,
131
+ filename: str = "ibcontroller.log",
132
+ sink: str = "std",
133
+ ) -> None:
134
+ """Configures the `ibcontroller` logger hierarchy (every submodule's
135
+ `logging.getLogger(__name__)` is a child of it, e.g. `ibcontroller.dispatch`)
136
+ -- either a console handler (`sink="std"`, synchronous `StreamHandler`) or a
137
+ file handler under `log_dir` (`sink="file"`; `ibcontroller.log` by default,
138
+ alongside `dispatch.py`'s own `cmd-{instance}.jsonl`/`events-{instance}.jsonl`
139
+ under the same flat directory convention -- app_dirs.py's log dir, typically),
140
+ never both (see module docstring). The file handler is queued: the logger
141
+ sees a `QueueHandler`, and the real `FileHandler` lives on a listener thread
142
+ (see the module docstring). `propagate = False` so nothing double-logs
143
+ through the root logger if something else ever configures that too. Safe to
144
+ call more than once -- replaces this logger's handlers each time rather than
145
+ accumulating them (previous "app" listeners are stopped first).
146
+
147
+ **`filename` exists for the instance-isolation design principle.**
148
+ All of ibcontroller's files live flat in one shared `log_dir` (2026-09-08:
149
+ the old per-instance `log_dir/{instance}` subdirectory was removed), so two
150
+ instances never collide because every filename carries the instance name --
151
+ `launcher.py`'s `launch_instance` passes
152
+ `filename=f"ibcontroller-{instance}.log"`, keeping the file self-identifying
153
+ whether it's copied, tailed, or globbed alongside another instance's log in
154
+ the same directory. This matches the convention `build_launch_plan` already
155
+ uses for socket filenames (`ibcontroller-agent-{instance}-{cmd,events}.sock`,
156
+ one shared runtime directory) -- one consistent scheme across all per-instance
157
+ files.
158
+ """
159
+ logger = logging.getLogger("ibcontroller")
160
+ _stop_listeners("app")
161
+ logger.setLevel(level)
162
+ logger.propagate = False
163
+ logger.handlers = []
164
+
165
+ if sink == "std":
166
+ console = logging.StreamHandler()
167
+ console.setFormatter(logging.Formatter(_FORMAT))
168
+ logger.addHandler(console)
169
+ elif sink == "file":
170
+ if log_dir is not None:
171
+ path = Path(log_dir)
172
+ path.mkdir(parents=True, exist_ok=True)
173
+ logger.addHandler(
174
+ _queued_file_handler(path / filename, key="app", mode="a", fmt=_FORMAT)
175
+ )
176
+ else:
177
+ raise ValueError(f"sink: expected 'std' or 'file', got {sink!r}")
178
+
179
+
180
+ def configure_trace(
181
+ *,
182
+ instance: str,
183
+ enabled: bool,
184
+ trace_dir: str | Path | None = None,
185
+ ) -> None:
186
+ """Configures one instance's two trace streams, `ibcontroller.trace.{instance}`
187
+ child loggers `.cmd` and `.event` -- the loggers `dispatch.py`'s `Dispatcher`
188
+ emits its NDJSON lines to (one line per command, one per raw event message).
189
+
190
+ `enabled` must be `True` **and** `trace_dir` given for tracing to be on;
191
+ otherwise the two loggers are set to a level above CRITICAL with no
192
+ handlers, so `Dispatcher`'s `isEnabledFor(DEBUG)` gate is false and nothing
193
+ is ever emitted (and no files created). When enabled, each stream gets its
194
+ own `FileHandler` (`cmd-{instance}.jsonl`/`events-{instance}.jsonl` under
195
+ `trace_dir`, created here) with a message-only formatter, behind its own
196
+ QueueHandler/QueueListener pair -- file open is `"w"` (truncate on start,
197
+ 2026-09-05 per the user's own steer: a trace file only makes sense for the
198
+ current session, stale lines from long-dead instances must not survive a
199
+ relaunch).
200
+
201
+ Safe to call more than once for the same instance -- previous listeners for
202
+ that instance are stopped first. Reconfiguring a different instance leaves
203
+ this one's streams untouched."""
204
+ key = f"trace:{instance}"
205
+ _stop_listeners(key)
206
+ cmd_logger = logging.getLogger(f"ibcontroller.trace.{instance}.cmd")
207
+ event_logger = logging.getLogger(f"ibcontroller.trace.{instance}.event")
208
+ for trace_logger in (cmd_logger, event_logger):
209
+ trace_logger.handlers = []
210
+ trace_logger.propagate = False
211
+
212
+ if not enabled or trace_dir is None:
213
+ cmd_logger.setLevel(_TRACE_LEVEL_DISABLED)
214
+ event_logger.setLevel(_TRACE_LEVEL_DISABLED)
215
+ return
216
+
217
+ path = Path(trace_dir)
218
+ path.mkdir(parents=True, exist_ok=True)
219
+ cmd_logger.setLevel(logging.DEBUG)
220
+ event_logger.setLevel(logging.DEBUG)
221
+ cmd_logger.addHandler(
222
+ _queued_file_handler(
223
+ path / f"cmd-{instance}.jsonl", key=key, mode="w", fmt=_MESSAGE_ONLY_FORMAT
224
+ )
225
+ )
226
+ event_logger.addHandler(
227
+ _queued_file_handler(
228
+ path / f"events-{instance}.jsonl",
229
+ key=key,
230
+ mode="w",
231
+ fmt=_MESSAGE_ONLY_FORMAT,
232
+ )
233
+ )
234
+
235
+
236
+ def configure_gateway_stdout(
237
+ instance: str, log_dir: str | Path, *, sink: str = "std"
238
+ ) -> logging.Logger:
239
+ """Configures one instance's logger for the launched process's raw stdout
240
+ (Gateway/TWS's own console/log4j output). `sink="file"` writes it, queued,
241
+ to `gateway-{instance}.log` under `log_dir` -- appended across the process's
242
+ whole life (unlike `configure_trace`'s per-session truncate: a restarted
243
+ process should keep adding to the same file, not lose the prior run's
244
+ lines). `sink="std"` (the default) instead attaches a synchronous console
245
+ `StreamHandler`, so Gateway/TWS's own log lines reach `docker logs` the same
246
+ way ibcontroller's own do (see module docstring) -- `log_dir` is unused in
247
+ this case. Either way, message-only formatter, since each line is already
248
+ Gateway/TWS's own formatted output.
249
+
250
+ Safe to call more than once for the same instance -- previous listeners for
251
+ that instance are stopped first, matching `configure_trace`'s own contract.
252
+ """
253
+ key = f"stdout:{instance}"
254
+ _stop_listeners(key)
255
+ logger = logging.getLogger(f"ibcontroller.stdout.{instance}")
256
+ logger.handlers = []
257
+ logger.propagate = False
258
+ logger.setLevel(logging.INFO)
259
+
260
+ if sink == "std":
261
+ console = logging.StreamHandler()
262
+ console.setFormatter(logging.Formatter(_MESSAGE_ONLY_FORMAT))
263
+ logger.addHandler(console)
264
+ elif sink == "file":
265
+ path = Path(log_dir)
266
+ path.mkdir(parents=True, exist_ok=True)
267
+ logger.addHandler(
268
+ _queued_file_handler(
269
+ path / f"gateway-{instance}.log",
270
+ key=key,
271
+ mode="a",
272
+ fmt=_MESSAGE_ONLY_FORMAT,
273
+ )
274
+ )
275
+ else:
276
+ raise ValueError(f"sink: expected 'std' or 'file', got {sink!r}")
277
+ return logger