Coverage for scanpath_studio/loading.py: 97%

395 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-07 21:10 +0000

1"""UX-165: loading cards that keep the page whole, say what is happening, and 

2can be cancelled. 

3 

4Three pieces, all built on :mod:`scanpath_studio.progress`: 

5 

6* :func:`card` — a region card. It holds its region at the height the content 

7 will take (``size``), shows nothing for :data:`DELAY_S`, then reveals a card — 

8 title, elapsed time, a count or a step list, a bar, an optional Cancel — that 

9 a timer thread keeps current while the script thread is blocked. A card over 

10 work that is cheap on a cache hit is *gated* (``reveal_on_work``): it waits, 

11 past the delay, for its task's first report — a miss. 

12* :class:`Page` — the page skeleton and the dataset card, for a load that holds 

13 up a whole view. It outlives its card: :meth:`Page.release` takes it down once 

14 the new page has drawn its controls, and a region card opening releases it. 

15* :func:`run_scope` — wraps each script run, so no timer outlives its run. 

16 

17Mechanics, proved by the UX-165 spike: the **script thread** draws each card 

18hidden when it opens — its Cancel button included, since a widget must be 

19created on the script thread and once per run — and a **timer thread** 

20carrying the script-run context reveals it and rewrites only its text 

21placeholders, the way ``st.spinner``'s own timer does. A click abandons the 

22running script at once (``runner.fastReruns``); Streamlit drops everything the 

23abandoned run's timer sends, and the abandoned run cannot write session state. 

24""" 

25 

26from __future__ import annotations 

27 

28import contextlib 

29import contextvars 

30import html 

31import logging 

32import re 

33import threading 

34import time 

35from collections.abc import Callable, Hashable, Iterator, MutableMapping, Sequence 

36from dataclasses import dataclass, field 

37from typing import Any 

38 

39import streamlit as st 

40from streamlit.runtime.scriptrunner import ( 

41 RerunException, 

42 StopException, 

43 add_script_run_ctx, 

44 get_script_run_ctx, 

45) 

46 

47from scanpath_studio import progress 

48from scanpath_studio.constants import SELECTOR_ROW_GRID, icon_html 

49from scanpath_studio.progress import Snapshot 

50from scanpath_studio.styles import selector_track_floor 

51 

52_LOGGER = logging.getLogger(__name__) 

53 

54#: Nothing shows for this long — Streamlit's own spinner delay. 

55DELAY_S: float = 0.5 

56#: How often a card refreshes once it shows. 

57REFRESH_S: float = 0.25 

58#: Session-state home of the last embedded size per plot key. 

59PLOT_SIZES_KEY = "_sps_plot_sizes" 

60#: The Scanpath and Corpus views' reserved area (``app.main``). 

61VIEW_AREA_KEY = "sps_view" 

62#: The page card's key — there is one page per run. 

63PAGE_CARD_KEY = "page" 

64#: Tests that inspect a card frozen by ``st.stop()`` set this so the off-thread 

65#: clear (`_clear_off_thread`) doesn't fire; production code never does. 

66_KEEP_ON_STOP: bool = False 

67 

68 

69def _esc(text: Any) -> str: 

70 return html.escape(str(text), quote=True) 

71 

72 

73def format_elapsed(seconds: float) -> str: 

74 """``0.8 s`` · ``42 s`` · ``2 min 5 s``.""" 

75 if seconds < 10: 

76 return f"{seconds:.1f} s" 

77 if seconds < 60: 

78 return f"{seconds:.0f} s" 

79 minutes, rest = divmod(round(seconds), 60) 

80 return f"{minutes} min {rest} s" if rest else f"{minutes} min" 

81 

82 

83def _megabytes(n: int) -> str: 

84 return f"{n / 1e6:,.1f}" if n < 10e6 else f"{n / 1e6:,.0f}" 

85 

86 

87def format_count(done: int | None, total: int | None, unit: str) -> str: 

88 """``312 of 900 files`` · ``120 of 450 MB`` · ``3.2 MB so far``.""" 

89 if done is None: 

90 return "" 

91 if unit == "bytes": 

92 if total: 

93 return f"{_megabytes(done)} of {_megabytes(total)} MB" 

94 return f"{_megabytes(done)} MB so far" 

95 suffix = f" {unit}" if unit else "" 

96 if total: 

97 return f"{done:,} of {total:,}{suffix}" 

98 return f"{done:,}{suffix}" 

99 

100 

101def head_html(snap: Snapshot, *, last: float | None = None) -> str: 

102 """The card's first line: spinner, title, elapsed (+ the last load's time). 

103 

104 Its live region (``role="status"``) is the title and the current step's 

105 label only — the label visually hidden, since the detail line or the step 

106 list already shows it — so a screen reader hears each step once. The 

107 elapsed time and the count, repainted about four times a second, sit 

108 outside it: still on the card, just not announced on every tick. 

109 """ 

110 when = format_elapsed(snap.elapsed) 

111 if last is not None: 

112 when += f" · last load {format_elapsed(last)}" 

113 step = snap.steps[snap.current] if 0 <= snap.current < len(snap.steps) else "" 

114 spoken = _esc(snap.title) 

115 if step: 

116 spoken += f'<span class="sps-sr-only"> · {_esc(step)}</span>' 

117 return ( 

118 '<div class="sps-card-head">' 

119 '<span class="sps-ring" aria-hidden="true"></span>' 

120 f'<span class="sps-card-title" role="status" aria-live="polite">{spoken}</span>' 

121 f'<span class="sps-card-time">{_esc(when)}</span></div>' 

122 ) 

123 

124 

125def detail_html(snap: Snapshot) -> str: 

126 """The detail line: the current step, a detail, the count — or nothing.""" 

127 parts = [] 

128 if snap.steps and snap.current < len(snap.steps): 

129 parts.append(snap.steps[snap.current]) 

130 if snap.detail: 

131 parts.append(snap.detail) 

132 count = format_count(snap.done, snap.total, snap.unit) 

133 if count: 

134 parts.append(count) 

135 if not parts: 

136 return "" 

137 return f'<div class="sps-card-detail">{_esc(" · ".join(parts))}</div>' 

138 

139 

140def steps_html(snap: Snapshot) -> str: 

141 """B's step list: done steps with ✓ and their time, the current one with a 

142 spinner and its count, later ones greyed out.""" 

143 items = [] 

144 for i, label in enumerate(snap.steps): 

145 if i < snap.current: 

146 items.append( 

147 '<li class="sps-step sps-step-done">' 

148 f"{icon_html('step_done')}" 

149 f'<span class="sps-step-label">{_esc(label)}</span>' 

150 f'<span class="sps-step-time">' 

151 f"{_esc(format_elapsed(snap.step_seconds[i] or 0.0))}</span></li>" 

152 ) 

153 elif i == snap.current: 

154 extra = " · ".join( 

155 part 

156 for part in ( 

157 snap.detail or "", 

158 format_count(snap.done, snap.total, snap.unit), 

159 ) 

160 if part 

161 ) 

162 items.append( 

163 '<li class="sps-step sps-step-now">' 

164 '<span class="sps-ring" aria-hidden="true"></span>' 

165 f'<span class="sps-step-label">{_esc(label)}</span>' 

166 f'<span class="sps-step-count">{_esc(extra)}</span></li>' 

167 ) 

168 else: 

169 items.append( 

170 '<li class="sps-step sps-step-todo">' 

171 f"{icon_html('step_todo')}" 

172 f'<span class="sps-step-label">{_esc(label)}</span></li>' 

173 ) 

174 return f'<ol class="sps-steps">{"".join(items)}</ol>' 

175 

176 

177def bar_html(snap: Snapshot) -> str: 

178 """A thin bar: filling when a total is known, sliding otherwise — and full 

179 once the task has finished (a kept page card, every step ticked).""" 

180 if snap.finished or snap.total: 

181 pct = ( 

182 100 

183 if snap.finished 

184 else max(0, min(100, round(100 * (snap.done or 0) / snap.total))) 

185 ) 

186 return ( 

187 '<div class="sps-bar" role="progressbar" aria-valuemin="0" ' 

188 f'aria-valuemax="100" aria-valuenow="{pct}">' 

189 f'<span style="width:{pct}%"></span></div>' 

190 ) 

191 return '<div class="sps-bar sps-bar-indeterminate" aria-hidden="true"><span></span></div>' 

192 

193 

194#: A card key the size box can name in a selector. 

195_CARD_KEY = re.compile(r"[A-Za-z0-9_-]+") 

196 

197 

198def size_box_html(width: int, height: int, *, card_key: str) -> str: 

199 """The placeholder that holds a figure's place, and its card's width. 

200 

201 ``height`` is the true-scale iframe's *fixed* height (the figure's own 

202 height + 12): `html_embed.embed_html_iframe` passes an int to `st.iframe`, 

203 so the row is exactly that tall at any column width. ``width`` is where the 

204 figure itself stops; the rule beside the box caps the card's own container 

205 there, so the card — box and body — spans exactly the figure in a wide 

206 column and the column in a narrow one, and centres over the figure 

207 (styles.py). A definite cap from above, deliberately: a grid track sized by 

208 its content grows past a narrow column or collapses to the card. 

209 

210 Raises `ValueError` for a ``card_key`` that isn't a plain name (letters, 

211 digits, ``_`` and ``-``): it becomes a selector in a raw ``<style>``, where 

212 anything else would make the rule invalid and leave the card unstyled. 

213 """ 

214 if not _CARD_KEY.fullmatch(card_key): 

215 # It becomes a selector in a raw <style>: fail loudly, not unstyled. 

216 raise ValueError(f"card key {card_key!r} must be a plain name") 

217 return ( 

218 f'<div class="sps-size-box" aria-hidden="true" style="height:{int(height)}px"></div>' 

219 f"<style>.st-key-sps_card_{card_key}{{max-width:{int(width)}px}}</style>" 

220 ) 

221 

222 

223def selector_row_tracks() -> str: 

224 """The skeleton's `grid-template-columns` for the selector row. 

225 

226 Each track takes the real row's weight and the floor `styles.py` gives it 

227 (UX-181), so the skeleton draws the row the page is about to show. 

228 """ 

229 return " ".join( 

230 f"minmax({selector_track_floor(i)}, {w}fr)" 

231 for i, w in enumerate(SELECTOR_ROW_GRID) 

232 ) 

233 

234 

235def _scanpath_skeleton(plot_height: int) -> str: 

236 tracks = selector_row_tracks() 

237 fields = '<div class="sps-sk sps-sk-field"></div>' * len(SELECTOR_ROW_GRID) 

238 chips = '<div class="sps-sk sps-sk-chip"></div>' * 4 

239 rail = '<div class="sps-sk sps-sk-row"></div>' * 9 

240 return ( 

241 '<div class="sps-page-skeleton sps-sk-scanpath" aria-hidden="true">' 

242 '<div class="sps-sk-main">' 

243 f'<div class="sps-sk-selectors" style="grid-template-columns:{tracks}">' 

244 f"{fields}</div>" 

245 f'<div class="sps-sk-chips">{chips}</div>' 

246 f'<div class="sps-sk sps-sk-plot" style="height:{int(plot_height)}px"></div>' 

247 "</div>" 

248 f'<div class="sps-sk-rail">{rail}</div>' 

249 "</div>" 

250 ) 

251 

252 

253def skeleton_html(view: str, *, plot_height: int = 480) -> str: 

254 """A skeleton of ``view``: ``"scanpath"`` · ``"corpus"`` · ``"data"``.""" 

255 if view == "scanpath": 

256 return _scanpath_skeleton(plot_height) 

257 if view == "corpus": 

258 return ( 

259 '<div class="sps-page-skeleton sps-sk-corpus" aria-hidden="true">' 

260 '<div class="sps-sk sps-sk-tabs"></div>' 

261 '<div class="sps-sk sps-sk-chart"></div>' 

262 '<div class="sps-sk sps-sk-chart"></div></div>' 

263 ) 

264 rows = '<div class="sps-sk sps-sk-row"></div>' * 6 

265 return ( 

266 f'<div class="sps-page-skeleton sps-sk-table" aria-hidden="true">{rows}</div>' 

267 ) 

268 

269 

270def estimate_plot_size( 

271 canvas_width: int, canvas_height: int, *, animation: bool = False 

272) -> tuple[int, int]: 

273 """``(width, iframe height)`` a first figure on this canvas will take. 

274 

275 The builders' own fit (`plots._fit_display_size`) for a figure framed on the 

276 full canvas, plus the axes' margins and, for a replay, the transport 

277 controls under it. Only a first render uses it; later ones reuse the size 

278 the last figure under the same plot key actually had (:func:`plot_size`). 

279 """ 

280 from scanpath_studio.plots import ( 

281 _CONTROLS_MARGIN_PX, 

282 _CONTROLS_SAFETY_PX, 

283 _fit_display_size, 

284 ) 

285 

286 cw, ch = max(int(canvas_width), 1), max(int(canvas_height), 1) 

287 width, height = _fit_display_size(cw, ch, [0, cw], [ch, 0], True) 

288 height += 60 

289 if animation: 

290 height += _CONTROLS_MARGIN_PX + _CONTROLS_SAFETY_PX 

291 return int(width), int(height) + 12 

292 

293 

294def _session() -> MutableMapping[str, Any]: 

295 """Session state, or a throwaway dict outside a script run.""" 

296 try: 

297 return st.session_state 

298 except Exception: 

299 return {} 

300 

301 

302def record_plot_size(key: str, width: int, height: int) -> None: 

303 """Remember the size a figure under ``key`` was embedded at.""" 

304 state = _session() 

305 sizes = dict(state.get(PLOT_SIZES_KEY) or {}) 

306 sizes[str(key)] = (int(width), int(height)) 

307 state[PLOT_SIZES_KEY] = sizes 

308 

309 

310def plot_size( 

311 key: str, canvas_width: int, canvas_height: int, *, animation: bool = False 

312) -> tuple[int, int]: 

313 """The recorded size for ``key``, else :func:`estimate_plot_size`.""" 

314 recorded = (_session().get(PLOT_SIZES_KEY) or {}).get(str(key)) 

315 if recorded: 

316 return int(recorded[0]), int(recorded[1]) 

317 return estimate_plot_size(canvas_width, canvas_height, animation=animation) 

318 

319 

320def recorded_plot_height(key: str, default: int) -> int: 

321 recorded = (_session().get(PLOT_SIZES_KEY) or {}).get(str(key)) 

322 return int(recorded[1]) if recorded else int(default) 

323 

324 

325@dataclass(frozen=True) 

326class Cancel: 

327 """A card's Cancel button: its label says where it goes.""" 

328 

329 label: str 

330 on_click: Callable[..., None] 

331 args: tuple = () 

332 

333 

334def session_id() -> str: 

335 """This run's session id — part of every task key, so one session's cancel 

336 can never stop another session's work.""" 

337 ctx = get_script_run_ctx(suppress_warning=True) 

338 return ctx.session_id if ctx is not None else "local" 

339 

340 

341@dataclass 

342class _RunState: 

343 cards: list[Card] = field(default_factory=list) 

344 page: Page | None = None 

345 #: The page stood for a wait the user saw, and no ungated region card has 

346 #: carried it on yet (`card`): set by the region card whose opening released 

347 #: the page, cleared by the first ungated one — a gated card never clears it. 

348 inherit_seen: bool = False 

349 

350 

351_RUN: contextvars.ContextVar[_RunState | None] = contextvars.ContextVar( 

352 "scanpath_loading_run", default=None 

353) 

354 

355 

356def _run() -> _RunState: 

357 """The current run's state — or a throwaway one outside `run_scope`. 

358 

359 Never sets the ContextVar itself: `run_scope` owns that set/reset pairing. 

360 A fragment rerun can reuse the same ScriptRunner thread, so a stray 

361 `_RUN.set(...)` here — with nothing that would ever reset it — would leak 

362 one run's cards/page into a later one that never entered `run_scope`. 

363 """ 

364 state = _RUN.get() 

365 if state is None: 

366 return _RunState() 

367 return state 

368 

369 

370class _Ticker(threading.Thread): 

371 """The timer thread: wait out the delay, reveal, then refresh. 

372 

373 With ``ready`` (a gated card, `Card`'s ``reveal_on_work``) the reveal also 

374 waits for ``ready()`` to hold, asked again every ``interval`` once the delay 

375 is over — so a card over a cache hit never shows, however long the hit takes. 

376 

377 Its event is ``_halt``, never ``_stop``: `threading.Thread` has an internal 

378 ``_stop`` method that ``join`` calls. 

379 """ 

380 

381 def __init__( 

382 self, 

383 *, 

384 delay: float, 

385 interval: float, 

386 on_reveal: Callable[[], None], 

387 on_tick: Callable[[], None], 

388 ready: Callable[[], bool] | None = None, 

389 ): 

390 super().__init__(daemon=True, name="scanpath-loading-ticker") 

391 self._delay = delay 

392 self._interval = interval 

393 self._on_reveal = on_reveal 

394 self._on_tick = on_tick 

395 self._ready = ready 

396 self._halt = threading.Event() 

397 

398 def run(self) -> None: 

399 if self._halt.wait(self._delay): 

400 return 

401 try: 

402 while self._ready is not None and not self._ready(): 

403 if self._halt.wait(self._interval): 

404 return 

405 self._on_reveal() 

406 while not self._halt.wait(self._interval): 

407 self._on_tick() 

408 except StopException: 

409 # The run's coordinator has been told to stop; nothing left to 

410 # update, and this is an expected outcome, not a bug — no log. 

411 return 

412 except Exception: 

413 # A placeholder whose run has ended: nothing left to update, but 

414 # unlike a stop this wasn't expected, so leave a trace of it. 

415 _LOGGER.debug("Loading card ticker stopped early", exc_info=True) 

416 return 

417 

418 def halt(self) -> None: 

419 self._halt.set() 

420 

421 

422def _clear_off_thread(slot) -> None: 

423 """Clear ``slot`` from a short-lived helper thread. 

424 

425 Once a run is stopped or superseded, Streamlit raises `StopException` / 

426 `RerunException` on the SCRIPT thread the next time it tries to send a 

427 message, instead of sending it — so `slot.empty()` never lands there. 

428 Off the script thread, Streamlit only checks a parallel-fragment 

429 coordinator (absent for a plain helper thread like this one), so the same 

430 call from here goes through. 

431 """ 

432 

433 def _clear() -> None: 

434 try: 

435 slot.empty() 

436 except Exception: 

437 pass 

438 

439 thread = threading.Thread(target=_clear, daemon=True, name="scanpath-loading-clear") 

440 add_script_run_ctx(thread) 

441 thread.start() 

442 thread.join(timeout=1.0) 

443 

444 

445class Card: 

446 """One loading card in one slot (see the module docstring). 

447 

448 ``reveal_on_work`` gates the card, for one over work that is cheap on a 

449 cache hit and slow only on a miss (the dataset pipeline, Compare's second 

450 dataset, the Corpus view's measures): its timer reveals it only once its 

451 task has reported (`progress.Task.worked`), so a plain rerun — however long 

452 its cache checks take on a big corpus — never shows it. ``reveal_now`` 

453 still shows it at once. A region card that continues a wait the user saw 

454 (``inherit``, see `card`) skips the delay instead: ungated it shows at 

455 once; gated it shows the moment its task reports, and never on a hit. 

456 """ 

457 

458 def __init__( 

459 self, 

460 slot, 

461 *, 

462 key: str, 

463 title: str, 

464 steps: Sequence[str] = (), 

465 step_list: bool = False, 

466 cancel: Cancel | None = None, 

467 size: tuple[int, int] | None = None, 

468 skeleton: str | None = None, 

469 task_key: Hashable | None = None, 

470 duration_key: Hashable | None = None, 

471 reveal_class: str = "sps-reveal", 

472 reveal_on_work: bool = False, 

473 ): 

474 self._slot = slot 

475 self.key = key 

476 self._title = title 

477 self._steps = tuple(steps) 

478 self._step_list = step_list 

479 self._cancel = cancel 

480 self._size = size 

481 self._skeleton = skeleton 

482 self._explicit_task_key = task_key is not None 

483 self._task_key = ( 

484 task_key if task_key is not None else ("card", session_id(), key) 

485 ) 

486 self._duration_key = duration_key 

487 self._reveal_class = reveal_class 

488 self._reveal_on_work = reveal_on_work 

489 self._task: progress.Task | None = None 

490 self._token: contextvars.Token | None = None 

491 self._ticker: _Ticker | None = None 

492 self._revealed = False 

493 self._revealed_at: float | None = None 

494 self._revealed_by_timer = False 

495 self.is_open = False 

496 self._skeleton_ph = self._head = self._detail = self._bar = self._reveal = None 

497 # The "last load" hint, read once when the card opens: `finish` records 

498 # this load's own time, and the final repaint mustn't quote it back. 

499 self._last_duration: float | None = None 

500 

501 @property 

502 def steps(self) -> tuple[str, ...]: 

503 return self._steps 

504 

505 @property 

506 def revealed(self) -> bool: 

507 return self._revealed 

508 

509 @property 

510 def was_seen(self) -> bool: 

511 """Did this card stand for a wait the user saw? 

512 

513 Revealed by its timer (the wait outlasted `DELAY_S` — and, for a gated 

514 card, did real work), or on screen for at least `DELAY_S` since an 

515 immediate reveal (``reveal_now``). A page card 

516 revealed at once and taken down a moment later — a quick view switch — 

517 was not: nothing should carry its reveal on to the next card. 

518 """ 

519 if not self._revealed: 

520 return False 

521 if self._revealed_by_timer: 

522 return True 

523 return ( 

524 self._revealed_at is not None 

525 and time.monotonic() - self._revealed_at >= DELAY_S 

526 ) 

527 

528 @property 

529 def task(self) -> progress.Task | None: 

530 return self._task 

531 

532 def open(self, *, reveal_now: bool = False, inherit: bool = False) -> Card: 

533 """Draw the card hidden — its size box shows at once — and arm the timer. 

534 

535 Drawing happens before the task is begun and activated: if drawing 

536 itself fails (e.g. the run is already being torn down), no task is 

537 left active or half-started for something to have to clean up. 

538 

539 ``reveal_now`` shows the card here, gated or not — a view switch's 

540 skeleton, the card after adding a dataset, a view switch onto a load in 

541 flight: the wait is known to be long before it starts. ``DELAY_S <= 0`` 

542 (the headless tests' setting) does the same for every card: "show 

543 everything at once" is what that setting is for. 

544 

545 ``inherit`` — the card continues a wait the user saw (`card`) — skips 

546 the delay but not the gate: an ungated card shows here, as with 

547 ``reveal_now``; a gated one is armed with no delay, so it shows the 

548 moment its task reports work and never on a cache hit. 

549 """ 

550 box = self._slot.container(key=f"sps_card_{self.key}") 

551 if self._size is not None: 

552 box.markdown( 

553 size_box_html(*self._size, card_key=self.key), unsafe_allow_html=True 

554 ) 

555 if self._skeleton is not None: 

556 self._skeleton_ph = box.empty() 

557 body = box.container(key=f"sps_cardbody_{self.key}") 

558 self._head = body.empty() 

559 self._detail = body.empty() 

560 self._bar = body.empty() 

561 if self._cancel is not None: 

562 body.button( 

563 self._cancel.label, 

564 key=f"sps_cancel_{self.key}", 

565 on_click=self._cancel.on_click, 

566 args=self._cancel.args, 

567 width="content", 

568 ) 

569 self._reveal = body.empty() 

570 self.is_open = True 

571 _run().cards.append(self) 

572 if self._duration_key is not None: 

573 self._last_duration = progress.last_duration(self._duration_key) 

574 self._task = progress.begin( 

575 self._task_key, 

576 title=self._title, 

577 steps=self._steps, 

578 fresh=not self._explicit_task_key, 

579 ) 

580 self._token = progress.activate(self._task) 

581 if reveal_now or DELAY_S <= 0 or (inherit and not self._reveal_on_work): 

582 self._show() 

583 if DELAY_S > 0: 

584 self._arm(delay=REFRESH_S, revealed=True) 

585 elif inherit: 

586 self._arm(delay=0, revealed=False) 

587 else: 

588 self._arm(delay=DELAY_S, revealed=False) 

589 return self 

590 

591 def _paint(self) -> None: 

592 snap = self._task.snapshot() 

593 self._head.markdown( 

594 head_html(snap, last=self._last_duration), unsafe_allow_html=True 

595 ) 

596 body = steps_html(snap) if self._step_list and snap.steps else detail_html(snap) 

597 if body: 

598 self._detail.markdown(body, unsafe_allow_html=True) 

599 else: 

600 self._detail.empty() 

601 self._bar.markdown(bar_html(snap), unsafe_allow_html=True) 

602 

603 def _show(self) -> None: 

604 if self._skeleton_ph is not None: 

605 self._skeleton_ph.markdown(self._skeleton, unsafe_allow_html=True) 

606 self._paint() 

607 self._reveal.markdown( 

608 f'<span class="{self._reveal_class}"></span>', unsafe_allow_html=True 

609 ) 

610 self._revealed_at = time.monotonic() 

611 self._revealed = True 

612 

613 def _show_by_timer(self) -> None: 

614 self._show() 

615 self._revealed_by_timer = True 

616 

617 def _has_worked(self) -> bool: 

618 return self._task is not None and self._task.worked 

619 

620 def _arm(self, *, delay: float, revealed: bool) -> None: 

621 ticker = _Ticker( 

622 delay=delay, 

623 interval=REFRESH_S, 

624 on_reveal=self._paint if revealed else self._show_by_timer, 

625 on_tick=self._paint, 

626 ready=self._has_worked if self._reveal_on_work and not revealed else None, 

627 ) 

628 add_script_run_ctx(ticker) 

629 ticker.start() 

630 self._ticker = ticker 

631 

632 def _halt(self) -> None: 

633 if self._ticker is not None: 

634 self._ticker.halt() 

635 self._ticker.join(timeout=1.0) 

636 self._ticker = None 

637 

638 def step(self, index: int, label: str | None = None) -> None: 

639 if self._task is None: 

640 return 

641 self._task.step_to(index, label) 

642 # With no timer running (DELAY_S == 0, as the headless tests use), 

643 # nothing else would repaint a shown card. `is_open` also guards a 

644 # card whose slot is already cleared: `_ticker` goes back to None 

645 # once halted, but `_revealed` stays True forever. 

646 if self.is_open and self._revealed and self._ticker is None: 

647 self._paint() 

648 

649 def finish(self) -> None: 

650 """Every step done — and the "last load" time recorded, for a real load. 

651 

652 Only a task that did real work (`progress.Task.worked` — something 

653 reported) *and* took at least `DELAY_S` records its duration: the 

654 dataset card opens on every run, and a plain rerun's 0.04 s would 

655 otherwise overwrite the real load's time, so a slow load read "last 

656 load 0.0 s" — nor is a plain rerun that happened to be slow, all cache 

657 checks on a big corpus, a load. A task that is already finished — 

658 retired by `Page.release` when something else interrupted the load, an 

659 inline download say — records nothing either. 

660 """ 

661 task = self._task 

662 if task is None or task.finished: 

663 return 

664 waited = time.monotonic() - task.started 

665 record = task.worked and waited >= DELAY_S 

666 task.finish(duration_key=self._duration_key if record else None) 

667 

668 def _retire(self) -> None: 

669 """Finish this card's task with no duration key, so a later run's 

670 `progress.begin` for the same key never joins it. 

671 

672 Called whenever this card's run ends in a way that will not itself 

673 resume the task (a normal close, or an ordinary exception) — never 

674 when `close()` hits `StopException`/`RerunException`, the one case 

675 `progress.begin`'s joining exists for (see `Card.close`). 

676 """ 

677 if self._task is not None: 

678 self._task.finish() 

679 

680 def close(self, *, keep: bool = False) -> None: 

681 """Stop the timer and take the card down — or, with ``keep``, leave it 

682 showing every step done (the page card, until its page is released). 

683 

684 A stopped or superseded run can't clear its own slot from the script 

685 thread — Streamlit raises `StopException`/`RerunException` there 

686 instead of sending the message (see `_clear_off_thread`) — so that's 

687 caught here and retried off-thread before the exception is re-raised, 

688 unless `_KEEP_ON_STOP` (tests only) says to leave the card frozen. A 

689 slot that clears normally retires this card's task; one Streamlit 

690 aborted stays joinable, since a rerun that interrupted a load is 

691 exactly the case joining exists for. 

692 """ 

693 if not self.is_open: 

694 return 

695 self._halt() 

696 try: 

697 if keep: 

698 if self._revealed: 

699 self._paint() 

700 else: 

701 try: 

702 self._slot.empty() 

703 except (StopException, RerunException): 

704 if not _KEEP_ON_STOP: 

705 _clear_off_thread(self._slot) 

706 raise 

707 else: 

708 self._retire() 

709 finally: 

710 if self._token is not None: 

711 progress.deactivate(self._token) 

712 self._token = None 

713 self.is_open = False 

714 

715 

716class Page: 

717 """The page skeleton and its dataset card, in the view's first slot.""" 

718 

719 def __init__(self, slot, *, view: str, plot_height: int = 480): 

720 self._slot = slot 

721 self._view = view 

722 self._plot_height = plot_height 

723 self.card: Card | None = None 

724 self._released = False 

725 _run().page = self 

726 

727 def open_card( 

728 self, 

729 *, 

730 title: str, 

731 steps: Sequence[str] = (), 

732 cancel: Cancel | None = None, 

733 task_key: Hashable | None = None, 

734 duration_key: Hashable | None = None, 

735 reveal_now: bool = False, 

736 reveal_on_work: bool = False, 

737 ) -> Card: 

738 self.card = Card( 

739 self._slot, 

740 key=PAGE_CARD_KEY, 

741 title=title, 

742 steps=steps, 

743 step_list=bool(steps), 

744 cancel=cancel, 

745 skeleton=skeleton_html(self._view, plot_height=self._plot_height), 

746 task_key=task_key, 

747 duration_key=duration_key, 

748 reveal_class="sps-reveal sps-reveal-page", 

749 reveal_on_work=reveal_on_work, 

750 ) 

751 return self.card.open(reveal_now=reveal_now) 

752 

753 def release(self) -> bool: 

754 """Take the skeleton down; say whether its wait was one the user saw. 

755 

756 A region card opening next shows at once only then (`card`), so a long 

757 wait reads as one continuous state — but a page revealed at once for a 

758 quick view switch (`Card.was_seen`) must not make the view's first card 

759 flash for a frame. 

760 

761 Closes the card *before* reading it: a concurrently-ticking card could 

762 still be mid-`_show()` on its timer thread, and closing joins that 

763 thread first, so the read afterwards can't race a reveal that was 

764 already underway. 

765 

766 Once its own slot has cleared, an unfinished task of the card's is 

767 retired (finished, no duration): something released the page in the 

768 middle of the load — an inline download, say — and the next run must 

769 start that load's card afresh, not join a task stopped mid-step. A clear 

770 Streamlit aborts (a stopped or superseded run) raises before that point, 

771 so an abandoned run's task stays joinable. 

772 """ 

773 if self._released: 

774 return False 

775 self._released = True 

776 card = self.card 

777 if card is not None and card.is_open: 

778 card.close(keep=True) 

779 seen = bool(card is not None and card.was_seen) 

780 self._slot.empty() 

781 if card is not None and card.task is not None and not card.task.finished: 

782 card.task.finish() 

783 state = _RUN.get() 

784 if state is not None and state.page is self: 

785 state.page = None 

786 return seen 

787 

788 

789def page(slot, *, view: str, plot_height: int = 480) -> Page: 

790 """Reserve ``slot`` as this run's page (see :class:`Page`).""" 

791 return Page(slot, view=view, plot_height=plot_height) 

792 

793 

794def release_page() -> bool: 

795 """Release this run's page, if any; say whether its wait was one the user 

796 saw (`Page.release`).""" 

797 state = _RUN.get() 

798 current = state.page if state is not None else None 

799 return current.release() if current is not None else False 

800 

801 

802@contextlib.contextmanager 

803def card( 

804 slot, 

805 *, 

806 key: str, 

807 title: str, 

808 steps: Sequence[str] = (), 

809 step_list: bool = False, 

810 cancel: Cancel | None = None, 

811 size: tuple[int, int] | None = None, 

812 task_key: Hashable | None = None, 

813 duration_key: Hashable | None = None, 

814 reveal_on_work: bool = False, 

815) -> Iterator[Card]: 

816 """A region card for one ``with`` block. 

817 

818 Opening one releases the page skeleton: the view has drawn its controls by 

819 the time it reaches its first slow region. When the skeleton stood for a 

820 wait the user saw (`Page.release`), the wait carries on (`Card.open`'s 

821 ``inherit``), so it reads as one continuous state: to the first **ungated** 

822 region card of the run, which shows at once and uses it up. A gated card 

823 (``reveal_on_work``, see `Card`) on the way shows through its gate with no 

824 delay — the moment it reports work, never on a cache hit — and passes the 

825 wait on: Compare's B card, cached, must not flash, nor leave the figure's 

826 card after it to wait its own delay with the previous dataset's figure 

827 unveiled. Only this function's own release starts the carry; one 

828 `release_page()` made elsewhere (an inline download, a view with nothing to 

829 draw) hands nothing on. 

830 

831 An ordinary exception from the block retires the card's task (see 

832 `Card._retire`) before re-raising — a card with an explicit `task_key` 

833 that failed must not look, to a later run, like one still safely loading. 

834 `open()` is inside the ``try`` too, so `close()` still runs (a no-op, 

835 since it never got to `is_open = True`) if opening itself raises. 

836 """ 

837 state = _run() 

838 if release_page(): 

839 state.inherit_seen = True 

840 inherit = state.inherit_seen 

841 if inherit and not reveal_on_work: 

842 state.inherit_seen = False # the first ungated card carries it on 

843 region = Card( 

844 slot, 

845 key=key, 

846 title=title, 

847 steps=steps, 

848 step_list=step_list, 

849 cancel=cancel, 

850 size=size, 

851 task_key=task_key, 

852 duration_key=duration_key, 

853 reveal_on_work=reveal_on_work, 

854 ) 

855 try: 

856 region.open(inherit=inherit) 

857 yield region 

858 region.finish() 

859 except Exception: 

860 region._retire() 

861 raise 

862 finally: 

863 region.close() 

864 

865 

866@contextlib.contextmanager 

867def run_scope() -> Iterator[None]: 

868 """Wrap one script run: fresh state in, every timer stopped out. 

869 

870 A run whose work was cancelled ends as a **stopped** one: the `Cancelled` 

871 is re-raised as Streamlit's own `StopException`, which Streamlit treats as 

872 a premature stop and so skips its stale-widget sweep — the state of every 

873 widget the run never reached is kept, where a normal finish would drop it. 

874 Today only an abandoned run ever computes a cancelled task 

875 (`progress.begin` never joins one), so its page is gone and Streamlit drops 

876 whatever it sends either way; the stop is what keeps a cut-short run from 

877 ending as a successful one, should a cancel ever reach a run on screen. 

878 

879 A card or the page left open when the run ends — interrupted, or simply 

880 never closed — is cleared off-thread here too, the same 

881 `StopException`/`RerunException` problem `Card.close()` guards against, 

882 for whatever this run didn't get to close itself. `_KEEP_ON_STOP` (tests 

883 only) skips it, same as in `close()`. 

884 

885 A run that ends with an ordinary exception also retires the tasks of the 

886 cards it left open (see `Card._retire`) before the exception carries on: 

887 nothing will resume them, and a later run must not join a load that 

888 failed. A stopped or superseded run (`StopException`/`RerunException`, 

889 which are not `Exception`s) leaves them joinable, as `Card.close` does. 

890 """ 

891 token = _RUN.set(_RunState()) 

892 failed = False 

893 try: 

894 with progress.scope(): 

895 yield 

896 except progress.Cancelled: 

897 raise StopException() from None 

898 except Exception: 

899 failed = True 

900 raise 

901 finally: 

902 state = _RUN.get() 

903 if state is not None: 

904 for opened in list(state.cards): 

905 opened._halt() 

906 if opened.is_open and failed: 

907 opened._retire() 

908 if opened.is_open and not _KEEP_ON_STOP: 

909 _clear_off_thread(opened._slot) 

910 run_page = state.page 

911 if run_page is not None and not run_page._released and not _KEEP_ON_STOP: 

912 _clear_off_thread(run_page._slot) 

913 _RUN.reset(token) 

914 

915 

916def covered() -> bool: 

917 """Is a card open in this run?""" 

918 state = _RUN.get() 

919 return bool(state is not None and any(c.is_open for c in state.cards)) 

920 

921 

922def spinner(text: str) -> contextlib.AbstractContextManager[Any]: 

923 """``st.spinner`` with the elapsed time — silent while a card covers it.""" 

924 if covered(): 

925 return contextlib.nullcontext() 

926 return st.spinner(text, show_time=True)