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/cli.py ADDED
@@ -0,0 +1,211 @@
1
+ """ibcontroller CLI entrypoint. This is the `ibcontroller` command that is installed
2
+ by the package. It is a thin wrapper around `ibcontroller.main`, which does the actual
3
+ work of loading config/labels and running the control loop -- this module only parses
4
+ arguments, reports what happened in human-readable form, and maps exceptions to exit
5
+ codes.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import os
12
+ from importlib.metadata import PackageNotFoundError
13
+ from importlib.metadata import version as _pkg_version
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+ import attrs
18
+ import typer
19
+
20
+ from ibcontroller import main as _main
21
+ from ibcontroller.app_dirs import resolve_app_dirs
22
+ from ibcontroller.config import Config, ConfigError, TradingMode
23
+ from ibcontroller.control_loop import OPERATIONAL_ERRORS as _CYCLE_OPERATIONAL_ERRORS
24
+
25
+ # Known "operational" failure modes (config mistakes, an install that isn't where
26
+ # configured, a real login/settings failure) -- reported as a clean one-line message
27
+ # and exit 1, not a traceback. Anything not in this tuple is unexpected and should show
28
+ # its full traceback (a real bug report, not a deployment mistake to fix and retry).
29
+ # `ConfigError`/`RuntimeError` happen before a cycle even starts (config load); the
30
+ # rest is `control_loop.py`'s own set, reused rather than duplicated here.
31
+ _OPERATIONAL_ERRORS = (
32
+ ConfigError,
33
+ RuntimeError,
34
+ *_CYCLE_OPERATIONAL_ERRORS,
35
+ )
36
+
37
+ app = typer.Typer(
38
+ no_args_is_help=True,
39
+ help="Login/launch controller for TWS and IBKR Gateway.",
40
+ )
41
+
42
+ # Real `Config` field names -- source of truth for `_cli_overrides` below, so a
43
+ # future field rename is caught immediately (KeyError-shaped, via the `in` check)
44
+ # rather than the override silently never reaching `Config`.
45
+ _CONFIG_FIELD_NAMES = frozenset(f.name for f in attrs.fields(Config))
46
+
47
+
48
+ def _cli_overrides(**kwargs: Any) -> dict[str, Any]:
49
+ """Builds `load_config`'s `cli_overrides` dict from `run`'s own per-field CLI
50
+ options: drops unset (`None`) options -- a `None` would wrongly override a real
51
+ TOML/env value -- and stringifies `Path` options (typed-settings expects the same
52
+ string shape TOML/env values already arrive in)."""
53
+ return {
54
+ k: (str(v) if isinstance(v, Path) else v)
55
+ for k, v in kwargs.items()
56
+ if v is not None and k in _CONFIG_FIELD_NAMES
57
+ }
58
+
59
+
60
+ @app.command()
61
+ def init(
62
+ force: bool = typer.Option(
63
+ False,
64
+ "--force",
65
+ help="Overwrite existing scaffold files with the bundled templates.",
66
+ ),
67
+ app_dir: Path | None = typer.Option( # noqa: B008 -- typer's own idiom
68
+ None,
69
+ "--app-dir",
70
+ help="Override IBC_APP_DIR for this invocation -- config/log/run "
71
+ "all move under {app_dir}/{config,log,run} instead of the platform default.",
72
+ ),
73
+ ) -> None:
74
+ """Scaffold the config directory with starter ibcontroller.toml/
75
+ ibkr_settings.toml.example/labels.json.example files. Never overwrites a file
76
+ that already exists unless --force is given."""
77
+ if app_dir is not None:
78
+ os.environ["IBC_APP_DIR"] = str(app_dir)
79
+ config_dir, log_dir = resolve_app_dirs()
80
+ written = _main.ensure_config_scaffold(config_dir, log_dir, force=force)
81
+
82
+ typer.echo(f"config dir: {config_dir}")
83
+ typer.echo(f"log dir: {log_dir}")
84
+ if written:
85
+ for path in written:
86
+ typer.echo(f" wrote {path.name}")
87
+ else:
88
+ typer.echo(
89
+ " nothing to do -- every scaffold file already exists "
90
+ "(use --force to overwrite)"
91
+ )
92
+ if "ibcontroller.toml" in {p.name for p in written} or force:
93
+ typer.echo(
94
+ f"\nEdit {config_dir / 'ibcontroller.toml'} (set trading_mode/tws_version) "
95
+ "and set IBC_USERID/IBC_PASSWORD before running "
96
+ "`ibcontroller run`."
97
+ )
98
+
99
+
100
+ @app.command()
101
+ def run( # noqa: PLR0913, PLR0917 -- one typer.Option per Config field, not
102
+ # actually 8 callers'-worth of complexity
103
+ dotenv: Path | None = typer.Option( # noqa: B008 -- typer's own idiom
104
+ None,
105
+ "--dotenv",
106
+ help="Optional .env file to load into the environment before reading config "
107
+ "(only fills variables not already set).",
108
+ ),
109
+ app_dir: Path | None = typer.Option( # noqa: B008 -- typer's own idiom
110
+ None,
111
+ "--app-dir",
112
+ help="Override IBC_APP_DIR for this invocation -- config/log/run "
113
+ "all move under {app_dir}/{config,log,run} instead of the platform default.",
114
+ ),
115
+ trading_mode: TradingMode | None = typer.Option( # noqa: B008
116
+ None,
117
+ "--trading-mode",
118
+ help="Override Config.trading_mode ('live'/'paper') for this invocation.",
119
+ ),
120
+ tws_path: Path | None = typer.Option( # noqa: B008 -- typer's own idiom
121
+ None,
122
+ "--tws-path",
123
+ help="Override Config.tws_path (the TWS/Gateway install-path inference) "
124
+ "for this invocation.",
125
+ ),
126
+ tws_channel: str | None = typer.Option(
127
+ None,
128
+ "--tws-channel",
129
+ help="Override Config.tws_channel ('stable'/'latest', filters install "
130
+ "auto-detection) for this invocation.",
131
+ ),
132
+ program: str | None = typer.Option(
133
+ None,
134
+ "--program",
135
+ help="Override Config.program ('gateway'/'tws') for this invocation.",
136
+ ),
137
+ tws_settings_path: Path | None = typer.Option( # noqa: B008
138
+ None,
139
+ "--tws-settings-path",
140
+ help="Override Config.tws_settings_path (where TWS/Gateway stores its own "
141
+ "settings) for this invocation.",
142
+ ),
143
+ instance: str | None = typer.Option(
144
+ None,
145
+ "--instance",
146
+ help="Override Config.instance (per-instance log/trace/socket names) for "
147
+ "this invocation. Defaults to '{program}-{trading_mode}', so --trading-mode "
148
+ "alone is usually enough to keep paper/live instances apart.",
149
+ ),
150
+ ) -> None:
151
+ """Run one ibcontroller instance until it stops (Ctrl-C for a graceful shutdown,
152
+ or Gateway/TWS exiting or restarting on its own). Scaffolds the config directory
153
+ first if it's missing, same as `ibcontroller init`.
154
+
155
+ `--trading-mode`/`--tws-path`/`--tws-channel`/`--program`/`--tws-settings-path`/
156
+ `--instance` let one
157
+ invocation pick these `Config` fields directly, overriding TOML/env for this run
158
+ only -- e.g. `ibcontroller run --trading-mode=live --dotenv=.env-live` and
159
+ `ibcontroller run --trading-mode=paper --dotenv=.env-paper` run side by side from
160
+ one shared config."""
161
+ if app_dir is not None:
162
+ os.environ["IBC_APP_DIR"] = str(app_dir)
163
+ config_dir, log_dir = resolve_app_dirs()
164
+ cli_overrides = _cli_overrides(
165
+ trading_mode=trading_mode,
166
+ tws_path=tws_path,
167
+ tws_channel=tws_channel,
168
+ program=program,
169
+ tws_settings_path=tws_settings_path,
170
+ instance=instance,
171
+ )
172
+ try:
173
+ cause = asyncio.run(
174
+ _main.run_async(
175
+ config_dir,
176
+ log_dir,
177
+ dotenv_path=dotenv,
178
+ cli_overrides=cli_overrides,
179
+ )
180
+ )
181
+ except ConfigError as exc:
182
+ typer.echo(f"error: {exc}", err=True)
183
+ typer.echo(
184
+ f"\nEdit {config_dir / 'ibcontroller.toml'} (run `ibcontroller init` "
185
+ "first if it doesn't exist yet) and set "
186
+ "IBC_USERID/IBC_PASSWORD.",
187
+ err=True,
188
+ )
189
+ raise typer.Exit(1) from exc
190
+ except _OPERATIONAL_ERRORS as exc:
191
+ typer.echo(f"error: {exc}", err=True)
192
+ raise typer.Exit(1) from exc
193
+
194
+ typer.echo(f"stopped: {cause.name}")
195
+
196
+
197
+ @app.command()
198
+ def version() -> None:
199
+ """Print the installed ibcontroller version."""
200
+ try:
201
+ typer.echo(_pkg_version("ibcontroller"))
202
+ except PackageNotFoundError:
203
+ typer.echo("unknown (not installed)")
204
+
205
+
206
+ def main() -> None:
207
+ app()
208
+
209
+
210
+ if __name__ == "__main__":
211
+ main()
ibcontroller/config.py ADDED
@@ -0,0 +1,410 @@
1
+ """
2
+ ibcontroller config
3
+
4
+ Configuration settings hierarchy, precedence, and validation.
5
+ Credentials (userid/password) are deliberately environment-variable-only, never accepted
6
+ from a file. This is a security measure to avoid accidentally committing secrets to
7
+ source control, and to support Docker secrets and other secret-management patterns.
8
+ The config file (ibcontroller.toml) is intentionally flat (no [section] headers) to
9
+ simplify parsing and avoid confusion. The config file is optional; if it doesn't exist,
10
+ the defaults are used, and environment variables can override them.
11
+
12
+ The precedence is: defaults < config file < environment variables.
13
+ """
14
+
15
+ import logging
16
+ import os
17
+ import sys
18
+ import tomllib
19
+ from collections.abc import Mapping
20
+ from enum import StrEnum
21
+ from pathlib import Path
22
+ from typing import Any
23
+
24
+ import attrs
25
+ import typed_settings as ts
26
+ from dotenv import load_dotenv
27
+ from typed_settings.converters import Converter
28
+ from typed_settings.dict_utils import set_path
29
+ from typed_settings.exceptions import TsError
30
+ from typed_settings.loaders import (
31
+ DictLoader,
32
+ EnvLoader,
33
+ FileLoader,
34
+ LoadedSettings,
35
+ LoaderMeta,
36
+ TomlFormat,
37
+ )
38
+ from typed_settings.processors import FormatProcessor
39
+ from typed_settings.types import Secret
40
+
41
+ from ibcontroller.app_dirs import resolve_app_dirs
42
+
43
+ ENV_PREFIX = "IBC_"
44
+ ENV_SENSITIVE: list[str] = ["IBC_USERID", "IBC_PASSWORD"]
45
+
46
+ # Field/TOML key names that must never appear in the config file -- credentials are
47
+ # environment-variable-only (see module docstring). Mirrors config_old.py's own
48
+ # _CREDENTIAL_KEY_NAMES, updated for this module's `userid` field name.
49
+ _CREDENTIAL_KEY_NAMES = frozenset({"userid", "password", "tws_userid", "tws_password"})
50
+
51
+ _LOG_LEVELS = {
52
+ "debug": logging.DEBUG,
53
+ "info": logging.INFO,
54
+ "warning": logging.WARNING,
55
+ "error": logging.ERROR,
56
+ }
57
+
58
+
59
+ class ConfigError(Exception):
60
+ """Bad or unsafe configuration -- an invalid value, a missing required setting.
61
+ The one exception type `load_config` raises; wraps typed-settings' own errors
62
+ (`TsError` subclasses) as well as the hand-written checks below (e.g. missing
63
+ credentials) so callers (`cli.py`) only ever need to catch one type."""
64
+
65
+
66
+ def _find_credential_keys(data: Mapping[str, Any], prefix: str = "") -> list[str]:
67
+ """Recursively searches a nested mapping for any keys that look like credentials.
68
+ Returns the full dotted paths to those keys. Direct port of config_old.py's own
69
+ function of the same name."""
70
+ found = []
71
+ for key, value in data.items():
72
+ path = f"{prefix}{key}"
73
+ if key in _CREDENTIAL_KEY_NAMES:
74
+ found.append(path)
75
+ if isinstance(value, Mapping):
76
+ found.extend(_find_credential_keys(value, prefix=f"{path}."))
77
+ return found
78
+
79
+
80
+ def _reject_credentials_in_file(data: Mapping[str, Any], path: Path) -> None:
81
+ """Raises ConfigError if any credential-shaped keys are found in the parsed TOML
82
+ data -- credentials are environment-variable-only, never accepted from a file
83
+ (direct port of config_old.py's function of the same name)."""
84
+ found = _find_credential_keys(data)
85
+ if found:
86
+ raise ConfigError(
87
+ f"{path}: credentials must not be set in the config file "
88
+ f"({', '.join(found)} found) -- use environment variables instead "
89
+ "(IBC_USERID/IBC_PASSWORD, each also accepting a "
90
+ "_FILE-suffixed variant)"
91
+ )
92
+
93
+
94
+ class _FileBackedEnvLoader(EnvLoader):
95
+ """Docker-secrets pattern: for each env var, `{var}_FILE` wins if set (trimmed
96
+ file contents); otherwise falls back to the plain var, same as `EnvLoader`.
97
+ Direct port of config_old.py's own `_env_or_file`, adapted to the `Loader` protocol
98
+ (`__call__(settings_cls, options) -> LoadedSettings`, see loaders.py) rather than
99
+ a plain function -- typed-settings has no `_FILE` convention of its own."""
100
+
101
+ def __call__(self, settings_cls: object, options: object) -> LoadedSettings:
102
+ env = os.environ
103
+ values: dict[str, Any] = {}
104
+ for option in options: # type: ignore[attr-defined]
105
+ varname = self.get_envvar(option)
106
+ file_varname = f"{varname}_FILE"
107
+ if file_varname in env:
108
+ secret_path = Path(env[file_varname])
109
+ try:
110
+ value = secret_path.read_text(encoding="utf-8").strip()
111
+ except OSError as exc:
112
+ raise ConfigError(f"{file_varname}={secret_path}: {exc}") from exc
113
+ set_path(values, option.path, value) # type: ignore[attr-defined]
114
+ elif varname in env:
115
+ set_path(values, option.path, env[varname]) # type: ignore[attr-defined]
116
+ return LoadedSettings(values, LoaderMeta(self))
117
+
118
+
119
+ def _log_level_converter(raw: object) -> int:
120
+ """
121
+ Accept the declared default (already a real `int`, e.g. `logging.INFO`) as well as a
122
+ raw name string from the environment/file layers.
123
+ """
124
+ if isinstance(raw, int):
125
+ return raw
126
+ name = str(raw).strip().lower()
127
+ if name not in _LOG_LEVELS:
128
+ raise ValueError(
129
+ f"log_level: expected one of {', '.join(sorted(_LOG_LEVELS))}, got {raw!r}"
130
+ )
131
+ return _LOG_LEVELS[name]
132
+
133
+
134
+ class TradingMode(StrEnum):
135
+ LIVE = "live"
136
+ PAPER = "paper"
137
+
138
+
139
+ class ExistingSessionAction(StrEnum):
140
+ """ExistingSessionDetectedAction is the user's choice for what to do when IBC
141
+ detects an existing TWS/Gateway session (see `ExistingSessionRecognizer` in
142
+ recognisers.py). The default is MANUAL, which leaves the user to decide what to do
143
+ in the GUI. PRIMARY and SECONDARY are the two automatic options, and
144
+ PRIMARY_OVERRIDE is a special case that forces the primary session to close even if
145
+ it is logged in as a different user (see the `ExistingSessionRecognizer` docstring
146
+ for why this is a real risk).
147
+ """
148
+
149
+ MANUAL = "manual"
150
+ PRIMARY = "primary"
151
+ PRIMARY_OVERRIDE = "primaryoverride"
152
+ SECONDARY = "secondary"
153
+
154
+
155
+ class LogSink(StrEnum):
156
+ """Where `configure_logging`/`configure_gateway_stdout` send their output --
157
+ exclusive, not additive: FILE is file-only (no console), STD is console-only
158
+ (no file). Deliberately excludes `configure_trace`'s NDJSON wire trace, which
159
+ stays file-only regardless (meant to be tailed, not mixed into a formatted
160
+ stdout stream). Default is STD (see `log_sink` field below for why)."""
161
+
162
+ FILE = "file"
163
+ STD = "std"
164
+
165
+
166
+ class DiagnosticScope(StrEnum):
167
+ """Which windows `diagnostics.watch_for_diagnostics` dumps -- input from
168
+ IBC's `LogStructureScope`, not a straight copy (IBC also has `untitled`,
169
+ dropped here: `WindowInfo.title` already distinguishes a real untitled
170
+ window from one merely unmatched by any recogniser, so `UNKNOWN` alone
171
+ covers it)."""
172
+
173
+ KNOWN = "known"
174
+ UNKNOWN = "unknown"
175
+ ALL = "all"
176
+
177
+
178
+ class DiagnosticWhen(StrEnum):
179
+ """When `diagnostics.watch_for_diagnostics` dumps a window -- input from
180
+ IBC's `LogStructureWhen`, minus `activate` (no matching `WindowEvent`
181
+ kind exists today; only `window_opened`/`window_closed`). NEVER is the
182
+ default -- diagnostics ship inert until explicitly opted into."""
183
+
184
+ OPEN = "open"
185
+ OPENCLOSE = "openclose"
186
+ NEVER = "never"
187
+
188
+
189
+ class AcceptIncomingConnections(StrEnum):
190
+ """AcceptIncomingConnections is the user's choice for what to do when ibcontroller
191
+ detects incoming API connections (see `AcceptIncomingConnectionsRecognizer` in
192
+ recognisers.py). The default is MANUAL, which leaves the user to decide what to do
193
+ in the GUI. ACCEPT and REJECT are the two automatic options, and MANUAL is a special
194
+ case that forces the user to decide what to do in the GUI.
195
+ """
196
+
197
+ ACCEPT = "accept"
198
+ REJECT = "reject"
199
+ MANUAL = "manual"
200
+
201
+
202
+ @ts.settings
203
+ class Config:
204
+ """
205
+ IBController's own config file, `ibcontroller.toml`.
206
+ """
207
+
208
+ # The IBKR settings file to load (if any) -- see `ibkr_settings.toml.example`.
209
+ # Defaults to `None` (inert) until a user points `[settings] file` at a renamed
210
+ # copy of the example file. See module docstring for why this is deliberate.
211
+ settings_file: str | None = None
212
+ # instance default: f"{program}-{trading_mode.value}".
213
+ instance: str = "{program}-{trading_mode}"
214
+ program: str = "gateway" # "gateway" or "tws"
215
+ # None -> auto-detected from the real install directory (launcher.
216
+ # _detect_tws_version), channel-aware via tws_channel below.
217
+ tws_version: str | None = None
218
+ # Filters auto-detection to installs carrying this channel (see
219
+ # launcher._detect_tws_version); "stable"/"latest" as directly observed on real
220
+ # installs (not a strict enum). Defaults to "stable", the safer pick when several
221
+ # channels are installed side by side.
222
+ tws_channel: str = "stable"
223
+ tws_path: str | None = None
224
+ # Where TWS/Gateway itself stores its settings (IBC's own TWS_SETTINGS_PATH) --
225
+ # deliberately NOT the same as tws_path (the install location). None defaults,
226
+ # per instance, in launcher.py's resolve_tws_settings_path.
227
+ tws_settings_path: str | None = None
228
+ # IBC-key-compatible settings (file or env, never a secret).
229
+ trading_mode: TradingMode = TradingMode.PAPER
230
+ # IBC: ReadOnlyLogin -- loaded but not yet wired to any behavior.
231
+ read_only_login: bool = False
232
+ read_only_api: bool | None = None # None = leave the existing setting unchanged
233
+ accept_incoming_connections: AcceptIncomingConnections = (
234
+ AcceptIncomingConnections.MANUAL
235
+ )
236
+ existing_session_action: ExistingSessionAction = ExistingSessionAction.MANUAL
237
+
238
+ # Login/MFA timeout and retry settings
239
+ # IBC: LoginDialogDisplayTimeout
240
+ login_dialog_display_timeout: float = 60.0
241
+ # IBC: SecondFactorAuthenticationTimeout
242
+ mfa_timeout: float = 180.0
243
+ # IBC: ReloginAfterSecondFactorAuthenticationTimeout
244
+ relogin_after_mfa_timeout: bool = False
245
+ # IBC: SecondFactorAuthenticationExitInterval
246
+ mfa_exit_interval: float = 60.0
247
+
248
+ # AutoRestartTime "hh:mm AM/PM" format (e.g. "08:00 AM")
249
+ auto_restart_time: str | None = None # None = leave the existing setting unchanged
250
+ # AutoLogoffTime "hh:mm AM/PM" format (e.g. "08:00 AM"); shares one Lock and
251
+ # Exit radio-button pair with auto_restart_time -- if both are set, the latter
252
+ # wins (applied last, matching builtin_settings.toml's order and IBC itself).
253
+ auto_logoff_time: str | None = None # None = leave the existing setting unchanged
254
+
255
+ # Self-scheduled shutdown actions (applies to TWS and Gateway alike), consumed
256
+ # by schedule.py/control_loop.py -- not GUI settings written to TWS/Gateway.
257
+ # ColdRestartTime "HH:MM" 24-hour local time; every Sunday, close the instance
258
+ # tidily and relaunch with a full fresh login (weekly reauth, Sunday 01:00
259
+ # US/Eastern token invalidation).
260
+ cold_restart_time: str | None = None # None = disable
261
+ # ClosedownAt "HH:MM" (daily) or "<Weekday> HH:MM" (weekly); close the instance
262
+ # tidily, no relaunch. If both cold_restart_time and closedown_at are set,
263
+ # whichever occurs first wins.
264
+ closedown_at: str | None = None # None = disable
265
+
266
+ # JVM heap for TWS/Gateway at launch, e.g. "1024m"/"4g". Overrides the
267
+ # -Xmx line in the installed .vmoptions file (ibgateway.vmoptions/tws.
268
+ # vmoptions); None = leave the file's value untouched.
269
+ java_heap_size: str | None = None
270
+
271
+ # Credentials -- environment-variable-only. Field name matches the real env var
272
+ # (IBC_USERID) directly -- no alias/mapping needed.
273
+ userid: Secret = ts.secret(default=None)
274
+ password: Secret = ts.secret(default=None)
275
+
276
+ # Logging and tracing -- log_dir is the one path ibcontroller's own log/trace files
277
+ # land in, always resolved: never None. Defaults to the platformdirs log dir
278
+ # (app_dirs.resolve_app_dirs, honoring IBC_APP_DIR's docker mode); a
279
+ # caller-supplied `load_config(log_dir=...)` is only a lower-priority default
280
+ # (DictLoader), so the config file's `log_dir` and the IBC_LOG_DIR env var
281
+ # genuinely override it. See load_config for the loader order.
282
+ trace_enabled: bool = False
283
+ log_dir: str = ts.option(factory=lambda: str(resolve_app_dirs()[1]))
284
+ # Logging level for ibcontroller's own log file (not the raw wire trace).
285
+ # Was previously unannotated (`log_level = logging.INFO`), which meant attrs
286
+ # never turned it into a real field at all -- not configurable, silently fixed
287
+ # at INFO, confirmed live via attrs.fields(Config).
288
+ log_level: int = ts.option(default=logging.INFO, converter=_log_level_converter)
289
+ # Where ibcontroller's own log (configure_logging) and Gateway/TWS's raw console
290
+ # output (configure_gateway_stdout) go -- "std" (console only) or "file" (only
291
+ # gateway-{instance}.log/ibcontroller-{instance}.log under log_dir), never both.
292
+ # Default is "std", not "file": this project's live-testing workflow relies on
293
+ # seeing logger output in the terminal during real login/restart sessions, and
294
+ # "std" is also what a Docker deployment wants with zero configuration (`docker
295
+ # logs` captures stdout/stderr, not files written inside the container). "file"
296
+ # is the explicit opt-in for a headless/service deployment that wants a
297
+ # persistent log file instead. Does not affect configure_trace's NDJSON wire
298
+ # trace, which stays file-only regardless (gitea #26).
299
+ log_sink: LogSink = LogSink.STD
300
+
301
+ # Diagnostics -- see diagnostics.py. Off by default (diagnostic_when=never);
302
+ # an opted-in deployment gets a structure dump logged for each matching
303
+ # window event, the replacement for socket-poking the agent by hand.
304
+ diagnostic_scope: DiagnosticScope = DiagnosticScope.KNOWN
305
+ diagnostic_when: DiagnosticWhen = DiagnosticWhen.NEVER
306
+
307
+
308
+ def _format_config(config: Config) -> list[str]:
309
+ """One `key=value` line per `Config` field, sorted by name -- credentials already
310
+ masked (`Secret.__str__` returns `'*******'` unconditionally, even for `None`), for
311
+ startup/diagnostic display (gitea #45)."""
312
+ return [
313
+ f"{f.name}={getattr(config, f.name)!s}"
314
+ for f in sorted(attrs.fields(type(config)), key=lambda f: f.name)
315
+ ]
316
+
317
+
318
+ def load_config(
319
+ config_dir: str | Path,
320
+ log_dir: str | Path | None = None,
321
+ toml_path: str | Path | None = None,
322
+ dotenv_path: str | Path | None = None,
323
+ cli_overrides: Mapping[str, Any] | None = None,
324
+ ) -> Config:
325
+
326
+ load_dotenv(dotenv_path, override=False)
327
+ sys.stdout.write("IBController > loaded from dotenv (override=False)\n")
328
+ env = os.environ
329
+
330
+ # display the env vars we actually read, but never print credentials in plaintext
331
+ sys.stdout.write("IBController > Environment variable: \n")
332
+ for key, val in env.items():
333
+ if key in ENV_SENSITIVE:
334
+ sys.stdout.write(f"IBController > env {key}=******\n")
335
+ elif key.startswith(ENV_PREFIX) and key not in ENV_SENSITIVE:
336
+ sys.stdout.write(f"IBController > env {key}={val}\n")
337
+
338
+ _config_file = (
339
+ Path(toml_path) if toml_path else Path(config_dir) / "ibcontroller.toml"
340
+ )
341
+ # Credentials are environment-variable-only -- reject them here, against the raw
342
+ # parsed TOML, before ts.load_settings ever sees the file (typed-settings has no
343
+ # per-key allow/deny-list hook of its own; userid/password are real Config fields,
344
+ # so InvalidOptionsError would not catch this). Silently skipped if the file
345
+ # doesn't exist, matching FileLoader's own optional-file handling.
346
+ try:
347
+ with _config_file.open("rb") as f:
348
+ _raw_toml = tomllib.load(f)
349
+ except OSError:
350
+ pass
351
+ else:
352
+ _reject_credentials_in_file(_raw_toml, _config_file)
353
+
354
+ # section=None -- the TOML file is flat, no [section] headers (see
355
+ # config_old.py's docstring). TomlFormat("") looks up settings[""] instead of
356
+ # returning the top-level dict, so it silently discards the whole file --
357
+ # confirmed live.
358
+ CONF_FORMATS = {"*.toml": TomlFormat(None)}
359
+ # A caller-supplied log_dir (platform dirs in production, a tmp dir in tests) is
360
+ # a low-priority DEFAULT -- first in the loader list, so every later loader wins
361
+ # over it and `log_dir` in the config file (or IBC_LOG_DIR) genuinely
362
+ # takes effect. The Config field's own factory default (resolve_app_dirs) is the
363
+ # even-lower built-in base when neither param nor file/env set it.
364
+ _log_dir_default: dict[str, str] = {}
365
+ if log_dir is not None:
366
+ _log_dir_default["log_dir"] = str(log_dir)
367
+ CONF_LOADERS: list[FileLoader | EnvLoader | DictLoader] = [
368
+ DictLoader(_log_dir_default),
369
+ ts.loaders.FileLoader(
370
+ files=[_config_file],
371
+ # pyrefly: ignore [bad-argument-type]
372
+ formats=CONF_FORMATS,
373
+ ),
374
+ # Must come after FileLoader: later loaders win, so this reproduces
375
+ # defaults < file < env (config_old.py's own precedence). _FileBackedEnvLoader
376
+ # adds the _FILE-suffix Docker-secrets indirection on top of plain EnvLoader.
377
+ _FileBackedEnvLoader(prefix=ENV_PREFIX),
378
+ ]
379
+ # CLI flags (cli.py) are the most explicit, per-invocation expression of intent --
380
+ # last loader wins, so these override file/env unconditionally. Only added when
381
+ # the caller actually set something, same reasoning as _log_dir_default above.
382
+ if cli_overrides:
383
+ CONF_LOADERS.append(DictLoader(dict(cli_overrides)))
384
+ CONF_PROCESSORS: list[FormatProcessor] = [FormatProcessor()]
385
+ CONF_CONVERTER: Converter = ts.converters.default_converter()
386
+
387
+ try:
388
+ _config: Config = ts.load_settings(
389
+ Config,
390
+ loaders=CONF_LOADERS,
391
+ processors=CONF_PROCESSORS,
392
+ converter=CONF_CONVERTER,
393
+ )
394
+ except TsError as exc:
395
+ raise ConfigError(str(exc)) from exc
396
+
397
+ if (
398
+ _config.userid.get_secret_value() is None
399
+ or _config.password.get_secret_value() is None
400
+ ):
401
+ raise ConfigError(
402
+ "credentials not found -- set IBC_USERID and "
403
+ "IBC_PASSWORD in the environment"
404
+ )
405
+
406
+ sys.stdout.write("IBController > Config: \n")
407
+ for line in _format_config(_config):
408
+ sys.stdout.write(f"IBController > config {line}\n")
409
+
410
+ return _config