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.
- quantui/__init__.py +311 -0
- quantui/analytics.py +609 -0
- quantui/app.py +5650 -0
- quantui/app_analysis.py +662 -0
- quantui/app_builders.py +2465 -0
- quantui/app_exports.py +194 -0
- quantui/app_formatters.py +493 -0
- quantui/app_history.py +624 -0
- quantui/app_runflow.py +1544 -0
- quantui/app_visualization.py +2620 -0
- quantui/ase_bridge.py +236 -0
- quantui/benchmarks.py +1543 -0
- quantui/c_stderr.py +124 -0
- quantui/cactus.py +88 -0
- quantui/calc_log.py +1116 -0
- quantui/calculator.py +204 -0
- quantui/cancellation.py +88 -0
- quantui/cli.py +288 -0
- quantui/comparison.py +306 -0
- quantui/config.py +725 -0
- quantui/data/js/3Dmol-min.js +2 -0
- quantui/data/js/3Dmol-min.js.LICENSE.txt +5 -0
- quantui/data/library/library.sqlite +0 -0
- quantui/data/manifests/bulk_qm9.json +1 -0
- quantui/data/manifests/curated.json +15482 -0
- quantui/data/manifests/presets.json +816 -0
- quantui/descriptor_cards.py +186 -0
- quantui/freq_calc.py +712 -0
- quantui/freq_ir_workers.py +229 -0
- quantui/gpu_offload.py +278 -0
- quantui/help_content.py +474 -0
- quantui/ir_plot.py +130 -0
- quantui/issue_tracker.py +170 -0
- quantui/live_log.py +387 -0
- quantui/log_utils.py +492 -0
- quantui/molecule.py +577 -0
- quantui/molecule_library.py +433 -0
- quantui/nmr_calc.py +437 -0
- quantui/optimizer.py +670 -0
- quantui/orbital_visualization.py +1102 -0
- quantui/pes_scan.py +420 -0
- quantui/preopt.py +355 -0
- quantui/progress.py +111 -0
- quantui/pubchem.py +1157 -0
- quantui/reorganization_energy.py +435 -0
- quantui/results_storage.py +902 -0
- quantui/security.py +14 -0
- quantui/session_calc.py +622 -0
- quantui/structure_providers.py +277 -0
- quantui/tddft_calc.py +307 -0
- quantui/user_settings.py +238 -0
- quantui/utils.py +287 -0
- quantui/vib_cache.py +247 -0
- quantui/visualization_py3dmol.py +593 -0
- quantui/viz_assets.py +101 -0
- quantui/viz_backend_router.py +243 -0
- quantui-0.5.1.dist-info/METADATA +533 -0
- quantui-0.5.1.dist-info/RECORD +62 -0
- quantui-0.5.1.dist-info/WHEEL +5 -0
- quantui-0.5.1.dist-info/entry_points.txt +2 -0
- quantui-0.5.1.dist-info/licenses/LICENSE +21 -0
- quantui-0.5.1.dist-info/top_level.txt +1 -0
quantui/issue_tracker.py
ADDED
|
@@ -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)
|