coverage-flashlight 1.0.0__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.
- coverage_flashlight/__init__.py +1 -0
- coverage_flashlight/__main__.py +3 -0
- coverage_flashlight/bootstrap/sitecustomize.py +20 -0
- coverage_flashlight/cli.py +107 -0
- coverage_flashlight/config.py +98 -0
- coverage_flashlight/coverage.py +82 -0
- coverage_flashlight/coverage_flashlight.html +158 -0
- coverage_flashlight/execution_flashlight.html +256 -0
- coverage_flashlight/flashlight.css +52 -0
- coverage_flashlight/pytest_plugin.py +28 -0
- coverage_flashlight/runner.py +226 -0
- coverage_flashlight/trace.py +353 -0
- coverage_flashlight-1.0.0.dist-info/METADATA +150 -0
- coverage_flashlight-1.0.0.dist-info/RECORD +17 -0
- coverage_flashlight-1.0.0.dist-info/WHEEL +4 -0
- coverage_flashlight-1.0.0.dist-info/entry_points.txt +2 -0
- coverage_flashlight-1.0.0.dist-info/licenses/LICENSE +23 -0
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<meta charset="utf-8">
|
|
4
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
5
|
+
<title>Execution flashlight</title>
|
|
6
|
+
<style>
|
|
7
|
+
__FLASHLIGHT_STYLE__
|
|
8
|
+
#capture { margin:0; font-size:11px; color:var(--muted); }
|
|
9
|
+
#scenario { flex:1; }
|
|
10
|
+
.scenario-control { flex:1; }
|
|
11
|
+
.playback-controls { flex-wrap:nowrap; overflow-x:auto; padding-bottom:2px; }
|
|
12
|
+
.playback-controls > * { flex-shrink:0; }
|
|
13
|
+
#lane { max-width:350px; }
|
|
14
|
+
#play { min-width:58px; color:var(--accent); }
|
|
15
|
+
.timeline { display:flex; align-items:center; gap:10px; margin-top:3px; }
|
|
16
|
+
input[type=range] { flex:1; min-width:0; margin:0; }
|
|
17
|
+
#position { min-width:130px; font-size:11px; font-variant-numeric:tabular-nums; text-align:right; }
|
|
18
|
+
#status { margin:0; color:var(--muted); font-size:11px; }
|
|
19
|
+
#warning { margin:3px 0; color:var(--beam); font-size:11px; white-space:pre-line; max-height:48px; overflow:auto; }
|
|
20
|
+
#warning:empty { display:none; }
|
|
21
|
+
main { grid-template-columns:minmax(280px,32%) minmax(0,1fr); }
|
|
22
|
+
.atlas-panel { display:flex; flex-direction:column; gap:6px; min-height:0; min-width:0; padding:8px 10px; border-right:1px solid var(--border); overflow:hidden; }
|
|
23
|
+
.section-head { justify-content:space-between; }
|
|
24
|
+
.legend { font-size:11px; color:var(--muted); }
|
|
25
|
+
.legend i { display:inline-block; width:8px; height:8px; margin:0 4px 0 8px; background:var(--visited); }
|
|
26
|
+
.legend i:last-of-type { background:var(--beam); }
|
|
27
|
+
#atlas { display:grid; grid-template-columns:repeat(auto-fit,minmax(125px,1fr)); align-content:start; gap:7px; flex:1; min-height:0; overflow:auto; padding:2px; overscroll-behavior:contain; }
|
|
28
|
+
.room { text-align:left; padding:7px; background:var(--panel); min-width:0; }
|
|
29
|
+
.room.active { border-color:var(--beam); box-shadow:inset 0 0 0 1px var(--beam); }
|
|
30
|
+
.room.selected { outline:1px solid var(--muted); outline-offset:1px; }
|
|
31
|
+
.room-name { display:block; overflow-wrap:anywhere; font:11px ui-monospace,Consolas,monospace; }
|
|
32
|
+
.room canvas { width:100%; height:40px; display:block; margin:6px 0; image-rendering:pixelated; }
|
|
33
|
+
.room small { font-size:10px; }
|
|
34
|
+
.source-panel .controls { margin:0; font-size:12px; }
|
|
35
|
+
#event { margin:0; color:var(--muted); font:11px/1.4 ui-monospace,Consolas,monospace; overflow-wrap:anywhere; max-height:44px; overflow:auto; }
|
|
36
|
+
.line .number, .source-gap .number { position:sticky; left:0; flex:0 0 68px; width:68px; padding:0 8px; text-align:right; color:var(--muted); background:var(--panel); user-select:none; }
|
|
37
|
+
.line.fog code { color:var(--muted); }
|
|
38
|
+
.line.seen { background:var(--hit); }
|
|
39
|
+
.line.beam { background:var(--partial); box-shadow:inset 3px 0 var(--beam); color:var(--fg); }
|
|
40
|
+
.source-gap { display:flex; color:var(--muted); background:var(--elsewhere); font:11px/1.8 ui-monospace,Consolas,monospace; }
|
|
41
|
+
.source-gap .hint { padding:0 12px; }
|
|
42
|
+
@media(max-width:850px) {
|
|
43
|
+
.atlas-panel { padding:6px 8px; border-right:0; border-bottom:1px solid var(--border); }
|
|
44
|
+
#atlas { grid-auto-flow:column; grid-auto-columns:140px; grid-template-columns:none; grid-template-rows:minmax(0,1fr); }
|
|
45
|
+
.room { overflow:auto; } .room canvas { height:24px; margin:3px 0; }
|
|
46
|
+
#lane { max-width:240px; }
|
|
47
|
+
}
|
|
48
|
+
</style>
|
|
49
|
+
<header>
|
|
50
|
+
<div class="heading"><h1>Execution flashlight</h1><p id="capture"></p></div>
|
|
51
|
+
<div class="controls">
|
|
52
|
+
<label for="scenario" class="scenario-control">Run <select id="scenario"></select></label>
|
|
53
|
+
<a id="coverage-link" hidden>Static coverage report ↗</a>
|
|
54
|
+
</div>
|
|
55
|
+
<p id="warning" role="status"></p>
|
|
56
|
+
<div class="controls playback-controls">
|
|
57
|
+
<button id="play" type="button">Play</button>
|
|
58
|
+
<button id="back" type="button" aria-label="Previous event">← Step</button>
|
|
59
|
+
<button id="forward" type="button" aria-label="Next event">Step →</button>
|
|
60
|
+
<button id="next-new" type="button">Next new line</button>
|
|
61
|
+
<button id="test-body" type="button">Test body</button>
|
|
62
|
+
<button id="whole" type="button">Whole run</button>
|
|
63
|
+
<label for="speed">Events/sec <select id="speed"><option>30</option><option selected>300</option><option>3000</option><option>30000</option></select></label>
|
|
64
|
+
<label for="lane">Process / thread <select id="lane"></select></label>
|
|
65
|
+
</div>
|
|
66
|
+
<div class="timeline"><input id="timeline" type="range" min="0" max="0" value="0" aria-label="Execution timeline"><output id="position">Before execution</output></div>
|
|
67
|
+
<p id="status" aria-live="polite"></p>
|
|
68
|
+
</header>
|
|
69
|
+
<main>
|
|
70
|
+
<section class="atlas-panel" aria-label="Module exploration map">
|
|
71
|
+
<div class="section-head"><h2>Module map</h2><span class="legend"><i></i>Visited <i></i>Flashlight</span></div>
|
|
72
|
+
<div id="atlas"></div>
|
|
73
|
+
</section>
|
|
74
|
+
<section class="source-panel" aria-label="Execution source">
|
|
75
|
+
<div class="section-head"><h2 id="filename"></h2></div>
|
|
76
|
+
<div class="controls"><label><input id="follow" type="checkbox" checked>Follow execution</label><label><input id="reveal" type="checkbox">Reveal source context</label></div>
|
|
77
|
+
<p id="event">Scrub or step to inspect an event.</p>
|
|
78
|
+
<div id="code"></div>
|
|
79
|
+
</section>
|
|
80
|
+
</main>
|
|
81
|
+
<footer>
|
|
82
|
+
<p id="generated"></p>
|
|
83
|
+
<details><summary>About playback</summary><div class="help-content">
|
|
84
|
+
<p>Fog means “no line event recorded yet at this position.” It is not proof that code is untested. Whole run shows the static union of recorded line events; the separate coverage report still measures executable statements and branch alternatives.</p>
|
|
85
|
+
<p>Playback uses event order, not real-time speed. The all-process view merges observations by the machine's monotonic clock. It does not establish causality between threads or processes. Select a thread to follow its own sequence. Call, return and exception markers are Python trace events; generator suspension also produces return/call markers.</p>
|
|
86
|
+
<p>Recording covers the configured project Python source, including newly launched Python subprocesses. Native code, generated code with synthetic filenames, pre-existing processes, and threads started outside Python's threading module are outside this capture. Source is snapshotted when recorded; variables and return values are not stored.</p>
|
|
87
|
+
<p>Tracing changes timing. An event cap or abruptly killed process can leave an incomplete recording; those cases are flagged above. Trace runs are exploratory, and ordinary tests remain the correctness check.</p>
|
|
88
|
+
</div></details>
|
|
89
|
+
</footer>
|
|
90
|
+
<script id="trace-data" type="application/json">__TRACE_DATA__</script>
|
|
91
|
+
<script>
|
|
92
|
+
"use strict";
|
|
93
|
+
const data = JSON.parse(document.getElementById("trace-data").textContent);
|
|
94
|
+
const el = id => document.getElementById(id);
|
|
95
|
+
const kinds = ["call", "line", "return", "exception"];
|
|
96
|
+
let recording, events = [], files = [], first = new Map(), rooms = new Map(), cursor = 0;
|
|
97
|
+
let currentFile = null, renderedFile = null, sourceRows = [], sourceLayout = null, playing = false, previousTime = null, credit = 0, frame = null;
|
|
98
|
+
const shortName = name => name;
|
|
99
|
+
function option(select, value, text) { const item = document.createElement("option"); item.value = value; item.textContent = text; select.append(item); }
|
|
100
|
+
data.scenarios.forEach((test, i) => option(el("scenario"), i, `${test.returncode ? "FAIL · " : test.outcome === "skipped" ? "SKIP · " : ""}${test.name}`));
|
|
101
|
+
if (data.coverage_link) { el("coverage-link").href = data.coverage_link; el("coverage-link").hidden = false; }
|
|
102
|
+
el("generated").textContent = `Recorded ${data.created} · ${data.scenarios.length} recorded runs · source snapshots included`;
|
|
103
|
+
function pause() { playing = false; el("play").textContent = "Play"; if (frame !== null) cancelAnimationFrame(frame); frame = null; previousTime = null; credit = 0; }
|
|
104
|
+
function selectTest() {
|
|
105
|
+
pause(); recording = data.scenarios[Number(el("scenario").value)];
|
|
106
|
+
el("lane").replaceChildren(); option(el("lane"), "all", "All processes · observed order");
|
|
107
|
+
const pids = [...new Set(recording.lanes.map(lane => lane.pid))];
|
|
108
|
+
pids.forEach(pid => option(el("lane"), `p:${pid}`, `PID ${pid} · ${recording.lanes.find(lane => lane.pid === pid).role === "child" ? "child process" : "main process"}`));
|
|
109
|
+
recording.lanes.forEach((lane, index) => option(el("lane"), `t:${index}`, `PID ${lane.pid} · ${lane.name} · thread ${index + 1}`));
|
|
110
|
+
el("capture").textContent = `Run ${recording.outcome || (recording.returncode ? "failed" : "passed")} · ${recording.processes} Python process(es) · ${recording.lanes.length} thread(s) · ${recording.events.length.toLocaleString()} events`;
|
|
111
|
+
el("warning").textContent = recording.warnings.length ? `Capture notes\n${recording.warnings.join("\n")}` : "";
|
|
112
|
+
selectLane();
|
|
113
|
+
}
|
|
114
|
+
function selectLane() {
|
|
115
|
+
pause(); const lane = el("lane").value;
|
|
116
|
+
events = recording.events.filter(event => lane === "all" || (lane.startsWith("p:") ? recording.lanes[event[1]].pid === Number(lane.slice(2)) : event[1] === Number(lane.slice(2))));
|
|
117
|
+
// Include every snapshotted module, even those with no events in this lane.
|
|
118
|
+
files = Object.keys(data.sources).sort(); first = new Map(files.map(name => [name, new Map()]));
|
|
119
|
+
events.forEach((event, index) => { if (event[2] !== 1) return; const [name, line] = recording.locations[event[3]]; if (!first.get(name).has(line)) first.get(name).set(line, index + 1); });
|
|
120
|
+
currentFile = files[0];
|
|
121
|
+
renderedFile = null; buildAtlas(); el("atlas").scrollTop = 0; el("timeline").max = events.length;
|
|
122
|
+
["play", "back", "forward", "next-new", "whole", "timeline"].forEach(id => el(id).disabled = !events.length);
|
|
123
|
+
el("test-body").disabled = !recording.phases.some(phase => phase.name === "call" && phase.outcome === null);
|
|
124
|
+
seek(0);
|
|
125
|
+
}
|
|
126
|
+
function buildAtlas() {
|
|
127
|
+
el("atlas").replaceChildren(); rooms = new Map();
|
|
128
|
+
for (const name of files) {
|
|
129
|
+
const button = document.createElement("button"); button.type = "button"; button.className = "room";
|
|
130
|
+
const title = document.createElement("span"); title.className = "room-name"; title.textContent = shortName(name);
|
|
131
|
+
const canvas = document.createElement("canvas"); canvas.width = 240; canvas.height = 90; canvas.setAttribute("aria-hidden", "true");
|
|
132
|
+
const count = document.createElement("small"); button.append(title, canvas, count);
|
|
133
|
+
button.addEventListener("click", () => { pause(); currentFile = name; el("follow").checked = false; draw(); });
|
|
134
|
+
el("atlas").append(button); rooms.set(name, {button, canvas, count});
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
function drawAtlas(active) {
|
|
138
|
+
let total = 0;
|
|
139
|
+
const theme = getComputedStyle(document.documentElement);
|
|
140
|
+
const colors = ["--beam", "--visited", "--fog"].map(name => theme.getPropertyValue(name).trim());
|
|
141
|
+
for (const name of files) {
|
|
142
|
+
const room = rooms.get(name), ctx = room.canvas.getContext("2d"), size = data.sources[name].length;
|
|
143
|
+
const visited = [...first.get(name)].filter(([, index]) => index <= cursor).map(([line]) => line);
|
|
144
|
+
total += visited.length;
|
|
145
|
+
ctx.clearRect(0, 0, 240, 90);
|
|
146
|
+
// Each tile spans a contiguous source range; titles/counts are accessible text.
|
|
147
|
+
const columns = 24, rows = 9, cells = columns * rows;
|
|
148
|
+
const lit = new Set(visited.map(line => Math.min(cells - 1, Math.floor((line - 1) * cells / Math.max(size, 1)))));
|
|
149
|
+
const beam = active && active[0] === name ? Math.min(cells - 1, Math.floor((active[1] - 1) * cells / Math.max(size, 1))) : -1;
|
|
150
|
+
for (let i = 0; i < cells; i++) {
|
|
151
|
+
ctx.fillStyle = i === beam ? colors[0] : lit.has(i) ? colors[1] : colors[2];
|
|
152
|
+
ctx.fillRect((i % columns) * 10, Math.floor(i / columns) * 10, 8, 8);
|
|
153
|
+
}
|
|
154
|
+
room.button.classList.toggle("active", Boolean(active && active[0] === name));
|
|
155
|
+
room.button.classList.toggle("selected", name === currentFile);
|
|
156
|
+
room.button.setAttribute("aria-pressed", String(name === currentFile));
|
|
157
|
+
room.button.setAttribute("aria-label", `${name}: ${visited.length} lines revealed`);
|
|
158
|
+
room.count.textContent = `${visited.length} lines revealed`;
|
|
159
|
+
}
|
|
160
|
+
if (active && el("follow").checked) {
|
|
161
|
+
const room = rooms.get(active[0]).button, atlas = el("atlas");
|
|
162
|
+
const bounds = room.getBoundingClientRect(), viewport = atlas.getBoundingClientRect();
|
|
163
|
+
if (bounds.top < viewport.top || bounds.bottom > viewport.bottom) atlas.scrollTop += bounds.top - viewport.top - 10;
|
|
164
|
+
if (bounds.left < viewport.left || bounds.right > viewport.right) atlas.scrollLeft += bounds.left - viewport.left - 10;
|
|
165
|
+
}
|
|
166
|
+
el("status").textContent = `${total.toLocaleString()} distinct lines revealed · ${events.length.toLocaleString()} events in selected view`;
|
|
167
|
+
}
|
|
168
|
+
function drawSource(active) {
|
|
169
|
+
el("filename").textContent = currentFile || "No source recorded";
|
|
170
|
+
el("filename").title = currentFile || "";
|
|
171
|
+
if (renderedFile !== currentFile) {
|
|
172
|
+
sourceRows = (data.sources[currentFile] || []).map((source, index) => {
|
|
173
|
+
const row = document.createElement("div"); row.className = "line fog"; row.dataset.line = index + 1;
|
|
174
|
+
const number = document.createElement("span"); number.className = "number"; number.textContent = index + 1;
|
|
175
|
+
const code = document.createElement("code"); code.textContent = source;
|
|
176
|
+
row.append(number, code); return row;
|
|
177
|
+
});
|
|
178
|
+
renderedFile = currentFile; sourceLayout = null;
|
|
179
|
+
}
|
|
180
|
+
const seen = first.get(currentFile) || new Map(), reveal = el("reveal").checked;
|
|
181
|
+
const shown = []; let beamRow;
|
|
182
|
+
for (const row of sourceRows) {
|
|
183
|
+
const line = Number(row.dataset.line), visible = seen.has(line) && seen.get(line) <= cursor;
|
|
184
|
+
const beam = Boolean(active && active[0] === currentFile && active[1] === line);
|
|
185
|
+
row.className = `line ${visible ? "seen" : "fog"}${beam ? " beam" : ""}`;
|
|
186
|
+
shown.push(reveal || visible || beam);
|
|
187
|
+
if (beam) beamRow = row;
|
|
188
|
+
}
|
|
189
|
+
const layout = shown.join(",");
|
|
190
|
+
if (layout !== sourceLayout) {
|
|
191
|
+
const fragment = document.createDocumentFragment();
|
|
192
|
+
for (let index = 0; index < sourceRows.length;) {
|
|
193
|
+
if (shown[index]) { fragment.append(sourceRows[index++]); continue; }
|
|
194
|
+
const start = index + 1;
|
|
195
|
+
while (index < sourceRows.length && !shown[index]) index++;
|
|
196
|
+
const end = index, count = end - start + 1;
|
|
197
|
+
const gap = document.createElement("div"); gap.className = "source-gap";
|
|
198
|
+
gap.dataset.start = start; gap.dataset.end = end;
|
|
199
|
+
const range = document.createElement("span"); range.className = "number"; range.textContent = count === 1 ? start : `${start}–${end}`;
|
|
200
|
+
const hint = document.createElement("span"); hint.className = "hint"; hint.textContent = `… ${count} unrevealed ${count === 1 ? "line" : "lines"}`;
|
|
201
|
+
gap.title = "Enable Reveal source context to show these lines";
|
|
202
|
+
gap.append(range, hint); fragment.append(gap);
|
|
203
|
+
}
|
|
204
|
+
el("code").replaceChildren(fragment); sourceLayout = layout;
|
|
205
|
+
}
|
|
206
|
+
if (beamRow && el("follow").checked) {
|
|
207
|
+
const container = el("code"), top = beamRow.getBoundingClientRect().top - container.getBoundingClientRect().top + container.scrollTop;
|
|
208
|
+
if (top < container.scrollTop + 40 || top > container.scrollTop + container.clientHeight - 60) container.scrollTop = Math.max(0, top - container.clientHeight / 3);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
function draw() {
|
|
212
|
+
const event = cursor ? events[cursor - 1] : null, location = event ? recording.locations[event[3]] : null;
|
|
213
|
+
if (location && el("follow").checked) currentFile = location[0];
|
|
214
|
+
el("timeline").value = cursor;
|
|
215
|
+
el("position").textContent = cursor ? `${cursor.toLocaleString()} / ${events.length.toLocaleString()}` : "Before execution";
|
|
216
|
+
if (event) {
|
|
217
|
+
const lane = recording.lanes[event[1]];
|
|
218
|
+
el("event").textContent = `+${event[0].toFixed(3)} ms · PID ${lane.pid} / ${lane.name}\n${kinds[event[2]]} · ${location[2]} · line ${location[1]}`;
|
|
219
|
+
} else el("event").textContent = "Before the first recorded event. Play, step, or scrub to explore.";
|
|
220
|
+
drawAtlas(location); drawSource(location);
|
|
221
|
+
}
|
|
222
|
+
function seek(position) { cursor = Math.max(0, Math.min(events.length, Math.floor(position))); draw(); }
|
|
223
|
+
function tick(now) {
|
|
224
|
+
if (!playing) return;
|
|
225
|
+
if (previousTime !== null) credit += Math.min(now - previousTime, 200) * Number(el("speed").value) / 1000;
|
|
226
|
+
previousTime = now; const steps = Math.floor(credit); credit -= steps;
|
|
227
|
+
if (steps) seek(cursor + steps);
|
|
228
|
+
if (cursor >= events.length) pause(); else frame = requestAnimationFrame(tick);
|
|
229
|
+
}
|
|
230
|
+
el("scenario").addEventListener("change", selectTest);
|
|
231
|
+
el("lane").addEventListener("change", selectLane);
|
|
232
|
+
el("timeline").addEventListener("input", () => { pause(); seek(Number(el("timeline").value)); });
|
|
233
|
+
el("play").addEventListener("click", () => {
|
|
234
|
+
if (playing) { pause(); return; }
|
|
235
|
+
if (cursor >= events.length) seek(0);
|
|
236
|
+
playing = true; el("play").textContent = "Pause"; frame = requestAnimationFrame(tick);
|
|
237
|
+
});
|
|
238
|
+
el("back").addEventListener("click", () => { pause(); seek(cursor - 1); });
|
|
239
|
+
el("forward").addEventListener("click", () => { pause(); seek(cursor + 1); });
|
|
240
|
+
el("whole").addEventListener("click", () => { pause(); seek(events.length); });
|
|
241
|
+
el("test-body").addEventListener("click", () => {
|
|
242
|
+
pause(); const phase = recording.phases.find(item => item.name === "call" && item.outcome === null);
|
|
243
|
+
if (phase) { const index = events.findIndex(event => event[0] >= phase.time); seek(index < 0 ? events.length : index + 1); }
|
|
244
|
+
});
|
|
245
|
+
el("next-new").addEventListener("click", () => {
|
|
246
|
+
pause(); let target = events.length;
|
|
247
|
+
for (const lines of first.values()) for (const index of lines.values()) if (index > cursor && index < target) target = index;
|
|
248
|
+
seek(target);
|
|
249
|
+
});
|
|
250
|
+
el("follow").addEventListener("change", draw);
|
|
251
|
+
el("reveal").addEventListener("change", draw);
|
|
252
|
+
matchMedia("(prefers-color-scheme: dark)").addEventListener("change", draw);
|
|
253
|
+
document.addEventListener("visibilitychange", () => { if (document.hidden) pause(); });
|
|
254
|
+
selectTest();
|
|
255
|
+
</script>
|
|
256
|
+
</html>
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
:root {
|
|
2
|
+
color-scheme:light dark;
|
|
3
|
+
--bg:#f8fafc; --fg:#172130; --panel:#fff; --muted:#526071; --border:#d9e0e7;
|
|
4
|
+
--hit:#d9f2df; --partial:#ffedbf; --miss:#f9dadb; --elsewhere:#e7ebf0;
|
|
5
|
+
--accent:#247344; --visited:#378069; --beam:#946300; --fog:#e7ebf0;
|
|
6
|
+
}
|
|
7
|
+
@media(prefers-color-scheme:dark) {
|
|
8
|
+
:root {
|
|
9
|
+
--bg:#111820; --fg:#e0e8f2; --panel:#17212c; --muted:#a2b0c0; --border:#344151;
|
|
10
|
+
--hit:#20432f; --partial:#514121; --miss:#4e2b33; --elsewhere:#253140;
|
|
11
|
+
--accent:#83d69a; --visited:#4d9780; --beam:#e8c77a; --fog:#253140;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
* { box-sizing:border-box; }
|
|
15
|
+
html { height:100%; overflow:hidden; }
|
|
16
|
+
body { margin:0; height:100dvh; overflow:hidden; display:grid; grid-template-rows:auto minmax(0,1fr) auto; background:var(--bg); color:var(--fg); font:13px/1.4 system-ui,sans-serif; }
|
|
17
|
+
header { padding:8px 12px; border-bottom:1px solid var(--border); max-height:40dvh; overflow:auto; }
|
|
18
|
+
h1 { font-size:18px; font-weight:600; margin:0; }
|
|
19
|
+
h2 { font:600 13px ui-monospace,Consolas,monospace; overflow-wrap:anywhere; margin:0; }
|
|
20
|
+
p { margin:4px 0; }
|
|
21
|
+
.muted, small { color:var(--muted); }
|
|
22
|
+
a { color:var(--accent); }
|
|
23
|
+
.heading, .controls { display:flex; align-items:center; flex-wrap:wrap; gap:6px 10px; }
|
|
24
|
+
.controls { margin-top:6px; }
|
|
25
|
+
label { display:flex; align-items:center; gap:6px; min-width:0; max-width:100%; }
|
|
26
|
+
button, select { font:inherit; color:var(--fg); background:var(--panel); border:1px solid var(--border); border-radius:4px; padding:4px 7px; }
|
|
27
|
+
select { max-width:100%; min-width:0; }
|
|
28
|
+
button { cursor:pointer; }
|
|
29
|
+
button:hover { border-color:var(--accent); }
|
|
30
|
+
button:disabled { opacity:.4; cursor:default; }
|
|
31
|
+
button:focus-visible, select:focus-visible, input:focus-visible { outline:2px solid var(--accent); outline-offset:2px; }
|
|
32
|
+
input { accent-color:var(--accent); }
|
|
33
|
+
.metrics { font-variant-numeric:tabular-nums; }
|
|
34
|
+
main { display:grid; min-height:0; overflow:hidden; }
|
|
35
|
+
.source-panel { display:flex; flex-direction:column; gap:6px; min-width:0; min-height:0; padding:8px 10px; overflow:hidden; }
|
|
36
|
+
.source-heading, .section-head { display:flex; align-items:center; flex-wrap:wrap; gap:6px 12px; }
|
|
37
|
+
.source-heading h2, .section-head h2 { min-width:0; flex:1; overflow:hidden; white-space:nowrap; text-overflow:ellipsis; }
|
|
38
|
+
#code { flex:1; min-height:0; overflow:auto; border:1px solid var(--border); border-radius:4px; background:var(--panel); overscroll-behavior:contain; }
|
|
39
|
+
.line { display:flex; min-width:max-content; font:12px/1.65 ui-monospace,Consolas,monospace; }
|
|
40
|
+
.line code { white-space:pre; padding:0 12px; font:inherit; }
|
|
41
|
+
footer { display:flex; align-items:center; justify-content:space-between; gap:12px; min-width:0; padding:4px 12px; border-top:1px solid var(--border); color:var(--muted); font-size:11px; }
|
|
42
|
+
#generated { margin:0; overflow:hidden; white-space:nowrap; text-overflow:ellipsis; }
|
|
43
|
+
footer details { position:relative; flex-shrink:0; }
|
|
44
|
+
summary { cursor:pointer; }
|
|
45
|
+
.help-content { position:absolute; z-index:5; right:0; bottom:calc(100% + 8px); width:min(650px,calc(100vw - 24px)); max-height:55dvh; overflow:auto; padding:12px; border:1px solid var(--border); border-radius:4px; background:var(--panel); color:var(--fg); font-size:13px; box-shadow:0 4px 24px #0002; }
|
|
46
|
+
.help-content p + p { margin-top:10px; }
|
|
47
|
+
@media(max-width:850px) {
|
|
48
|
+
main { grid-template-columns:minmax(0,1fr) !important; grid-template-rows:minmax(0,20%) minmax(0,1fr); }
|
|
49
|
+
header { padding:6px 8px; }
|
|
50
|
+
.source-panel { padding:6px 8px; }
|
|
51
|
+
footer { padding:3px 8px; }
|
|
52
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Explicitly loaded only for flashlight's isolated pytest subprocesses."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@pytest.hookimpl(trylast=True)
|
|
11
|
+
def pytest_collection_modifyitems(config, items):
|
|
12
|
+
if config.getoption("numprocesses", default=0):
|
|
13
|
+
raise pytest.UsageError(
|
|
14
|
+
"flashlight isolates each case; pytest-xdist parallel execution is not supported"
|
|
15
|
+
)
|
|
16
|
+
target = os.environ.get("COVERAGE_FLASHLIGHT_TEST")
|
|
17
|
+
if target:
|
|
18
|
+
deselected = [item for item in items if item.nodeid != target]
|
|
19
|
+
items[:] = [item for item in items if item.nodeid == target]
|
|
20
|
+
config.hook.pytest_deselected(items=deselected)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def pytest_collection_finish(session):
|
|
24
|
+
output = os.environ.get("COVERAGE_FLASHLIGHT_COLLECT")
|
|
25
|
+
if output:
|
|
26
|
+
Path(output).write_text(
|
|
27
|
+
json.dumps([item.nodeid for item in session.items]), encoding="utf-8"
|
|
28
|
+
)
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
"""Execute ordinary Python or isolated pytest cases, then assemble a report."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import configparser
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import subprocess
|
|
9
|
+
import sys
|
|
10
|
+
import tempfile
|
|
11
|
+
import uuid
|
|
12
|
+
from datetime import UTC, datetime
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from coverage import Coverage
|
|
16
|
+
|
|
17
|
+
from coverage_flashlight import coverage as static
|
|
18
|
+
from coverage_flashlight import trace as execution
|
|
19
|
+
from coverage_flashlight.config import EXCLUDED_DIRS, Sources
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def child_environment() -> dict[str, str]:
|
|
23
|
+
env = {
|
|
24
|
+
key: value
|
|
25
|
+
for key, value in os.environ.items()
|
|
26
|
+
if not key.startswith(("COVERAGE_FLASHLIGHT_", "COVERAGE_"))
|
|
27
|
+
}
|
|
28
|
+
package_parent = str(Path(__file__).resolve().parent.parent)
|
|
29
|
+
env["PYTHONPATH"] = os.pathsep.join(
|
|
30
|
+
filter(None, [package_parent, env.get("PYTHONPATH")])
|
|
31
|
+
)
|
|
32
|
+
return env
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def collect(arguments: list[str], root: Path, env: dict[str, str]) -> list[str]:
|
|
36
|
+
with tempfile.TemporaryDirectory(prefix="flashlight-collect-") as directory:
|
|
37
|
+
output = Path(directory) / "cases.json"
|
|
38
|
+
result = subprocess.run(
|
|
39
|
+
[
|
|
40
|
+
sys.executable,
|
|
41
|
+
"-m",
|
|
42
|
+
"pytest",
|
|
43
|
+
"-p",
|
|
44
|
+
"coverage_flashlight.pytest_plugin",
|
|
45
|
+
"--collect-only",
|
|
46
|
+
"-q",
|
|
47
|
+
*arguments,
|
|
48
|
+
],
|
|
49
|
+
cwd=root,
|
|
50
|
+
env={**env, "COVERAGE_FLASHLIGHT_COLLECT": str(output)},
|
|
51
|
+
capture_output=True,
|
|
52
|
+
text=True,
|
|
53
|
+
check=False,
|
|
54
|
+
)
|
|
55
|
+
if result.returncode:
|
|
56
|
+
print(result.stdout, end="")
|
|
57
|
+
print(result.stderr, end="", file=sys.stderr)
|
|
58
|
+
raise subprocess.CalledProcessError(result.returncode, result.args)
|
|
59
|
+
cases = json.loads(output.read_text(encoding="utf-8"))
|
|
60
|
+
if not cases:
|
|
61
|
+
raise ValueError("No pytest cases selected")
|
|
62
|
+
return cases
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def coverage_config(path: Path, sources: Sources) -> None:
|
|
66
|
+
config = configparser.ConfigParser(interpolation=None)
|
|
67
|
+
includes = [
|
|
68
|
+
str(source / "*") if source.is_dir() else str(source)
|
|
69
|
+
for source in sources.paths
|
|
70
|
+
]
|
|
71
|
+
config["run"] = {
|
|
72
|
+
"branch": "true",
|
|
73
|
+
"core": "ctrace",
|
|
74
|
+
"parallel": "true",
|
|
75
|
+
"patch": "subprocess\n_exit",
|
|
76
|
+
"include": "\n".join(includes),
|
|
77
|
+
"omit": "\n".join(
|
|
78
|
+
[*(f"*/{name}/*" for name in EXCLUDED_DIRS), "*/.*/*", *sources.omit]
|
|
79
|
+
),
|
|
80
|
+
}
|
|
81
|
+
with path.open("w", encoding="utf-8") as stream:
|
|
82
|
+
config.write(stream)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def render_coverage(data_file: Path, output: Path, root: Path) -> None:
|
|
86
|
+
cov = Coverage(data_file=str(data_file.resolve()))
|
|
87
|
+
cov.load()
|
|
88
|
+
if not cov.get_data().has_arcs():
|
|
89
|
+
raise ValueError(
|
|
90
|
+
"Branch coverage is required; collect with coverage run --branch"
|
|
91
|
+
)
|
|
92
|
+
payload = static.build_payload(cov, root)
|
|
93
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
94
|
+
output.write_text(static.render(payload), encoding="utf-8")
|
|
95
|
+
print(f"Wrote {output} ({len(payload['scopes'])} scopes)")
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def measure(
|
|
99
|
+
arguments: list[str],
|
|
100
|
+
output: Path,
|
|
101
|
+
sources: Sources,
|
|
102
|
+
*,
|
|
103
|
+
trace: bool = False,
|
|
104
|
+
pytest: bool = False,
|
|
105
|
+
module: bool = False,
|
|
106
|
+
max_events: int = 100_000,
|
|
107
|
+
data_file: Path = Path(".coverage"),
|
|
108
|
+
) -> int:
|
|
109
|
+
env = child_environment()
|
|
110
|
+
cases = (
|
|
111
|
+
collect(arguments, sources.root, env)
|
|
112
|
+
if pytest
|
|
113
|
+
else [" ".join([*(["-m"] if module else []), *arguments])]
|
|
114
|
+
)
|
|
115
|
+
snapshot = sources.snapshot()
|
|
116
|
+
if not snapshot:
|
|
117
|
+
raise ValueError("No Python source files selected")
|
|
118
|
+
output = output.resolve()
|
|
119
|
+
archive = (
|
|
120
|
+
output.parent
|
|
121
|
+
/ "runs"
|
|
122
|
+
/ (datetime.now(UTC).strftime("%Y%m%d-%H%M%S") + "-" + uuid.uuid4().hex[:6])
|
|
123
|
+
)
|
|
124
|
+
archive.mkdir(parents=True)
|
|
125
|
+
coverage_path = archive / ".coverage"
|
|
126
|
+
config_path = archive / "coverage.ini"
|
|
127
|
+
if not trace:
|
|
128
|
+
coverage_config(config_path, sources)
|
|
129
|
+
payload = {
|
|
130
|
+
"created": datetime.now(UTC).isoformat(),
|
|
131
|
+
"sources": snapshot,
|
|
132
|
+
"scenarios": [],
|
|
133
|
+
"coverage_link": "flashlight.html"
|
|
134
|
+
if (output.parent / "flashlight.html").is_file()
|
|
135
|
+
else None,
|
|
136
|
+
}
|
|
137
|
+
outcomes = []
|
|
138
|
+
status = 0
|
|
139
|
+
for index, name in enumerate(cases):
|
|
140
|
+
run_env = env.copy()
|
|
141
|
+
if pytest:
|
|
142
|
+
target = [
|
|
143
|
+
"-m",
|
|
144
|
+
"pytest",
|
|
145
|
+
"-p",
|
|
146
|
+
"coverage_flashlight.pytest_plugin",
|
|
147
|
+
*arguments,
|
|
148
|
+
]
|
|
149
|
+
run_env["COVERAGE_FLASHLIGHT_TEST"] = name
|
|
150
|
+
else:
|
|
151
|
+
target = [*(["-m"] if module else []), *arguments]
|
|
152
|
+
if trace:
|
|
153
|
+
directory = archive / str(index)
|
|
154
|
+
run_env.update(
|
|
155
|
+
COVERAGE_FLASHLIGHT_ROOT=str(sources.root),
|
|
156
|
+
COVERAGE_FLASHLIGHT_DIR=str(directory),
|
|
157
|
+
COVERAGE_FLASHLIGHT_LIMIT=str(max_events),
|
|
158
|
+
COVERAGE_FLASHLIGHT_MODE="test" if pytest else "run",
|
|
159
|
+
COVERAGE_FLASHLIGHT_SOURCE=json.dumps(
|
|
160
|
+
[str(path) for path in sources.paths]
|
|
161
|
+
),
|
|
162
|
+
COVERAGE_FLASHLIGHT_OMIT=json.dumps(sources.omit),
|
|
163
|
+
PYTHONPATH=os.pathsep.join(
|
|
164
|
+
[str(Path(__file__).parent / "bootstrap"), env["PYTHONPATH"]]
|
|
165
|
+
),
|
|
166
|
+
)
|
|
167
|
+
if pytest:
|
|
168
|
+
target[2:2] = ["-p", "coverage_flashlight.trace"]
|
|
169
|
+
command = [sys.executable, *target]
|
|
170
|
+
else:
|
|
171
|
+
run_env["COVERAGE_FILE"] = str(coverage_path)
|
|
172
|
+
command = [
|
|
173
|
+
sys.executable,
|
|
174
|
+
"-m",
|
|
175
|
+
"coverage",
|
|
176
|
+
"run",
|
|
177
|
+
f"--rcfile={config_path}",
|
|
178
|
+
f"--context={name}",
|
|
179
|
+
*target,
|
|
180
|
+
]
|
|
181
|
+
print(
|
|
182
|
+
f"{'Recording' if trace else 'Measuring'} {index + 1}/{len(cases)}: {name}",
|
|
183
|
+
flush=True,
|
|
184
|
+
)
|
|
185
|
+
result = subprocess.run(command, cwd=sources.root, env=run_env, check=False)
|
|
186
|
+
status = status or result.returncode
|
|
187
|
+
outcomes.append({"name": name, "returncode": result.returncode})
|
|
188
|
+
if trace:
|
|
189
|
+
recording = execution.read_recording(directory, payload["sources"])
|
|
190
|
+
recording.update(
|
|
191
|
+
name=name,
|
|
192
|
+
returncode=result.returncode,
|
|
193
|
+
outcome="failed"
|
|
194
|
+
if result.returncode
|
|
195
|
+
else "skipped"
|
|
196
|
+
if any(phase["outcome"] == "skipped" for phase in recording["phases"])
|
|
197
|
+
else "passed",
|
|
198
|
+
)
|
|
199
|
+
payload["scenarios"].append(recording)
|
|
200
|
+
(archive / "manifest.json").write_text(
|
|
201
|
+
json.dumps(
|
|
202
|
+
{
|
|
203
|
+
"created": payload["created"],
|
|
204
|
+
"runs": outcomes,
|
|
205
|
+
"trace": trace,
|
|
206
|
+
"max_events": max_events,
|
|
207
|
+
},
|
|
208
|
+
indent=2,
|
|
209
|
+
),
|
|
210
|
+
encoding="utf-8",
|
|
211
|
+
)
|
|
212
|
+
if trace:
|
|
213
|
+
output.write_text(execution.render(payload), encoding="utf-8")
|
|
214
|
+
else:
|
|
215
|
+
cov = Coverage(data_file=str(coverage_path), config_file=False)
|
|
216
|
+
cov.combine(strict=False)
|
|
217
|
+
# Include selected but unexecuted files, also when --source names one file.
|
|
218
|
+
cov.get_data().add_arcs({})
|
|
219
|
+
cov.get_data().touch_files([str(sources.root / name) for name in snapshot])
|
|
220
|
+
cov.save()
|
|
221
|
+
data_file = data_file.resolve()
|
|
222
|
+
data_file.parent.mkdir(parents=True, exist_ok=True)
|
|
223
|
+
data_file.write_bytes(coverage_path.read_bytes())
|
|
224
|
+
render_coverage(data_file, output, sources.root)
|
|
225
|
+
print(f"Wrote {output}; run records: {archive}", flush=True)
|
|
226
|
+
return status
|