quantui 0.5.1__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.
Files changed (62) hide show
  1. quantui/__init__.py +311 -0
  2. quantui/analytics.py +609 -0
  3. quantui/app.py +5650 -0
  4. quantui/app_analysis.py +662 -0
  5. quantui/app_builders.py +2465 -0
  6. quantui/app_exports.py +194 -0
  7. quantui/app_formatters.py +493 -0
  8. quantui/app_history.py +624 -0
  9. quantui/app_runflow.py +1544 -0
  10. quantui/app_visualization.py +2620 -0
  11. quantui/ase_bridge.py +236 -0
  12. quantui/benchmarks.py +1543 -0
  13. quantui/c_stderr.py +124 -0
  14. quantui/cactus.py +88 -0
  15. quantui/calc_log.py +1116 -0
  16. quantui/calculator.py +204 -0
  17. quantui/cancellation.py +88 -0
  18. quantui/cli.py +288 -0
  19. quantui/comparison.py +306 -0
  20. quantui/config.py +725 -0
  21. quantui/data/js/3Dmol-min.js +2 -0
  22. quantui/data/js/3Dmol-min.js.LICENSE.txt +5 -0
  23. quantui/data/library/library.sqlite +0 -0
  24. quantui/data/manifests/bulk_qm9.json +1 -0
  25. quantui/data/manifests/curated.json +15482 -0
  26. quantui/data/manifests/presets.json +816 -0
  27. quantui/descriptor_cards.py +186 -0
  28. quantui/freq_calc.py +712 -0
  29. quantui/freq_ir_workers.py +229 -0
  30. quantui/gpu_offload.py +278 -0
  31. quantui/help_content.py +474 -0
  32. quantui/ir_plot.py +130 -0
  33. quantui/issue_tracker.py +170 -0
  34. quantui/live_log.py +387 -0
  35. quantui/log_utils.py +492 -0
  36. quantui/molecule.py +577 -0
  37. quantui/molecule_library.py +433 -0
  38. quantui/nmr_calc.py +437 -0
  39. quantui/optimizer.py +670 -0
  40. quantui/orbital_visualization.py +1102 -0
  41. quantui/pes_scan.py +420 -0
  42. quantui/preopt.py +355 -0
  43. quantui/progress.py +111 -0
  44. quantui/pubchem.py +1157 -0
  45. quantui/reorganization_energy.py +435 -0
  46. quantui/results_storage.py +902 -0
  47. quantui/security.py +14 -0
  48. quantui/session_calc.py +622 -0
  49. quantui/structure_providers.py +277 -0
  50. quantui/tddft_calc.py +307 -0
  51. quantui/user_settings.py +238 -0
  52. quantui/utils.py +287 -0
  53. quantui/vib_cache.py +247 -0
  54. quantui/visualization_py3dmol.py +593 -0
  55. quantui/viz_assets.py +101 -0
  56. quantui/viz_backend_router.py +243 -0
  57. quantui-0.5.1.dist-info/METADATA +533 -0
  58. quantui-0.5.1.dist-info/RECORD +62 -0
  59. quantui-0.5.1.dist-info/WHEEL +5 -0
  60. quantui-0.5.1.dist-info/entry_points.txt +2 -0
  61. quantui-0.5.1.dist-info/licenses/LICENSE +21 -0
  62. quantui-0.5.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,170 @@
1
+ """
2
+ Issue tracking for QuantUI.
3
+
4
+ User-reported issues are stored in a local SQLite database alongside the
5
+ session event log. Each issue captures a description and a snapshot of the
6
+ app state at the time of the report, making it possible to reconstruct the
7
+ sequence of events leading up to a problem.
8
+
9
+ Database location
10
+ -----------------
11
+ ``<log_dir>/issues.db`` where ``<log_dir>`` is the same directory used by
12
+ ``calc_log`` (``~/.quantui/logs`` by default, or ``$QUANTUI_LOG_DIR``).
13
+
14
+ Public API
15
+ ----------
16
+ ``log_issue(description, context, session_id)``
17
+ Save an issue and mirror it to the event log.
18
+
19
+ ``get_issues(n)``
20
+ Return the *n* most recent issues as a list of dicts.
21
+
22
+ ``clear_issues()``
23
+ Delete all issue records (drops and recreates the table).
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import json
29
+ import os
30
+ import sqlite3
31
+ import threading
32
+ from datetime import datetime, timezone
33
+ from pathlib import Path
34
+ from typing import Optional
35
+
36
+ _LOCK = threading.Lock()
37
+
38
+ # ---------------------------------------------------------------------------
39
+ # Path helpers (mirror calc_log so both use the same QUANTUI_LOG_DIR env var)
40
+ # ---------------------------------------------------------------------------
41
+
42
+
43
+ def _log_dir() -> Path:
44
+ env = os.environ.get("QUANTUI_LOG_DIR")
45
+ return Path(env) if env else Path.home() / ".quantui" / "logs"
46
+
47
+
48
+ def _db_path() -> Path:
49
+ return _log_dir() / "issues.db"
50
+
51
+
52
+ # ---------------------------------------------------------------------------
53
+ # Schema
54
+ # ---------------------------------------------------------------------------
55
+
56
+ _CREATE_TABLE = """
57
+ CREATE TABLE IF NOT EXISTS issues (
58
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
59
+ timestamp TEXT NOT NULL,
60
+ description TEXT NOT NULL,
61
+ session_id TEXT,
62
+ context_json TEXT
63
+ );
64
+ """
65
+
66
+
67
+ def _init_db() -> None:
68
+ db = _db_path()
69
+ db.parent.mkdir(parents=True, exist_ok=True)
70
+ with sqlite3.connect(str(db)) as conn:
71
+ conn.execute(_CREATE_TABLE)
72
+ conn.commit()
73
+
74
+
75
+ # ---------------------------------------------------------------------------
76
+ # Public API
77
+ # ---------------------------------------------------------------------------
78
+
79
+
80
+ def log_issue(
81
+ description: str,
82
+ context: Optional[dict] = None,
83
+ session_id: Optional[str] = None,
84
+ ) -> int:
85
+ """Save an issue report to SQLite and the event log.
86
+
87
+ Args:
88
+ description: Free-text description of the observed issue.
89
+ context: Optional snapshot dict (molecule, settings, last result,
90
+ recent events). Stored as JSON in the DB.
91
+ session_id: Caller-supplied session identifier for cross-referencing
92
+ with the event log.
93
+
94
+ Returns:
95
+ The auto-incremented issue id.
96
+ """
97
+ _init_db()
98
+ ts = datetime.now(timezone.utc).isoformat()
99
+ ctx_json = json.dumps(context or {}, ensure_ascii=False)
100
+ with _LOCK:
101
+ with sqlite3.connect(str(_db_path())) as conn:
102
+ cursor = conn.execute(
103
+ "INSERT INTO issues (timestamp, description, session_id, context_json)"
104
+ " VALUES (?, ?, ?, ?)",
105
+ (ts, description, session_id, ctx_json),
106
+ )
107
+ conn.commit()
108
+ issue_id: int = cursor.lastrowid # type: ignore[assignment]
109
+
110
+ # Mirror to the sequential event log so issues appear in context
111
+ try:
112
+ from quantui import calc_log as _clog
113
+
114
+ _clog.log_event(
115
+ "issue_filed",
116
+ description[:200],
117
+ issue_id=issue_id,
118
+ session_id=session_id,
119
+ )
120
+ except Exception:
121
+ pass
122
+
123
+ return issue_id
124
+
125
+
126
+ def get_issues(n: int = 50) -> list[dict]:
127
+ """Return the *n* most recent issues, newest first.
128
+
129
+ Args:
130
+ n: Maximum number of issues to return.
131
+
132
+ Returns:
133
+ List of dicts with keys: ``id``, ``timestamp``, ``description``,
134
+ ``session_id``, ``context``.
135
+ """
136
+ db = _db_path()
137
+ if not db.exists():
138
+ return []
139
+ with sqlite3.connect(str(db)) as conn:
140
+ rows = conn.execute(
141
+ "SELECT id, timestamp, description, session_id, context_json"
142
+ " FROM issues ORDER BY id DESC LIMIT ?",
143
+ (n,),
144
+ ).fetchall()
145
+ return [
146
+ {
147
+ "id": row[0],
148
+ "timestamp": row[1],
149
+ "description": row[2],
150
+ "session_id": row[3],
151
+ "context": json.loads(row[4] or "{}"),
152
+ }
153
+ for row in rows
154
+ ]
155
+
156
+
157
+ def clear_issues() -> None:
158
+ """Delete all issue records from the database.
159
+
160
+ Drops and recreates the ``issues`` table. The database file itself is
161
+ kept so the path remains stable.
162
+ """
163
+ db = _db_path()
164
+ if not db.exists():
165
+ return
166
+ with _LOCK:
167
+ with sqlite3.connect(str(db)) as conn:
168
+ conn.execute("DROP TABLE IF EXISTS issues")
169
+ conn.execute(_CREATE_TABLE)
170
+ conn.commit()
quantui/live_log.py ADDED
@@ -0,0 +1,387 @@
1
+ """Live calculation log that QuantUI owns end-to-end (M-LOGSCROLL route C).
2
+
3
+ Why this exists
4
+ ---------------
5
+ The Calculate-tab log used to be a plain ``widgets.Output``. ipywidgets rebuilds
6
+ that widget's DOM subtree on every appended line and resets ``scrollTop`` to 0,
7
+ so scrolling up during a run was impossible — the view snapped away within a
8
+ frame. The old workaround re-pinned the box to the bottom every animation frame,
9
+ which "fixed" jumps-to-top by permanently forcing stuck-at-bottom.
10
+
11
+ Ruled out in live Voilà sessions (2026-07-30):
12
+
13
+ - **Native anchoring alone** — still jumped to the top. ``overflow-anchor``
14
+ protects against *content insertion*; it cannot undo an explicit ``scrollTop``
15
+ assignment.
16
+ - **An outer scroll container** around a non-scrolling Output — also jumped to
17
+ the top: the Output's subtree teardown collapses the ancestor's
18
+ ``scrollHeight``, so the browser clamps the ancestor's ``scrollTop`` too.
19
+
20
+ So the re-render has to go, not be out-raced. This module owns one ``<div>``
21
+ created once and appends **text nodes** to it. With a stable node,
22
+ ``overflow-anchor: auto`` holds the user's position for free, and a single
23
+ "was I at the bottom?" check gives follow-the-tail.
24
+
25
+ How text reaches the browser — and why NOT ``display()``
26
+ --------------------------------------------------------
27
+ First implementation pushed each chunk as ``display(Javascript(...))`` into a
28
+ hidden Output. **That silently dropped every streaming line.** ``display()``
29
+ inside an Output routes by *parent message id*: the frontend captures iopub
30
+ messages whose parent matches the one ``Output.__enter__`` recorded. The run
31
+ header survived only because it is written while the Run click's comm message is
32
+ being processed. Output produced from the calc thread — or from an io_loop
33
+ callback — has no message being processed, so there is no parent to route by and
34
+ the payload never reaches the browser. Marshalling to the main thread did not
35
+ help, because the constraint is the *message context*, not the thread.
36
+
37
+ The old ``Output.append_stdout`` never had this problem because it mutates the
38
+ ``outputs`` **traitlet**, which syncs over the widget comm and is completely
39
+ independent of message parentage.
40
+
41
+ So this module uses the two mechanisms already proven in this app:
42
+
43
+ 1. **Traitlet sync carries the data** — setting a hidden widget's ``value``
44
+ works from any thread, no message context required.
45
+ 2. **JS installed once at render carries the behaviour** — a ``MutationObserver``
46
+ set up the same way the vib camera hook is (reflections/01 Rule 7), which
47
+ copies each chunk into the log container.
48
+
49
+ No ``display()`` on the streaming path at all. The observer reads chunks out of
50
+ mutation *records* rather than re-reading current DOM state, so nothing is lost
51
+ when several chunks land in the same frame.
52
+
53
+ Drop-in contract
54
+ ----------------
55
+ :class:`LiveLog` mimics the slice of ``widgets.Output`` the app already used —
56
+ ``append_stdout()``, ``clear_output()`` and assigning ``.outputs`` — so
57
+ ``_LogCapture.write``, the atomic run-header write and Clear are unchanged.
58
+
59
+ Known limitation
60
+ ----------------
61
+ Text lives in the DOM, not widget state, so a frontend re-render (kernel
62
+ reconnect) loses it. Python keeps the authoritative copy in :attr:`text`; call
63
+ :meth:`resync` to repaint. M-RECONNECT should call it when restoring a view.
64
+ """
65
+
66
+ from __future__ import annotations
67
+
68
+ import html as _html
69
+ import logging
70
+ import threading
71
+ from typing import Any, Optional
72
+
73
+ import ipywidgets as widgets
74
+ from IPython.display import Javascript, display
75
+
76
+ _LOG = logging.getLogger(__name__)
77
+
78
+ # Coalescing window for appends. PySCF emits many lines per second; batching
79
+ # keeps the comm quiet while staying short enough to read as live.
80
+ _FLUSH_INTERVAL_S = 0.12
81
+
82
+ _LOG_CLASS = "quantui-live-log"
83
+ _MAIL_CLASS = "quantui-live-mail"
84
+
85
+ # The container is the scroll box AND the text-node parent — deliberately no
86
+ # inner <span>. _APP_CSS's system-font rule targets bare `span` with !important,
87
+ # so a wrapper span would be forced back to a proportional font and re-break the
88
+ # ASCII header exactly as .jp-OutputArea-output did (see GOTCHAS).
89
+ _CONTAINER_STYLE = (
90
+ "height:300px;overflow-y:auto;overflow-anchor:auto;"
91
+ "border:1px solid #c0ccd8;border-radius:2px;padding:8px;"
92
+ "font-family:ui-monospace,SFMono-Regular,'SF Mono',Menlo,Consolas,"
93
+ "'Liberation Mono','Courier New',monospace;"
94
+ "font-size:12.5px;line-height:1.35;white-space:pre-wrap;"
95
+ "word-break:break-word;"
96
+ )
97
+
98
+ _PLACEHOLDER = "No calculation run yet. PySCF output and any errors will appear here."
99
+
100
+
101
+ def _bridge_js(log_cls: str, mail_cls: str) -> str:
102
+ """JS installed once at render: mailbox mutations → log container.
103
+
104
+ Retry-until-present mirrors ``_vib_bridge_set_mode``: at install time the
105
+ widgets may not be in the DOM yet. The watchdog re-attaches if ipywidgets
106
+ replaces the mailbox node, which it may do on any re-render.
107
+ """
108
+ return """
109
+ (function(){
110
+ var LOG = "__LOG_CLS__", MAIL = "__MAIL_CLS__";
111
+ var lastSeq = 0, observed = null;
112
+
113
+ function apply(node){
114
+ var seq = parseInt(node.getAttribute('data-qseq') || '0', 10);
115
+ if (!seq || seq <= lastSeq) { return; } // replay / out-of-order
116
+ if (seq > lastSeq + 1) {
117
+ console.warn('[quantui-live-log] gap', lastSeq, '->', seq);
118
+ }
119
+ lastSeq = seq;
120
+ var box = document.querySelector('.' + LOG);
121
+ if (!box) { return; }
122
+ var op = node.getAttribute('data-qop') || 'append';
123
+ var txt = node.textContent || '';
124
+ if (op === 'set') {
125
+ box.textContent = txt;
126
+ box.scrollTop = box.scrollHeight;
127
+ return;
128
+ }
129
+ // Append a text node: the existing DOM is untouched, so there is no
130
+ // re-render and therefore no scrollTop reset. That is the whole fix.
131
+ var atBottom = (box.scrollHeight - box.scrollTop - box.clientHeight) < 8;
132
+ box.appendChild(document.createTextNode(txt));
133
+ if (atBottom) { box.scrollTop = box.scrollHeight; }
134
+ }
135
+
136
+ function scan(nodes){
137
+ for (var i = 0; i < nodes.length; i++){
138
+ var n = nodes[i];
139
+ if (n.nodeType !== 1) { continue; }
140
+ if (n.hasAttribute && n.hasAttribute('data-qseq')) { apply(n); }
141
+ else if (n.querySelector) {
142
+ var inner = n.querySelector('[data-qseq]');
143
+ if (inner) { apply(inner); }
144
+ }
145
+ }
146
+ }
147
+
148
+ var obs = new MutationObserver(function(recs){
149
+ // Read from the RECORDS, not from current DOM state: several chunks can
150
+ // land in one frame and only the records preserve every one.
151
+ for (var i = 0; i < recs.length; i++){ scan(recs[i].addedNodes); }
152
+ });
153
+
154
+ function attach(){
155
+ var host = document.querySelector('.' + MAIL);
156
+ if (!host) { return false; }
157
+ if (host === observed) { return true; }
158
+ try { obs.disconnect(); } catch (e) {}
159
+ obs.observe(host, {childList: true, subtree: true});
160
+ observed = host;
161
+ // Catch anything delivered before the observer was live.
162
+ var pending = host.querySelector('[data-qseq]');
163
+ if (pending) { apply(pending); }
164
+ console.debug('[quantui-live-log] bridge attached');
165
+ return true;
166
+ }
167
+
168
+ var tries = 0;
169
+ (function boot(){
170
+ if (attach()) { return; }
171
+ if (++tries < 60) { setTimeout(boot, 50); }
172
+ else { console.warn('[quantui-live-log] mailbox never appeared'); }
173
+ })();
174
+
175
+ // ipywidgets may replace the mailbox node on a re-render, which silently
176
+ // detaches the observer. Cheap watchdog re-attaches.
177
+ setInterval(function(){
178
+ if (!observed || !document.contains(observed)) { attach(); }
179
+ }, 2000);
180
+ })();
181
+ """.replace("__LOG_CLS__", log_cls).replace("__MAIL_CLS__", mail_cls)
182
+
183
+
184
+ class LiveLog(widgets.VBox):
185
+ """An append-only, scroll-stable log surface.
186
+
187
+ Parameters
188
+ ----------
189
+ uid:
190
+ Suffix making the container/mailbox classes unique per app instance, so
191
+ two apps in one kernel cannot write into each other's log.
192
+ """
193
+
194
+ def __init__(self, uid: str = "main", **kwargs: Any) -> None:
195
+ self._cls = f"{_LOG_CLASS}-{uid}"
196
+ self._mail_cls = f"{_MAIL_CLASS}-{uid}"
197
+
198
+ self._container = widgets.HTML(
199
+ f'<div class="{self._cls}" style="{_CONTAINER_STYLE}">{_PLACEHOLDER}</div>'
200
+ )
201
+ # Transport. Setting .value is a traitlet sync over the widget comm —
202
+ # thread-safe and independent of message parentage, which is exactly
203
+ # what display() was not. Zero height so it never affects layout.
204
+ self._mailbox = widgets.HTML(
205
+ "", layout=widgets.Layout(height="0px", overflow="hidden", margin="0")
206
+ )
207
+ self._mailbox.add_class(self._mail_cls)
208
+ # Carries the one-time observer install. A Javascript output stored in
209
+ # an Output widget executes when that widget renders, which is how the
210
+ # old scroll guard and the vib camera hook both work.
211
+ self._bridge = widgets.Output(
212
+ layout=widgets.Layout(height="0px", overflow="hidden", margin="0")
213
+ )
214
+
215
+ self._lock = threading.RLock()
216
+ self._pending = ""
217
+ self._text = ""
218
+ self._seq = 0
219
+ self._timer: Optional[threading.Timer] = None
220
+ self._placeholder_showing = True
221
+ self._posts = 0
222
+ self._post_errors = 0
223
+
224
+ super().__init__([self._container, self._mailbox, self._bridge], **kwargs)
225
+ self._install_bridge()
226
+
227
+ # ── public state ────────────────────────────────────────────────────────
228
+
229
+ @property
230
+ def text(self) -> str:
231
+ """Everything written so far — the authoritative copy, not the DOM's."""
232
+ with self._lock:
233
+ return self._text + self._pending
234
+
235
+ def diagnostics(self) -> dict:
236
+ """Snapshot of transport health.
237
+
238
+ When the log is blank there is otherwise no way to tell whether Python
239
+ never sent, sent and raised, or sent fine and the browser dropped it.
240
+ Pair with the ``[quantui-live-log]`` console markers.
241
+ """
242
+ with self._lock:
243
+ return {
244
+ "posts": self._posts,
245
+ "errors": self._post_errors,
246
+ "seq": self._seq,
247
+ "chars": len(self._text) + len(self._pending),
248
+ "pending": len(self._pending),
249
+ "log_class": self._cls,
250
+ "mail_class": self._mail_cls,
251
+ }
252
+
253
+ # ── widgets.Output-compatible surface ───────────────────────────────────
254
+
255
+ def append_stdout(self, text: str) -> None:
256
+ """Append *text*. Safe to call from a background thread."""
257
+ if not text:
258
+ return
259
+ with self._lock:
260
+ self._pending += text
261
+ self._schedule_locked()
262
+
263
+ def clear_output(self, *_args: Any, **_kwargs: Any) -> None:
264
+ """Reset to the placeholder. Signature tolerates Output's kwargs."""
265
+ self.set_text("")
266
+
267
+ @property
268
+ def outputs(self) -> tuple:
269
+ """Mimic ``widgets.Output.outputs`` as a single stream entry."""
270
+ text = self.text
271
+ if not text:
272
+ return ()
273
+ return ({"output_type": "stream", "name": "stdout", "text": text},)
274
+
275
+ @outputs.setter
276
+ def outputs(self, value: tuple) -> None:
277
+ """Atomic replace — used by the on-click run-header write.
278
+
279
+ Atomicity matters: the header write became a single assignment to fix
280
+ the pre-step-1 blank-window bug, so this must not become
281
+ clear-then-append.
282
+ """
283
+ text = "".join(
284
+ item.get("text", "") for item in (value or ()) if isinstance(item, dict)
285
+ )
286
+ self.set_text(text)
287
+
288
+ # ── core operations ─────────────────────────────────────────────────────
289
+
290
+ def set_text(self, text: str) -> None:
291
+ """Replace the entire log body with *text* (empty → placeholder)."""
292
+ with self._lock:
293
+ self._cancel_timer_locked()
294
+ self._pending = ""
295
+ self._text = text
296
+ self._placeholder_showing = not text
297
+ self._post("set", text if text else _PLACEHOLDER)
298
+
299
+ def resync(self) -> None:
300
+ """Repaint the container from :attr:`text` after a frontend re-render."""
301
+ with self._lock:
302
+ self._post("set", (self._text + self._pending) or _PLACEHOLDER)
303
+
304
+ def flush(self) -> None:
305
+ """Force any buffered text out immediately."""
306
+ self._flush()
307
+
308
+ # ── internals ───────────────────────────────────────────────────────────
309
+
310
+ def _schedule_locked(self) -> None:
311
+ if self._timer is not None:
312
+ return
313
+ self._timer = threading.Timer(_FLUSH_INTERVAL_S, self._flush)
314
+ self._timer.daemon = True
315
+ self._timer.start()
316
+
317
+ def _cancel_timer_locked(self) -> None:
318
+ if self._timer is not None:
319
+ try:
320
+ self._timer.cancel()
321
+ except Exception: # noqa: BLE001 — best-effort
322
+ pass
323
+ self._timer = None
324
+
325
+ def _flush(self) -> None:
326
+ with self._lock:
327
+ self._timer = None
328
+ chunk, self._pending = self._pending, ""
329
+ if not chunk:
330
+ return
331
+ replacing_placeholder = self._placeholder_showing
332
+ self._placeholder_showing = False
333
+ self._text += chunk
334
+ # First real output replaces the placeholder rather than appending
335
+ # after it.
336
+ if replacing_placeholder:
337
+ self._post("set", self._text)
338
+ else:
339
+ self._post("append", chunk)
340
+
341
+ def _post(self, op: str, payload: str) -> None:
342
+ """Hand one chunk to the browser via the mailbox traitlet.
343
+
344
+ NOT named ``_send``: that is ``widgets.Widget._send``, the comm
345
+ transport itself. Overriding it breaks ``add_class`` and every state
346
+ sync — which is exactly what happened, and what
347
+ ``test_add_class_still_available`` now catches.
348
+
349
+ Sequence numbers let the observer discard replays and warn on gaps —
350
+ cheap insurance, since a lost chunk would otherwise be invisible.
351
+ """
352
+ self._seq += 1
353
+ try:
354
+ self._mailbox.value = (
355
+ f'<span data-qseq="{self._seq}" data-qop="{op}">'
356
+ f"{_html.escape(payload)}</span>"
357
+ )
358
+ self._posts += 1
359
+ except Exception as exc: # noqa: BLE001 — never break a run over a log
360
+ # Logged, not silently swallowed: a bare `except: pass` here is what
361
+ # hid the original transport failure for two live test rounds.
362
+ self._post_errors += 1
363
+ _LOG.warning("live-log post failed (op=%s): %s", op, exc)
364
+
365
+ @staticmethod
366
+ def _in_kernel() -> bool:
367
+ """True only inside a live IPython kernel.
368
+
369
+ Off-frontend (pytest, the CLI) there is nothing to render the installer
370
+ into, and displaying anyway writes to real stdout.
371
+ """
372
+ try:
373
+ from IPython import get_ipython
374
+
375
+ ip = get_ipython()
376
+ return ip is not None and getattr(ip, "kernel", None) is not None
377
+ except Exception: # noqa: BLE001 — absence of IPython is a valid answer
378
+ return False
379
+
380
+ def _install_bridge(self) -> None:
381
+ if not self._in_kernel():
382
+ return
383
+ try:
384
+ with self._bridge:
385
+ display(Javascript(_bridge_js(self._cls, self._mail_cls)))
386
+ except Exception as exc: # noqa: BLE001
387
+ _LOG.warning("live-log bridge install failed: %s", exc)