chartremotely 0.2.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.
chartremotely/ax.py ADDED
@@ -0,0 +1,278 @@
1
+ """macOS Accessibility and input primitives.
2
+
3
+ Everything awkward about driving thinkorswim lives here so the drivers above
4
+ stay readable. thinkorswim is a Java/Swing application, which breaks several
5
+ reasonable assumptions:
6
+
7
+ * Its accessibility tree is real, but chart children are populated lazily -
8
+ walking ``AXChildren`` misses the symbol field entirely. Hit-testing finds
9
+ it. See :func:`element_at`.
10
+ * ``AXValue`` is read-only on its text fields, while ``AXFocused`` is
11
+ settable. You focus a field and type into it; you cannot write it.
12
+ * Its menu items answer to ``AXPick``, not ``AXPress``.
13
+ * Its aggregation list is a *lightweight* popup painted inside the window.
14
+ It ignores process-targeted mouse events completely, so it needs a real
15
+ HID click - which in turn needs the target to be unobstructed.
16
+ * AWT ignores ``CGEventKeyboardSetUnicodeString``; only real virtual
17
+ keycodes register.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import time
23
+
24
+ import Quartz
25
+ from AppKit import NSWorkspace
26
+ from ApplicationServices import (
27
+ AXUIElementCopyActionNames,
28
+ AXUIElementCopyAttributeValue,
29
+ AXUIElementCopyElementAtPosition,
30
+ AXUIElementCreateApplication,
31
+ AXUIElementIsAttributeSettable,
32
+ AXUIElementPerformAction,
33
+ AXUIElementSetAttributeValue,
34
+ AXValueGetValue,
35
+ kAXErrorSuccess,
36
+ kAXValueCGPointType,
37
+ kAXValueCGSizeType,
38
+ )
39
+
40
+ APP_NAME = "thinkorswim"
41
+
42
+ # Virtual keycodes. AWT ignores unicode-string events, so every character
43
+ # must be sent as the key a human would press.
44
+ KEYCODE = {
45
+ "a": 0, "b": 11, "c": 8, "d": 2, "e": 14, "f": 3, "g": 5, "h": 4,
46
+ "i": 34, "j": 38, "k": 40, "l": 37, "m": 46, "n": 45, "o": 31, "p": 35,
47
+ "q": 12, "r": 15, "s": 1, "t": 17, "u": 32, "v": 9, "w": 13, "x": 7,
48
+ "y": 16, "z": 6,
49
+ "0": 29, "1": 18, "2": 19, "3": 20, "4": 21,
50
+ "5": 23, "6": 22, "7": 26, "8": 28, "9": 25,
51
+ ".": 47, "/": 44, "-": 27, ":": 41, "$": 21,
52
+ }
53
+ KEY_RETURN, KEY_ESCAPE, KEY_DOWN = 36, 53, 125
54
+
55
+
56
+ class NotRunning(RuntimeError):
57
+ """thinkorswim is not running."""
58
+
59
+
60
+ def running_app():
61
+ for a in NSWorkspace.sharedWorkspace().runningApplications():
62
+ if APP_NAME.lower() in (a.localizedName() or "").lower():
63
+ return a
64
+ raise NotRunning(f"{APP_NAME} is not running")
65
+
66
+
67
+ def handle(app=None):
68
+ return AXUIElementCreateApplication((app or running_app()).processIdentifier())
69
+
70
+
71
+ def activate(app=None) -> None:
72
+ """Bring thinkorswim forward, and wait for it to actually be forward.
73
+
74
+ This must happen BEFORE any hit-testing.
75
+ ``AXUIElementCopyElementAtPosition`` resolves against the application's
76
+ menu bar - not the window under the point - when the app is not
77
+ frontmost, so every lookup silently returns ``AXMenuBar``.
78
+ """
79
+ (app or running_app()).activateWithOptions_(2)
80
+ time.sleep(0.6)
81
+
82
+
83
+ def attr(element, name):
84
+ err, value = AXUIElementCopyAttributeValue(element, name, None)
85
+ return value if err == kAXErrorSuccess else None
86
+
87
+
88
+ def settable(element, name) -> bool:
89
+ err, is_settable = AXUIElementIsAttributeSettable(element, name, None)
90
+ return bool(is_settable) if err == kAXErrorSuccess else False
91
+
92
+
93
+ def set_attr(element, name, value) -> bool:
94
+ return AXUIElementSetAttributeValue(element, name, value) == kAXErrorSuccess
95
+
96
+
97
+ def actions(element) -> list[str]:
98
+ err, names = AXUIElementCopyActionNames(element, None)
99
+ return list(names) if err == kAXErrorSuccess else []
100
+
101
+
102
+ def perform(element, action) -> bool:
103
+ """Run an action. Menu items want ``AXPick``; buttons want ``AXPress``."""
104
+ return AXUIElementPerformAction(element, action) == kAXErrorSuccess
105
+
106
+
107
+ def _geo(element, name, kind):
108
+ value = attr(element, name)
109
+ if value is None:
110
+ return None
111
+ ok, out = AXValueGetValue(value, kind, None)
112
+ return out if ok else None
113
+
114
+
115
+ def position(element):
116
+ return _geo(element, "AXPosition", kAXValueCGPointType)
117
+
118
+
119
+ def size(element):
120
+ return _geo(element, "AXSize", kAXValueCGSizeType)
121
+
122
+
123
+ def centre(element) -> tuple[int, int] | None:
124
+ p, s = position(element), size(element)
125
+ return (int(p.x + s.width / 2), int(p.y + s.height / 2)) if p and s else None
126
+
127
+
128
+ def element_at(ax_app, x: float, y: float):
129
+ """Hit-test a screen point. Requires the app to be frontmost."""
130
+ err, element = AXUIElementCopyElementAtPosition(ax_app, float(x), float(y), None)
131
+ return element if err == kAXErrorSuccess else None
132
+
133
+
134
+ def window(ax_app, title_prefix: str):
135
+ for w in attr(ax_app, "AXWindows") or []:
136
+ if (attr(w, "AXTitle") or "").startswith(title_prefix):
137
+ return w
138
+ return None
139
+
140
+
141
+ def walk(element, visit, depth: int = 0, budget: list[int] | None = None) -> None:
142
+ """Depth-first walk. Chart subtrees are lazy, so this cannot find
143
+ everything - use it for dialogs, and hit-testing for charts."""
144
+ budget = budget if budget is not None else [80000]
145
+ budget[0] -= 1
146
+ if budget[0] <= 0 or depth > 45:
147
+ return
148
+ visit(element)
149
+ for child in attr(element, "AXChildren") or []:
150
+ walk(child, visit, depth + 1, budget)
151
+
152
+
153
+ def find(root, *, role=None, label=None, predicate=None) -> list:
154
+ """Collect matching descendants. ``label`` checks title, value and
155
+ description - thinkorswim scatters captions across all three."""
156
+ hits = []
157
+
158
+ def visit(element):
159
+ if role and attr(element, "AXRole") != role:
160
+ return
161
+ if label is not None and label not in (
162
+ attr(element, "AXTitle"), attr(element, "AXValue"),
163
+ attr(element, "AXDescription"),
164
+ ):
165
+ return
166
+ if predicate and not predicate(element):
167
+ return
168
+ hits.append(element)
169
+
170
+ walk(root, visit)
171
+ return hits
172
+
173
+
174
+ # --------------------------------------------------------------------------
175
+ # Input
176
+
177
+
178
+ def key(code: int, flags: int = 0, pid: int | None = None) -> None:
179
+ """Send a keystroke, to a process when given a pid.
180
+
181
+ ``CGEventPost(kCGHIDEventTap)`` delivers to whatever holds keyboard
182
+ focus, which is not reliably thinkorswim when another window floats
183
+ always-on-top - the keystrokes land in that window instead.
184
+ ``CGEventPostToPid`` removes the race.
185
+ """
186
+ for down in (True, False):
187
+ event = Quartz.CGEventCreateKeyboardEvent(None, code, down)
188
+ Quartz.CGEventSetFlags(event, flags)
189
+ if pid is None:
190
+ Quartz.CGEventPost(Quartz.kCGHIDEventTap, event)
191
+ else:
192
+ Quartz.CGEventPostToPid(pid, event)
193
+ time.sleep(0.012)
194
+
195
+
196
+ def type_text(text: str, pid: int | None = None) -> None:
197
+ for ch in text.lower():
198
+ code = KEYCODE.get(ch)
199
+ if code is None:
200
+ continue
201
+ key(code, Quartz.kCGEventFlagMaskShift if ch in "$:" else 0, pid)
202
+ time.sleep(0.02)
203
+
204
+
205
+ def escape(pid: int) -> None:
206
+ key(KEY_ESCAPE, 0, pid)
207
+
208
+
209
+ # --------------------------------------------------------------------------
210
+ # Occlusion. A real HID click goes to whatever is visually on top, so
211
+ # clicking blind can land in another application entirely.
212
+
213
+
214
+ def _regular_pids() -> set[int]:
215
+ """Ordinary windowed apps.
216
+
217
+ System overlays - Notification Center above all - keep permanent
218
+ full-screen windows at high layers with alpha 1.0 that are visually
219
+ empty. Counting those as obstructions blocks every click forever.
220
+ """
221
+ return {a.processIdentifier()
222
+ for a in NSWorkspace.sharedWorkspace().runningApplications()
223
+ if a.activationPolicy() == 0}
224
+
225
+
226
+ def occluders_at(x: float, y: float, tos_pid: int) -> list[int]:
227
+ """Pids of regular apps whose windows sit above thinkorswim at a point."""
228
+ regular = _regular_pids()
229
+ found, seen_target = [], False
230
+ for w in Quartz.CGWindowListCopyWindowInfo(
231
+ Quartz.kCGWindowListOptionOnScreenOnly, Quartz.kCGNullWindowID):
232
+ pid = w.get("kCGWindowOwnerPID")
233
+ b = w.get("kCGWindowBounds") or {}
234
+ covers = (b.get("X", 0) <= x <= b.get("X", 0) + b.get("Width", 0)
235
+ and b.get("Y", 0) <= y <= b.get("Y", 0) + b.get("Height", 0))
236
+ if pid == tos_pid and covers:
237
+ seen_target = True
238
+ elif (covers and not seen_target and pid in regular
239
+ and w.get("kCGWindowLayer", 0) >= 0 and w.get("kCGWindowAlpha", 1)):
240
+ found.append(pid)
241
+ return list(dict.fromkeys(found))
242
+
243
+
244
+ def hide_apps(pids) -> list:
245
+ apps = [a for a in NSWorkspace.sharedWorkspace().runningApplications()
246
+ if a.processIdentifier() in pids and not a.isHidden()]
247
+ for a in apps:
248
+ a.hide()
249
+ if apps:
250
+ time.sleep(0.6)
251
+ return apps
252
+
253
+
254
+ def unhide_apps(apps) -> None:
255
+ for a in apps:
256
+ a.unhide()
257
+
258
+
259
+ class Obstructed(RuntimeError):
260
+ """The click target is covered and the click was refused."""
261
+
262
+
263
+ def click(x: int, y: int, tos_pid: int) -> None:
264
+ """Real HID click, refused unless the target is provably unobstructed."""
265
+ blockers = occluders_at(x, y, tos_pid)
266
+ if blockers:
267
+ names = sorted({(a.localizedName() or "?")
268
+ for a in NSWorkspace.sharedWorkspace().runningApplications()
269
+ if a.processIdentifier() in blockers})
270
+ raise Obstructed(f"target at ({x},{y}) is covered by {', '.join(names)}")
271
+ Quartz.CGEventPost(Quartz.kCGHIDEventTap, Quartz.CGEventCreateMouseEvent(
272
+ None, Quartz.kCGEventMouseMoved, Quartz.CGPointMake(x, y),
273
+ Quartz.kCGMouseButtonLeft))
274
+ time.sleep(0.25)
275
+ for kind in (Quartz.kCGEventLeftMouseDown, Quartz.kCGEventLeftMouseUp):
276
+ Quartz.CGEventPost(Quartz.kCGHIDEventTap, Quartz.CGEventCreateMouseEvent(
277
+ None, kind, Quartz.CGPointMake(x, y), Quartz.kCGMouseButtonLeft))
278
+ time.sleep(0.12)
chartremotely/cli.py ADDED
@@ -0,0 +1,136 @@
1
+ """Command line entry point."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+
8
+
9
+ def main(argv: list[str] | None = None) -> int:
10
+ parser = argparse.ArgumentParser(
11
+ prog="chartremotely",
12
+ description="Remote control of desktop charting applications on macOS.")
13
+ sub = parser.add_subparsers(dest="command", required=True)
14
+
15
+ show = sub.add_parser("show", help="put a security on the chart")
16
+ show.add_argument("name", nargs="+", help="ticker or spoken company name")
17
+ show.add_argument("--scale", default="", help="time frame mnemonic")
18
+
19
+ scale = sub.add_parser("scale", help="change the chart's time frame")
20
+ scale.add_argument("phrase", nargs="+")
21
+
22
+ sub.add_parser("read", help="report the chart's symbol and scale")
23
+ sub.add_parser("scales", help="list the time frames this chart offers")
24
+ sub.add_parser("learn", help="rediscover the chart's controls")
25
+ sub.add_parser("doctor", help="check everything this agent needs")
26
+ sub.add_parser("token", help="print the agent's access token")
27
+
28
+ setup = sub.add_parser("studies", help="rebuild the chart's study set")
29
+ setup.add_argument("--row-height", default=None,
30
+ help="profile row height; match the strike increment (1.0, 2.5)")
31
+
32
+ serve = sub.add_parser("serve", help="run the local listener")
33
+ serve.add_argument("--port", type=int, default=None)
34
+
35
+ pair = sub.add_parser("pair", help="adopt this display to an operator")
36
+ pair.add_argument("--operator", default=None, help="operator base URL")
37
+
38
+ relay = sub.add_parser("relay", help="hold a connection open for the operator")
39
+ relay.add_argument("--operator", default=None)
40
+
41
+ sub.add_parser("setup", help="pair this Mac and make its voice Shortcut, step by step")
42
+
43
+ perms = sub.add_parser("permissions", help="report (or request) Accessibility and Screen Recording")
44
+ perms.add_argument("--request", action="store_true", help="ask macOS to prompt for them")
45
+ perms.add_argument("--out", default=None, help="write the answer as JSON to this file")
46
+
47
+ args = parser.parse_args(argv)
48
+
49
+ # Imported lazily so `doctor` can explain a missing dependency rather
50
+ # than dying on an ImportError.
51
+ if args.command == "doctor":
52
+ from .doctor import report
53
+ return report()
54
+ if args.command == "token":
55
+ from .config import ensure_token
56
+ print(ensure_token())
57
+ return 0
58
+ if args.command == "pair":
59
+ from .relay import pair as do_pair
60
+ print("Give this code to your MCP client: ", end="", flush=True)
61
+ try:
62
+ do_pair(args.operator, on_code=lambda c: print(c, flush=True))
63
+ except Exception as exc: # noqa: BLE001 - surface the reason plainly
64
+ print(f"pairing failed: {exc}")
65
+ return 2
66
+ print("paired")
67
+ return 0
68
+ if args.command == "setup":
69
+ from .setup import SetupError
70
+ from .setup import run as run_setup
71
+ try:
72
+ return run_setup()
73
+ except (SetupError, RuntimeError, OSError) as exc:
74
+ print(f"setup stopped: {exc}")
75
+ print("Fix that, then run `chartremotely setup` again; finished steps are skipped.")
76
+ return 2
77
+ if args.command == "permissions":
78
+ import json
79
+ from pathlib import Path
80
+
81
+ from .services import permissions
82
+ answer = json.dumps(permissions(args.request))
83
+ if args.out:
84
+ Path(args.out).write_text(answer)
85
+ print(answer)
86
+ return 0
87
+ if args.command == "relay":
88
+ from .relay import run
89
+ run(args.operator)
90
+ return 0
91
+ if args.command == "serve":
92
+ from .server import serve as run
93
+ run(args.port)
94
+ return 0
95
+
96
+ from . import symbol, timeframe
97
+ from .vocab import ERR, cmd_resolve, cmd_set, dispatch
98
+
99
+ if args.command == "show":
100
+ spoken = " ".join(args.name)
101
+ ticker = spoken if _looks_like_ticker(spoken) else cmd_resolve(spoken)
102
+ if ticker.startswith(ERR):
103
+ print(ticker)
104
+ return 2
105
+ print(cmd_set(ticker, args.scale))
106
+ return 0
107
+ if args.command == "scale":
108
+ print(dispatch("scale " + " ".join(args.phrase)))
109
+ return 0
110
+ if args.command == "read":
111
+ print(dispatch("read"))
112
+ return 0
113
+ if args.command == "scales":
114
+ for label in timeframe.presets():
115
+ print(" ", label)
116
+ return 0
117
+ if args.command == "studies":
118
+ from . import studies
119
+ for line in studies.setup(args.row_height):
120
+ print(" ", line)
121
+ return 0
122
+ if args.command == "learn":
123
+ symbol.learn()
124
+ timeframe.learn()
125
+ print("controls rediscovered")
126
+ return 0
127
+ return 1
128
+
129
+
130
+ def _looks_like_ticker(text: str) -> bool:
131
+ from .symbol import TICKER
132
+ return bool(TICKER.match(text.strip().upper()))
133
+
134
+
135
+ if __name__ == "__main__":
136
+ sys.exit(main())
@@ -0,0 +1,68 @@
1
+ """One configuration file, in one place.
2
+
3
+ Replaces the scattered ``~/.config/tos-symbol.json`` and
4
+ ``~/.config/tos-chart-token`` of the prototype.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import os
11
+ import secrets
12
+ from pathlib import Path
13
+
14
+ CONFIG_DIR = Path(os.path.expanduser("~/.config/chartremotely"))
15
+ CONFIG_PATH = CONFIG_DIR / "config.json"
16
+ DATA_DIR = Path(os.path.expanduser("~/.local/share/chartremotely"))
17
+
18
+ DEFAULTS: dict = {
19
+ # Window whose title starts with this holds the chart. The build number
20
+ # is deliberately excluded so a thinkorswim update does not break it.
21
+ "window_prefix": "Main@thinkorswim",
22
+ # Offsets are relative to the WINDOW, not the screen, so moving or
23
+ # resizing the window does not invalidate them.
24
+ "symbol_dx": None,
25
+ "symbol_dy": None,
26
+ "aggregation_dx": None,
27
+ "aggregation_dy": None,
28
+ # Row height should match the underlying's strike increment so volume
29
+ # profile buckets land on tradable strikes.
30
+ "row_height": "1.0",
31
+ # The SEC wants a contactable address on registry requests.
32
+ "contact": None,
33
+ # Set once the agent is adopted by an operator.
34
+ "operator_url": None,
35
+ "agent_id": None,
36
+ "agent_secret": None,
37
+ "token": None,
38
+ "port": 8899,
39
+ }
40
+
41
+
42
+ def load() -> dict:
43
+ cfg = dict(DEFAULTS)
44
+ if CONFIG_PATH.exists():
45
+ cfg.update(json.loads(CONFIG_PATH.read_text()))
46
+ return cfg
47
+
48
+
49
+ def save(cfg: dict) -> None:
50
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
51
+ tmp = CONFIG_PATH.with_suffix(".tmp")
52
+ tmp.write_text(json.dumps(cfg, indent=2))
53
+ tmp.chmod(0o600)
54
+ tmp.replace(CONFIG_PATH)
55
+
56
+
57
+ def update(**changes) -> dict:
58
+ cfg = load()
59
+ cfg.update(changes)
60
+ save(cfg)
61
+ return cfg
62
+
63
+
64
+ def ensure_token() -> str:
65
+ cfg = load()
66
+ if not cfg.get("token"):
67
+ cfg = update(token=secrets.token_urlsafe(32))
68
+ return cfg["token"]
@@ -0,0 +1,118 @@
1
+ """Check everything the agent needs, and say what to do about each failure.
2
+
3
+ Written for someone who cannot see the screen. When a wall display in
4
+ another building goes quiet, this is the first thing to run - so every
5
+ check names the fix, not just the symptom.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import urllib.error
11
+ import urllib.request
12
+
13
+ from . import config
14
+
15
+ OK, BAD, WARN = " ok ", " FAIL ", " warn "
16
+
17
+
18
+ def _check_pyobjc() -> tuple[str, str]:
19
+ try:
20
+ import Quartz # noqa: F401
21
+ from ApplicationServices import AXUIElementCreateApplication # noqa: F401
22
+ except ImportError:
23
+ return BAD, 'PyObjC missing - pip install "chartremotely[macos]"'
24
+ return OK, "PyObjC present"
25
+
26
+
27
+ def _check_app() -> tuple[str, str]:
28
+ from . import ax
29
+ try:
30
+ app = ax.running_app()
31
+ except ax.NotRunning:
32
+ return BAD, "thinkorswim is not running - start it"
33
+ return OK, f"thinkorswim running (pid {app.processIdentifier()})"
34
+
35
+
36
+ def _check_accessibility() -> tuple[str, str]:
37
+ """A denied grant looks like an empty tree, not an error."""
38
+ from . import ax
39
+ try:
40
+ app = ax.running_app()
41
+ except ax.NotRunning:
42
+ return WARN, "skipped - thinkorswim is not running"
43
+ windows = ax.attr(ax.handle(app), "AXWindows")
44
+ if not windows:
45
+ return BAD, ("no accessibility access - grant it in System Settings > "
46
+ "Privacy & Security > Accessibility. This cannot be automated.")
47
+ return OK, f"accessibility granted ({len(windows)} windows visible)"
48
+
49
+
50
+ def _check_controls() -> tuple[str, str]:
51
+ from . import ax, symbol, timeframe
52
+ cfg = config.load()
53
+ try:
54
+ ax_app = ax.handle()
55
+ ax.activate()
56
+ except ax.NotRunning:
57
+ return WARN, "skipped - thinkorswim is not running"
58
+ found_symbol = symbol.discover(ax_app, cfg["window_prefix"])
59
+ found_scale = timeframe.discover(ax_app, cfg["window_prefix"])
60
+ if not found_symbol:
61
+ return BAD, "no symbol field found - is a chart visible in the window?"
62
+ if not found_scale:
63
+ return WARN, "symbol field found, but no aggregation control - scale changes unavailable"
64
+ return OK, f"symbol field at {found_symbol}, aggregation at {found_scale}"
65
+
66
+
67
+ def _check_registry() -> tuple[str, str]:
68
+ from . import registry
69
+ if not registry.CACHE.exists():
70
+ return WARN, "security registry not cached yet - it downloads on first use"
71
+ try:
72
+ rows = registry.load()
73
+ except (OSError, ValueError) as exc:
74
+ return BAD, f"registry unreadable: {exc}"
75
+ if not config.load().get("contact"):
76
+ return WARN, (f"{len(rows)} securities cached, but no contact address set - "
77
+ "the SEC answers 403 without one. Set it in config.json.")
78
+ return OK, f"{len(rows)} securities cached"
79
+
80
+
81
+ def _check_listener() -> tuple[str, str]:
82
+ cfg = config.load()
83
+ port = cfg["port"]
84
+ token = cfg.get("token")
85
+ if not token:
86
+ return WARN, "no token yet - it is generated when the listener first starts"
87
+ request = urllib.request.Request(
88
+ f"http://127.0.0.1:{port}/chart", data=b"read",
89
+ headers={"X-Token": token}, method="POST")
90
+ try:
91
+ with urllib.request.urlopen(request, timeout=5) as response:
92
+ return OK, f"listener answering on :{port} - {response.read().decode().strip()}"
93
+ except urllib.error.URLError:
94
+ return WARN, f"nothing listening on :{port} - run: chartremotely serve"
95
+
96
+
97
+ CHECKS = (
98
+ ("PyObjC", _check_pyobjc),
99
+ ("thinkorswim", _check_app),
100
+ ("Accessibility", _check_accessibility),
101
+ ("Chart controls", _check_controls),
102
+ ("Registry", _check_registry),
103
+ ("Listener", _check_listener),
104
+ )
105
+
106
+
107
+ def report() -> int:
108
+ """Print every check. Returns non-zero if any hard check failed."""
109
+ failed = False
110
+ for name, check in CHECKS:
111
+ try:
112
+ status, detail = check()
113
+ except Exception as exc: # noqa: BLE001 - a doctor must never crash
114
+ status, detail = BAD, f"check raised: {exc}"
115
+ if status == BAD:
116
+ failed = True
117
+ print(f"[{status}] {name:<16} {detail}")
118
+ return 1 if failed else 0