Coverage for scanpath_studio/export_status.py: 94%
88 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-07 21:10 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-07 21:10 +0000
1"""Shared, honest progress vocabulary for static, animation, and bulk exports."""
3from __future__ import annotations
5import hashlib
6import math
7import time
8from collections.abc import Callable
9from dataclasses import dataclass
10from enum import Enum
12EXPORTER_VERSION = "exp6-v1"
15class ExportStage(str, Enum):
16 PREPARING = "preparing"
17 STARTING_RENDERER = "starting_renderer"
18 RASTERIZING = "rasterizing"
19 ENCODING_WRITING = "encoding_writing"
20 FINALIZING = "finalizing"
21 READY = "ready"
22 ERROR = "error"
25@dataclass(frozen=True)
26class ExportStatus:
27 """One observable job state; counts appear only when real units exist."""
29 stage: ExportStage
30 message: str
31 completed: int | None = None
32 total: int | None = None
33 elapsed_s: float = 0.0
34 error: str | None = None
36 @property
37 def fraction(self) -> float | None:
38 if self.completed is None or self.total is None or self.total <= 0:
39 return None
40 return min(max(self.completed / self.total, 0.0), 1.0)
42 @property
43 def rate_per_s(self) -> float | None:
44 """Units finished per second so far, or ``None`` before there are any."""
45 if not self.completed or not self.elapsed_s:
46 return None
47 return self.completed / self.elapsed_s
49 @property
50 def remaining_s(self) -> float | None:
51 """Seconds left at the rate observed so far (PERF-6).
53 ``None`` until the first unit finishes: with nothing done there is no
54 rate, and an estimate invented from one would be a guess wearing a
55 number. Deliberately the naive rate over the *whole* run rather than a
56 recent window — a bulk export's per-trial cost is near-constant, and an
57 estimate that lurches with each slow trial reads as broken.
58 """
59 rate = self.rate_per_s
60 if rate is None or self.total is None:
61 return None
62 return max(0.0, (self.total - self.completed) / rate)
65StatusCallback = Callable[[ExportStatus], None]
68def format_duration(seconds: float | None) -> str:
69 """A duration as a person would say it: ``2h 15m``, ``1m 15s``, ``9s``."""
70 if seconds is None:
71 return ""
72 if seconds < 1:
73 return "under a second"
74 seconds = round(seconds) # round() on a float already returns an int
75 hours, rest = divmod(seconds, 3600)
76 minutes, secs = divmod(rest, 60)
77 if hours:
78 return f"{hours}h {minutes}m"
79 if minutes:
80 return f"{minutes}m {secs}s"
81 return f"{secs}s"
84def progress_caption(status: ExportStatus) -> str:
85 """One line under the bar: how far, how fast, how much longer.
87 A bulk export over a real corpus runs for hours, and "1,203 / 20,000" only
88 answers the first of those. The rate is what makes the estimate legible —
89 it lets someone sanity-check the number rather than take it on faith.
90 """
91 if status.completed is None or status.total is None:
92 return status.message
93 # The total counts export units — a multi-screen trial is several.
94 parts = [f"{status.completed:,}/{status.total:,} done"]
95 rate = status.rate_per_s
96 if rate is not None:
97 parts.append(f"{rate:.3g}/s" if rate < 10 else f"{rate:.0f}/s")
98 remaining = status.remaining_s
99 if remaining is not None:
100 parts.append(
101 "finishing…" if remaining < 1 else f"{format_duration(remaining)} left"
102 )
103 parts.append(status.message)
104 return " · ".join(parts)
107def emit_status(
108 callback: StatusCallback | None,
109 stage: ExportStage,
110 message: str,
111 *,
112 started_at: float | None = None,
113 completed: int | None = None,
114 total: int | None = None,
115 error: str | None = None,
116) -> ExportStatus:
117 """Validate and emit one status while remaining cheap when no UI listens."""
118 if (completed is None) != (total is None):
119 raise ValueError("completed and total must be supplied together")
120 if completed is not None and (completed < 0 or total is None or total < 0):
121 raise ValueError("progress counts must be non-negative")
122 if completed is not None and total is not None and completed > total:
123 raise ValueError("completed progress cannot exceed total")
124 status = ExportStatus(
125 stage=stage,
126 message=message,
127 completed=completed,
128 total=total,
129 elapsed_s=max(0.0, time.perf_counter() - started_at) if started_at else 0.0,
130 error=error,
131 )
132 if callback is not None:
133 callback(status)
134 return status
137def export_signature(
138 content: str,
139 *,
140 fmt: str,
141 width: int,
142 height: int,
143 scale: float,
144 exporter_version: str = EXPORTER_VERSION,
145) -> str:
146 """Hash every output-affecting input for safe export-byte reuse.
148 ``content`` identifies what is drawn. It used to be the figure's JSON, which
149 costs 12 s to write for a 2,000-frame replay on every rerun; the animation
150 export passes its replay's inputs instead (PERF-16, `tabs._ReplayView`).
151 """
152 if int(width) <= 0 or int(height) <= 0:
153 raise ValueError("export width and height must be positive")
154 if not math.isfinite(float(scale)) or float(scale) <= 0:
155 raise ValueError("export scale must be a positive finite number")
156 digest = hashlib.sha256()
157 for value in (
158 exporter_version,
159 str(fmt).lower(),
160 str(int(width)),
161 str(int(height)),
162 f"{float(scale):.12g}",
163 ):
164 digest.update(value.encode("utf-8"))
165 digest.update(b"\0")
166 digest.update(content.encode("utf-8"))
167 return digest.hexdigest()