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