Coverage for scanpath_studio/progress.py: 100%

159 statements  

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

1"""UX-165: a Streamlit-free progress hook for slow work. 

2 

3Loaders and figure builders call :func:`report` from inside their loops, and 

4the orchestrator calls :func:`step_to` between stages. With no task active both 

5return at once, so the headless API and the CLI see no change. With one active 

6— ``loading.card`` opens it — they update a thread-safe :class:`Task` that the 

7card's timer thread reads. 

8 

9The two calls are also the **cancel checkpoint**: after :func:`cancel`, the next 

10call in the computing thread raises :class:`Cancelled`, so abandoned work stops 

11within one file, frame or chunk instead of running to the end of its step. 

12 

13A :func:`report` also says that **real work** is happening (:attr:`Task.worked`), 

14which is what a card over work that is cheap on a cache hit waits for before it 

15shows (``loading.Card``'s ``reveal_on_work``). So a build such a card covers 

16that has no loop to report from calls a bare ``report()`` first thing: it only 

17runs on a miss. 

18 

19And they are where slow work **lets the server talk**. A CPU-bound build on the 

20script thread holds Python's GIL, and the server's event loop needs several 

21handoffs of it to send one message, each waiting out the interpreter's switch 

22interval — so everything a run queues, a card's own reveal included, used to 

23reach the browser only once the build ended. Inside a task, a call therefore 

24sleeps for ``YIELD_S`` whenever ``YIELD_EVERY_S`` has passed since the task 

25began or last did — so a task quicker than that never sleeps: a sleep releases 

26the GIL outright, and the loop drains its queue uncontended. 

27 

28Nothing here creates a Streamlit element, deliberately. The replay's frame loop 

29runs inside two nested ``st.cache_data`` functions, and Streamlit replays every 

30element created inside a cached function on each later hit — a progress bar 

31drawn from inside the loop would reappear on every rerun. 

32""" 

33 

34from __future__ import annotations 

35 

36import contextvars 

37import threading 

38import time 

39import weakref 

40from collections.abc import Hashable, Iterator, Sequence 

41from contextlib import contextmanager 

42from dataclasses import dataclass 

43 

44#: How often a task's checkpoints hand the GIL over, and for how long — at most 

45#: YIELD_S / YIELD_EVERY_S (4%) of the work's time, the first time one interval 

46#: into the task. Measured on the cold first replay build: the rail reached the 

47#: browser at 0.5 s instead of 3.2 s, and the card showed on time instead of not 

48#: at all. 

49YIELD_EVERY_S = 0.025 

50YIELD_S = 0.001 

51 

52# The yield's clock and sleep: module attributes, so a test can replace them 

53# without patching the `time` module every thread shares. 

54_clock = time.monotonic 

55_sleep = time.sleep 

56 

57 

58class Cancelled(BaseException): 

59 """Raised at a checkpoint once the running task has been cancelled. 

60 

61 A ``BaseException``, like Streamlit's own ``StopException``, so the broad 

62 ``except Exception`` handlers in the load path (a mapping that fails, a 

63 corpus that will not read) cannot mistake a cancel for a data error. 

64 """ 

65 

66 

67@dataclass(frozen=True) 

68class Snapshot: 

69 """One consistent reading of a :class:`Task`, for a card to draw.""" 

70 

71 title: str 

72 steps: tuple[str, ...] 

73 step_seconds: tuple[float | None, ...] 

74 current: int 

75 done: int | None 

76 total: int | None 

77 unit: str 

78 detail: str | None 

79 elapsed: float 

80 finished: bool 

81 

82 

83class Task: 

84 """What one slow job has done so far. 

85 

86 Written by the computing thread and read by a card's timer thread, so every 

87 field sits behind one lock and readers take a :class:`Snapshot`. 

88 """ 

89 

90 def __init__(self, key: Hashable, *, title: str, steps: Sequence[str] = ()): 

91 self.key = key 

92 self._lock = threading.Lock() 

93 self._title = title 

94 self._steps = list(steps) 

95 self._seconds: list[float | None] = [None] * len(self._steps) 

96 self._current = 0 

97 self._done: int | None = None 

98 self._total: int | None = None 

99 self._unit = "" 

100 self._detail: str | None = None 

101 self.started = time.monotonic() 

102 self._step_started = self.started 

103 self._finished = False 

104 self._worked = False 

105 self._cancelled = threading.Event() 

106 # The first yield comes YIELD_EVERY_S into the task, not at its first 

107 # checkpoint: a plain rerun's page card makes one, and a quick run must 

108 # not pay a sleep for it. Read from `_clock`, the clock `_checkpoint` 

109 # compares against (and the one a test replaces). 

110 self._last_yield = _clock() 

111 

112 @property 

113 def cancelled(self) -> bool: 

114 return self._cancelled.is_set() 

115 

116 @property 

117 def finished(self) -> bool: 

118 with self._lock: 

119 return self._finished 

120 

121 @property 

122 def worked(self) -> bool: 

123 """Has anything reported to this task — real work, i.e. a cache miss? 

124 

125 Set by :meth:`report`, never by :meth:`step_to`: the orchestrator moves 

126 between steps on a cache hit just the same, while only a build that is 

127 actually running reports (a bare ``report()`` is enough). A gated card 

128 (``loading.Card``'s ``reveal_on_work``) waits for this. 

129 """ 

130 with self._lock: 

131 return self._worked 

132 

133 def cancel(self) -> None: 

134 self._cancelled.set() 

135 

136 def _checkpoint(self) -> None: 

137 """Stop a cancelled task here; otherwise let the server send (see the 

138 module docstring) at most every ``YIELD_EVERY_S``.""" 

139 if self._cancelled.is_set(): 

140 raise Cancelled(self.key) 

141 # The slot is claimed under the lock, before the sleep, so two threads 

142 # reporting to one joined task take one yield per window between them; 

143 # the sleep is outside it, since the card's timer reads snapshots under 

144 # this lock. 

145 with self._lock: 

146 due = _clock() - self._last_yield >= YIELD_EVERY_S 

147 if due: 

148 self._last_yield = _clock() 

149 if due: 

150 _sleep(YIELD_S) 

151 

152 def report( 

153 self, 

154 done: int | None = None, 

155 total: int | None = None, 

156 *, 

157 unit: str = "", 

158 detail: str | None = None, 

159 ) -> None: 

160 """Record progress — and that work is happening (:attr:`worked`). 

161 

162 A bare ``report()`` (no count, unit or detail) only does the latter, 

163 leaving the count as it is: a run that waits on a build another run is 

164 counting into the same, joined task must not wipe that count. 

165 """ 

166 self._checkpoint() 

167 with self._lock: 

168 self._worked = True 

169 if done is None and total is None and not unit and detail is None: 

170 return 

171 self._done = done 

172 self._total = total 

173 if unit: 

174 self._unit = unit 

175 if detail is not None: 

176 self._detail = detail 

177 

178 def step_to(self, index: int, label: str | None = None) -> None: 

179 """Start step ``index``, finishing the ones before it. 

180 

181 Never moves back: a run that joined a task another run was driving may 

182 ask for a step the other already reached. 

183 """ 

184 self._checkpoint() 

185 now = time.monotonic() 

186 with self._lock: 

187 if label and 0 <= index < len(self._steps): 

188 self._steps[index] = label 

189 if index <= self._current: 

190 return 

191 for i in range(self._current, min(index, len(self._steps))): 

192 if self._seconds[i] is None: 

193 self._seconds[i] = ( 

194 now - self._step_started if i == self._current else 0.0 

195 ) 

196 self._current = index 

197 self._step_started = now 

198 self._done = self._total = None 

199 self._unit = "" 

200 self._detail = None 

201 

202 def finish(self, *, duration_key: Hashable | None = None) -> float: 

203 """Mark every step done; remember the duration under ``duration_key``.""" 

204 now = time.monotonic() 

205 with self._lock: 

206 for i in range(len(self._steps)): 

207 if self._seconds[i] is None: 

208 self._seconds[i] = ( 

209 now - self._step_started if i == self._current else 0.0 

210 ) 

211 self._current = len(self._steps) 

212 self._finished = True 

213 seconds = now - self.started 

214 if duration_key is not None: 

215 with _REGISTRY_LOCK: 

216 _DURATIONS[duration_key] = seconds 

217 return seconds 

218 

219 def snapshot(self) -> Snapshot: 

220 with self._lock: 

221 return Snapshot( 

222 title=self._title, 

223 steps=tuple(self._steps), 

224 step_seconds=tuple(self._seconds), 

225 current=self._current, 

226 done=self._done, 

227 total=self._total, 

228 unit=self._unit, 

229 detail=self._detail, 

230 elapsed=time.monotonic() - self.started, 

231 finished=self._finished, 

232 ) 

233 

234 

235#: Every task key carries the session id, so a plain dict here kept every task of 

236#: every session for the life of the server. Weak: a task stays registered 

237#: exactly while something holds it — its card during the run, and a superseded 

238#: run's thread while that is still computing it, the case joining exists for. 

239_REGISTRY: weakref.WeakValueDictionary[Hashable, Task] = weakref.WeakValueDictionary() 

240#: One float per duration key (a dataset name), so this one stays a plain dict. 

241_DURATIONS: dict[Hashable, float] = {} 

242_REGISTRY_LOCK = threading.Lock() 

243_ACTIVE: contextvars.ContextVar[Task | None] = contextvars.ContextVar( 

244 "scanpath_progress_task", default=None 

245) 

246 

247 

248def begin( 

249 key: Hashable, *, title: str, steps: Sequence[str] = (), fresh: bool = False 

250) -> Task: 

251 """The task for ``key`` — the one already running, or a new one. 

252 

253 Joining is what lets a rerun that interrupted a load keep showing that 

254 load's counts instead of starting from zero — while the interrupted run's 

255 thread still holds the task (`_REGISTRY` is weak). A finished or cancelled 

256 task is never joined: its record is replaced. ``fresh=True`` replaces any 

257 existing record unconditionally, even one still mid-flight — for a caller 

258 with no stable identity to join across runs in the first place (e.g. a 

259 region card opened with no explicit task key). 

260 """ 

261 with _REGISTRY_LOCK: 

262 task = None if fresh else _REGISTRY.get(key) 

263 if task is None or task.cancelled or task.finished: 

264 task = Task(key, title=title, steps=steps) 

265 _REGISTRY[key] = task 

266 return task 

267 

268 

269def activate(task: Task) -> contextvars.Token: 

270 return _ACTIVE.set(task) 

271 

272 

273def deactivate(token: contextvars.Token) -> None: 

274 _ACTIVE.reset(token) 

275 

276 

277def active() -> Task | None: 

278 return _ACTIVE.get() 

279 

280 

281@contextmanager 

282def task(key: Hashable, *, title: str, steps: Sequence[str] = ()) -> Iterator[Task]: 

283 """:func:`begin` + :func:`activate` for one block.""" 

284 current = begin(key, title=title, steps=steps) 

285 token = activate(current) 

286 try: 

287 yield current 

288 finally: 

289 deactivate(token) 

290 

291 

292@contextmanager 

293def scope() -> Iterator[None]: 

294 """No active task inside, whatever an earlier run in this thread left.""" 

295 token = _ACTIVE.set(None) 

296 try: 

297 yield 

298 finally: 

299 _ACTIVE.reset(token) 

300 

301 

302def report( 

303 done: int | None = None, 

304 total: int | None = None, 

305 *, 

306 unit: str = "", 

307 detail: str | None = None, 

308) -> None: 

309 """Record progress on the active task — a no-op without one. 

310 

311 Any call, a bare ``report()`` included, marks the task as having worked; 

312 a bare one leaves its count alone (see :meth:`Task.report`). 

313 """ 

314 current = _ACTIVE.get() 

315 if current is not None: 

316 current.report(done, total, unit=unit, detail=detail) 

317 

318 

319def step_to(index: int, label: str | None = None) -> None: 

320 """Move the active task to step ``index`` — a no-op without one.""" 

321 current = _ACTIVE.get() 

322 if current is not None: 

323 current.step_to(index, label) 

324 

325 

326def running(key: Hashable) -> bool: 

327 """Is a task running under ``key`` — registered, neither finished nor 

328 cancelled? 

329 

330 The registry is weak (`_REGISTRY`), so a task is in it only while something 

331 holds it: its card during its run, or a superseded run's thread still 

332 computing it. A key some run left behind — one that ended by ``st.stop()``, 

333 an exception or a rerun in the middle of its work — names no task once 

334 nothing holds that task any more, and so no work in flight. 

335 """ 

336 with _REGISTRY_LOCK: 

337 current = _REGISTRY.get(key) 

338 return current is not None and not current.finished and not current.cancelled 

339 

340 

341def cancel(key: Hashable) -> None: 

342 """Cancel ``key``'s task: its computing thread stops at its next checkpoint.""" 

343 with _REGISTRY_LOCK: 

344 current = _REGISTRY.get(key) 

345 if current is not None: 

346 current.cancel() 

347 

348 

349def last_duration(key: Hashable) -> float | None: 

350 with _REGISTRY_LOCK: 

351 return _DURATIONS.get(key)