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.
@@ -0,0 +1 @@
1
+ """Coverage reports and opt-in ordered Python execution recording."""
@@ -0,0 +1,3 @@
1
+ from coverage_flashlight.cli import main
2
+
3
+ raise SystemExit(main())
@@ -0,0 +1,20 @@
1
+ """Opt-in child-process bootstrap; used only by the execution flashlight runner."""
2
+
3
+ import importlib.machinery
4
+ import importlib.util
5
+ import os
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ # Preserve the environment's own startup customization.
10
+ _here = Path(__file__).parent.resolve()
11
+ _existing = importlib.machinery.PathFinder.find_spec(
12
+ "sitecustomize", [path for path in sys.path if Path(path).resolve() != _here]
13
+ )
14
+ if _existing is not None and _existing.loader is not None:
15
+ _existing.loader.exec_module(importlib.util.module_from_spec(_existing))
16
+
17
+ if os.environ.get("COVERAGE_FLASHLIGHT_DIR"):
18
+ from coverage_flashlight.trace import install
19
+
20
+ install()
@@ -0,0 +1,107 @@
1
+ """The flashlight command; target arguments are passed through after --."""
2
+
3
+ import argparse
4
+ import subprocess
5
+ from importlib.metadata import version
6
+ from pathlib import Path
7
+
8
+ from coverage.exceptions import CoverageException
9
+
10
+ from coverage_flashlight.config import project_sources
11
+ from coverage_flashlight.runner import measure, render_coverage
12
+
13
+
14
+ def main(argv=None) -> int:
15
+ parser = argparse.ArgumentParser(
16
+ prog="flashlight",
17
+ description="Explore Python coverage or replay executed lines.",
18
+ )
19
+ parser.add_argument(
20
+ "--version", action="version", version=version("coverage-flashlight")
21
+ )
22
+ commands = parser.add_subparsers(dest="command", required=True)
23
+ for name, help_text in [
24
+ ("pytest", "Measure each selected pytest case"),
25
+ ("run", "Execute a Python script or module once"),
26
+ ]:
27
+ command = commands.add_parser(name, help=help_text)
28
+ command.add_argument(
29
+ "--trace",
30
+ action="store_true",
31
+ help="record ordered execution instead of line/branch coverage",
32
+ )
33
+ command.add_argument(
34
+ "--source",
35
+ action="append",
36
+ help="project-relative source file or directory; repeat for multiple paths",
37
+ )
38
+ command.add_argument(
39
+ "--output",
40
+ type=Path,
41
+ help="HTML report path (default: htmlcov/flashlight.html or execution.html)",
42
+ )
43
+ command.add_argument(
44
+ "--data-file",
45
+ type=Path,
46
+ default=Path(".coverage"),
47
+ help="coverage database to replace (coverage mode only)",
48
+ )
49
+ command.add_argument(
50
+ "--max-events",
51
+ type=int,
52
+ default=100_000,
53
+ help="maximum trace events per process",
54
+ )
55
+ if name == "run":
56
+ command.add_argument(
57
+ "-m",
58
+ "--module",
59
+ action="store_true",
60
+ help="run the target as a Python module",
61
+ )
62
+ command.add_argument(
63
+ "arguments", nargs=argparse.REMAINDER, help="target arguments, after --"
64
+ )
65
+ report = commands.add_parser(
66
+ "report", help="Render existing branch coverage without executing code"
67
+ )
68
+ report.add_argument("--data-file", type=Path, default=Path(".coverage"))
69
+ report.add_argument("--output", type=Path, default=Path("htmlcov/flashlight.html"))
70
+ args = parser.parse_args(argv)
71
+ try:
72
+ if args.command == "report":
73
+ if not args.data_file.is_file():
74
+ parser.error(f"Coverage database does not exist: {args.data_file}")
75
+ render_coverage(args.data_file, args.output, Path.cwd())
76
+ return 0
77
+ arguments = (
78
+ args.arguments[1:] if args.arguments[:1] == ["--"] else args.arguments
79
+ )
80
+ if args.command == "run" and not arguments:
81
+ parser.error("run requires a script path or module name")
82
+ if args.max_events <= 0:
83
+ parser.error("--max-events must be positive")
84
+ sources = project_sources(Path.cwd(), trace=args.trace, override=args.source)
85
+ return measure(
86
+ arguments,
87
+ args.output
88
+ or Path(
89
+ "htmlcov/execution.html" if args.trace else "htmlcov/flashlight.html"
90
+ ),
91
+ sources,
92
+ trace=args.trace,
93
+ pytest=args.command == "pytest",
94
+ module=getattr(args, "module", False),
95
+ max_events=args.max_events,
96
+ data_file=args.data_file,
97
+ )
98
+ except subprocess.CalledProcessError as error:
99
+ return error.returncode
100
+ except (ValueError, OSError, CoverageException) as error:
101
+ parser.error(str(error))
102
+ except KeyboardInterrupt:
103
+ return 130
104
+
105
+
106
+ if __name__ == "__main__":
107
+ raise SystemExit(main())
@@ -0,0 +1,98 @@
1
+ """Project-local source selection shared by both measurement modes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import fnmatch
6
+ import os
7
+ import tomllib
8
+ from pathlib import Path
9
+
10
+ EXCLUDED_DIRS = {
11
+ ".venv",
12
+ "venv",
13
+ "node_modules",
14
+ "build",
15
+ "dist",
16
+ "__pycache__",
17
+ "site-packages",
18
+ }
19
+
20
+
21
+ class Sources:
22
+ def __init__(self, root: Path, paths: list[str], omit: list[str] | None = None):
23
+ self.root = root.resolve()
24
+ self.paths = [(self.root / path).resolve() for path in paths]
25
+ self.omit = omit or []
26
+ if not self.paths:
27
+ raise ValueError(
28
+ "source must contain at least one project-relative file or directory"
29
+ )
30
+ for path in self.paths:
31
+ if not path.is_relative_to(self.root) or not path.exists():
32
+ raise ValueError(f"Source must exist inside the project root: {path}")
33
+
34
+ def name(self, filename: str) -> str | None:
35
+ # Use the real platform's concrete path type, even when tests mock os.name.
36
+ path = type(self.root)(filename)
37
+ if not path.is_absolute() or not filename.endswith(".py"):
38
+ return None
39
+ if not any(
40
+ path == source or path.is_relative_to(source) for source in self.paths
41
+ ):
42
+ return None
43
+ relative = path.relative_to(self.root)
44
+ if any(
45
+ part.startswith(".") or part in EXCLUDED_DIRS for part in relative.parts
46
+ ):
47
+ return None
48
+ name = relative.as_posix()
49
+ if any(
50
+ fnmatch.fnmatchcase(name, pattern)
51
+ or fnmatch.fnmatchcase(path.as_posix(), pattern)
52
+ for pattern in self.omit
53
+ ):
54
+ return None
55
+ return name
56
+
57
+ def snapshot(self) -> dict[str, list[str]]:
58
+ result = {}
59
+ for source in self.paths:
60
+ if source.is_file():
61
+ if name := self.name(str(source)):
62
+ result[name] = source.read_text(encoding="utf-8").splitlines()
63
+ continue
64
+ for directory, subdirs, filenames in os.walk(source):
65
+ subdirs[:] = [
66
+ name
67
+ for name in subdirs
68
+ if not name.startswith(".")
69
+ and name not in EXCLUDED_DIRS
70
+ and not (Path(directory) / name).is_symlink()
71
+ ]
72
+ for filename in filenames:
73
+ path = Path(directory) / filename
74
+ if path.is_symlink():
75
+ continue
76
+ if name := self.name(str(path)):
77
+ result[name] = path.read_text(encoding="utf-8").splitlines()
78
+ return dict(sorted(result.items()))
79
+
80
+
81
+ def project_sources(
82
+ root: Path, *, trace: bool = False, override: list[str] | None = None
83
+ ) -> Sources:
84
+ path = root / "pyproject.toml"
85
+ document = tomllib.loads(path.read_text(encoding="utf-8")) if path.is_file() else {}
86
+ settings = document.get("tool", {}).get("coverage-flashlight", {})
87
+ paths = override or settings.get(
88
+ "trace-source" if trace else "source", settings.get("source", ["."])
89
+ )
90
+ omit = settings.get("omit", [])
91
+ for name, value in [("source", paths), ("omit", omit)]:
92
+ if not isinstance(value, list) or not all(
93
+ isinstance(item, str) for item in value
94
+ ):
95
+ raise ValueError(
96
+ f"tool.coverage-flashlight.{name} must be a list of strings"
97
+ )
98
+ return Sources(root, paths, omit)
@@ -0,0 +1,82 @@
1
+ """Render measured line/branch coverage as a self-contained scenario explorer."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ import tempfile
8
+ from datetime import UTC, datetime
9
+ from pathlib import Path
10
+
11
+ from coverage import Coverage
12
+
13
+
14
+ def build_payload(cov: Coverage, root: Path) -> dict:
15
+ """Use coverage.py's analysis rather than inferring executable code ourselves."""
16
+ root = root.resolve()
17
+ measured_files = [
18
+ name
19
+ for name in cov.get_data().measured_files()
20
+ if Path(name).resolve().is_relative_to(root)
21
+ ]
22
+ if not measured_files:
23
+ raise ValueError("No measured source files inside the project root")
24
+ labels = sorted(cov.get_data().measured_contexts())
25
+ scopes = [("All recorded runs", None)]
26
+ scopes.extend(
27
+ (label or "Unlabelled run", [f"^{re.escape(label)}$"]) for label in labels
28
+ )
29
+
30
+ payload = {
31
+ "created": datetime.now(UTC).isoformat(),
32
+ "files": {},
33
+ "scopes": [],
34
+ "contexts": labels,
35
+ }
36
+ with tempfile.TemporaryDirectory(prefix="flashlight-report-") as directory:
37
+ report_path = Path(directory) / "report.json"
38
+ for index, (label, contexts) in enumerate(scopes):
39
+ cov.json_report(
40
+ morfs=measured_files,
41
+ outfile=str(report_path),
42
+ contexts=contexts,
43
+ show_contexts=True,
44
+ )
45
+ report = json.loads(report_path.read_text(encoding="utf-8"))
46
+ scope = {"label": label, "totals": report["totals"], "files": {}}
47
+ for filename, measured in report["files"].items():
48
+ path = Path(filename)
49
+ if not path.is_absolute():
50
+ path = root / path
51
+ name = path.relative_to(root).as_posix()
52
+ scope["files"][name] = {
53
+ "summary": measured["summary"],
54
+ "executed": measured["executed_lines"],
55
+ "missing_branches": measured.get("missing_branches", []),
56
+ }
57
+ if index == 0:
58
+ payload["files"][name] = {
59
+ "source": path.read_text(encoding="utf-8").splitlines(),
60
+ "statements": sorted(
61
+ measured["executed_lines"] + measured["missing_lines"]
62
+ ),
63
+ "excluded": measured["excluded_lines"],
64
+ "contexts": measured.get("contexts", {}),
65
+ }
66
+ payload["scopes"].append(scope)
67
+ return payload
68
+
69
+
70
+ def render(payload: dict) -> str:
71
+ template = (
72
+ Path(__file__).with_name("coverage_flashlight.html").read_text(encoding="utf-8")
73
+ )
74
+ template = template.replace(
75
+ "__FLASHLIGHT_STYLE__",
76
+ Path(__file__).with_name("flashlight.css").read_text(encoding="utf-8"),
77
+ )
78
+ # Source files can contain literal </script> tags (notably the dashboard).
79
+ encoded = json.dumps(payload, ensure_ascii=True, separators=(",", ":")).replace(
80
+ "<", "\\u003c"
81
+ )
82
+ return template.replace("__COVERAGE_DATA__", encoded)
@@ -0,0 +1,158 @@
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>Coverage flashlight</title>
6
+ <style>
7
+ __FLASHLIGHT_STYLE__
8
+ .legend { display:flex; gap:12px; flex-wrap:wrap; font-size:11px; margin-top:5px; color:var(--muted); }
9
+ .legend span::before { content:""; display:inline-block; width:9px; height:9px; border:1px solid var(--border); margin-right:4px; }
10
+ .legend .lit::before { background:var(--hit); }
11
+ .legend .partial::before { background:var(--partial); }
12
+ .legend .miss::before { background:var(--miss); }
13
+ .legend .elsewhere::before { background:var(--elsewhere); }
14
+ main { grid-template-columns:minmax(260px,310px) minmax(0,1fr); }
15
+ aside { min-height:0; padding:6px 10px; border-right:1px solid var(--border); overflow:auto; overscroll-behavior:contain; }
16
+ table { border-collapse:collapse; width:100%; }
17
+ th { position:sticky; top:-6px; background:var(--bg); font-weight:500; color:var(--muted); text-align:right; font-size:11px; }
18
+ th:first-child { text-align:left; }
19
+ td { padding:2px 0; border-bottom:1px solid var(--border); font-variant-numeric:tabular-nums; }
20
+ td button { width:100%; border:0; text-align:left; background:none; font-size:12px; padding:5px 3px; overflow-wrap:anywhere; }
21
+ tr[aria-selected=true] { background:var(--elsewhere); }
22
+ .percent { min-width:53px; font-size:11px; text-align:right; }
23
+ .bar { height:3px; width:45px; margin:2px 0 2px auto; background:var(--border); }
24
+ .bar i { display:block; height:100%; background:var(--accent); }
25
+ #file-summary { margin:0; font-size:12px; }
26
+ .line.hit { background:var(--hit); }
27
+ .line.partial { background:var(--partial); }
28
+ .line.miss { background:var(--miss); }
29
+ .line.elsewhere { background:var(--elsewhere); }
30
+ .line.selected { outline:2px solid var(--accent); outline-offset:-2px; }
31
+ .line button { position:sticky; left:0; flex:0 0 56px; width:56px; border:0; border-radius:0; padding:0 8px; font:inherit; text-align:right; color:var(--muted); background:var(--panel); }
32
+ #line-detail { flex-shrink:0; overflow:auto; max-height:32px; font-size:11px; overflow-wrap:anywhere; color:var(--muted); }
33
+ #scope { flex:1; }
34
+ @media(max-width:850px) { aside { border-right:0; border-bottom:1px solid var(--border); } #totals { font-size:11px; } }
35
+ </style>
36
+ <header>
37
+ <h1>Coverage flashlight</h1>
38
+ <div class="controls">
39
+ <label for="scope">Measured scope</label><select id="scope"></select>
40
+ <span class="metrics" id="totals" aria-live="polite"></span>
41
+ </div>
42
+ <div class="legend" aria-label="Line status legend">
43
+ <span class="lit" title="Executed in the selected scope">Executed</span><span class="partial" title="Executed line with an untested branch alternative">Branch gap</span>
44
+ <span class="elsewhere" title="Executed in another recorded scope">Other scope</span><span class="miss" title="Never executed in these runs">Unexecuted</span>
45
+ </div>
46
+ </header>
47
+ <main>
48
+ <aside aria-label="Modules">
49
+ <table><thead><tr><th>Module</th><th>Lines</th><th>Branches</th></tr></thead><tbody id="modules"></tbody></table>
50
+ </aside>
51
+ <section class="source-panel" aria-label="Source coverage">
52
+ <div class="source-heading"><h2 id="filename"></h2><button id="next-gap" type="button" aria-label="Next uncovered line or branch">Next gap</button></div>
53
+ <p id="file-summary" class="metrics"></p>
54
+ <div id="code" aria-label="Source lines"></div>
55
+ <div id="line-detail" aria-live="polite">Select a line number to inspect its recorded scenarios and missing branch destinations.</div>
56
+ </section>
57
+ </main>
58
+ <footer><span id="generated"></span>
59
+ <details><summary>About coverage</summary><div class="help-content">
60
+ <p>Coverage records execution, not correctness. Imports and function definitions count as executable lines. A test can execute code without asserting its result.</p>
61
+ <p>Scopes include setup, teardown, and Python subprocesses started during that run. Background worker activity is attributed to the scenario, not to individual concurrent requests. A pre-existing GUI process is not captured automatically.</p>
62
+ <p>This view contains no execution counts, durations, call ordering, variable history, or native-code coverage. Branch destinations are line numbers within this file; “exit” means leaving the function. The source is a snapshot taken when this report was generated.</p>
63
+ </div></details>
64
+ </footer>
65
+ <script id="coverage-data" type="application/json">__COVERAGE_DATA__</script>
66
+ <script>
67
+ "use strict";
68
+ const data = JSON.parse(document.getElementById("coverage-data").textContent);
69
+ const scopeSelect = document.getElementById("scope");
70
+ const files = Object.keys(data.files).sort();
71
+ let currentFile = files[0];
72
+ let selectedLine = null;
73
+ const percent = (hit, total) => total ? `${(100 * hit / total).toFixed(1)}%` : "—";
74
+ const totalsText = s => `${percent(s.covered_lines, s.num_statements)} lines (${s.covered_lines}/${s.num_statements}) · ${percent(s.covered_branches, s.num_branches)} branches (${s.covered_branches}/${s.num_branches})`;
75
+ const scope = () => data.scopes[Number(scopeSelect.value)];
76
+ for (let index = 0; index < data.scopes.length; index++) {
77
+ const option = document.createElement("option");
78
+ option.value = index;
79
+ option.textContent = data.scopes[index].label;
80
+ scopeSelect.append(option);
81
+ }
82
+ function metricCell(hit, total) {
83
+ const td = document.createElement("td"); td.className = "percent";
84
+ td.textContent = percent(hit, total);
85
+ const bar = document.createElement("div"); bar.className = "bar";
86
+ const fill = document.createElement("i"); fill.style.width = `${total ? 100 * hit / total : 0}%`;
87
+ bar.append(fill); td.append(bar); return td;
88
+ }
89
+ function drawModules() {
90
+ const body = document.getElementById("modules"); body.replaceChildren();
91
+ for (const name of files) {
92
+ const row = document.createElement("tr"); row.setAttribute("aria-selected", String(name === currentFile));
93
+ const td = document.createElement("td");
94
+ const button = document.createElement("button"); button.type = "button";
95
+ button.textContent = name;
96
+ button.addEventListener("click", () => { currentFile = name; selectedLine = null; draw(); });
97
+ td.append(button); row.append(td);
98
+ const s = scope().files[name].summary;
99
+ row.append(metricCell(s.covered_lines, s.num_statements), metricCell(s.covered_branches, s.num_branches));
100
+ body.append(row);
101
+ }
102
+ }
103
+ function selectLine(line) {
104
+ selectedLine = line;
105
+ for (const row of document.querySelectorAll(".line.selected")) row.classList.remove("selected");
106
+ document.getElementById(`line-${line}`).classList.add("selected");
107
+ const labels = data.files[currentFile].contexts[String(line)] || [];
108
+ const missing = scope().files[currentFile].missing_branches.filter(([from]) => from === line).map(([,to]) => to < 0 ? "exit" : to);
109
+ const detail = document.getElementById("line-detail");
110
+ detail.textContent = `Line ${line} · ${labels.length ? "Recorded in: " + labels.join(", ") : "No recorded execution"}${missing.length ? " · Missing destinations in scope: " + missing.join(", ") : ""}`;
111
+ }
112
+ function drawSource() {
113
+ const measured = scope().files[currentFile];
114
+ const file = data.files[currentFile];
115
+ const statements = new Set(file.statements);
116
+ const hits = new Set(measured.executed);
117
+ const allHits = new Set(data.scopes[0].files[currentFile].executed);
118
+ const partial = new Set(measured.missing_branches.map(([from]) => from));
119
+ document.getElementById("filename").textContent = currentFile;
120
+ document.getElementById("filename").title = currentFile;
121
+ document.getElementById("file-summary").textContent = totalsText(measured.summary);
122
+ const code = document.getElementById("code"); code.replaceChildren();
123
+ const fragment = document.createDocumentFragment();
124
+ file.source.forEach((text, index) => {
125
+ const line = index + 1;
126
+ const row = document.createElement("div"); row.className = "line"; row.id = `line-${line}`;
127
+ if (statements.has(line)) row.classList.add(hits.has(line) ? partial.has(line) ? "partial" : "hit" : allHits.has(line) ? "elsewhere" : "miss");
128
+ const number = document.createElement("button"); number.type = "button"; number.textContent = line;
129
+ number.setAttribute("aria-label", `Inspect line ${line}`);
130
+ number.addEventListener("click", () => selectLine(line));
131
+ const content = document.createElement("code"); content.textContent = text || " ";
132
+ row.append(number, content); fragment.append(row);
133
+ });
134
+ code.append(fragment);
135
+ if (selectedLine !== null) selectLine(selectedLine);
136
+ else { code.scrollTop = 0; document.getElementById("line-detail").textContent = "Select a line number to inspect its recorded scenarios and missing branch destinations."; }
137
+ }
138
+ function draw() {
139
+ const s = scope().totals;
140
+ document.getElementById("totals").textContent = `${percent(s.covered_lines, s.num_statements)} lines · ${percent(s.covered_branches, s.num_branches)} branches`;
141
+ document.getElementById("totals").title = totalsText(s);
142
+ drawModules(); drawSource();
143
+ }
144
+ scopeSelect.addEventListener("change", draw);
145
+ document.getElementById("next-gap").addEventListener("click", () => {
146
+ const measured = scope().files[currentFile];
147
+ const hits = new Set(measured.executed);
148
+ const partial = new Set(measured.missing_branches.map(([from]) => from));
149
+ const gaps = data.files[currentFile].statements.filter(line => !hits.has(line) || partial.has(line));
150
+ if (!gaps.length) { document.getElementById("line-detail").textContent = "No uncovered lines or branches in this module and scope."; return; }
151
+ const next = gaps.find(line => line > (selectedLine || 0)) || gaps[0];
152
+ selectLine(next);
153
+ document.getElementById(`line-${next}`).scrollIntoView({block:"center"});
154
+ });
155
+ document.getElementById("generated").textContent = `Measured Python execution · Report generated ${new Date(data.created).toLocaleString()} · ${files.length} modules · ${data.contexts.length} recorded scopes`;
156
+ draw();
157
+ </script>
158
+ </html>