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,353 @@
1
+ """Record ordered Python execution for isolated pytest cases, including workers.
2
+
3
+ No tracing is installed by importing this module. The runner adds an opt-in
4
+ sitecustomize to its children's PYTHONPATH; the installed environment is untouched.
5
+
6
+ PYTEST_DONT_REWRITE: this module is also loaded before pytest in child processes.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import atexit
12
+ import json
13
+ import os
14
+ import sys
15
+ import threading
16
+ import time
17
+ import uuid
18
+ from pathlib import Path
19
+
20
+ from coverage_flashlight.config import Sources
21
+
22
+ KINDS = ("call", "line", "return", "exception")
23
+ _recorder = None
24
+
25
+
26
+ class Recorder:
27
+ def __init__(
28
+ self,
29
+ root: Path,
30
+ directory: Path,
31
+ limit: int,
32
+ *,
33
+ active: bool,
34
+ sources: Sources | None = None,
35
+ role: str | None = None,
36
+ ):
37
+ self.root = root.resolve()
38
+ self.selection = sources or Sources(self.root, ["."])
39
+ self.limit = limit
40
+ self.active = active
41
+ self.closed = False
42
+ self.count = 0
43
+ self.truncated = False
44
+ self.capture_error = None
45
+ self.lock = threading.RLock()
46
+ self.paths = {}
47
+ self.locations = {}
48
+ self.threads = {}
49
+ self.sources = set()
50
+ self.buffer = []
51
+ directory.mkdir(parents=True, exist_ok=True)
52
+ self.file = (directory / f"{os.getpid()}-{uuid.uuid4().hex[:8]}.jsonl").open(
53
+ "x", encoding="utf-8"
54
+ )
55
+ self.write(
56
+ {
57
+ "type": "process",
58
+ "pid": os.getpid(),
59
+ "parent": os.getppid(),
60
+ "role": role or ("child" if active else "test"),
61
+ }
62
+ )
63
+
64
+ def write(self, value):
65
+ try:
66
+ self.file.write(json.dumps(value, separators=(",", ":")) + "\n")
67
+ except OSError as error:
68
+ self.capture_error = str(error)
69
+ self.active = False
70
+
71
+ def start(self):
72
+ if sys.gettrace() is not None or threading.gettrace() is not None:
73
+ raise RuntimeError(
74
+ "Execution recording cannot share a tracer with coverage or a debugger"
75
+ )
76
+ threading.settrace(self.trace)
77
+ sys.settrace(self.trace)
78
+
79
+ def trace(self, frame, event, _arg):
80
+ try:
81
+ return self._trace(frame, event)
82
+ except (OSError, UnicodeError) as error:
83
+ # A diagnostic capture must not turn a source/logging I/O failure
84
+ # into an exception in the application's currently executing line.
85
+ self.capture_error = str(error)
86
+ self.active = False
87
+ return None
88
+
89
+ def _trace(self, frame, event):
90
+ if self.closed or not self.active or self.truncated or self.capture_error:
91
+ return None
92
+ filename = frame.f_code.co_filename
93
+ if filename not in self.paths:
94
+ self.paths[filename] = self.selection.name(filename)
95
+ path = self.paths[filename]
96
+ if path is None:
97
+ return None
98
+ if event not in KINDS:
99
+ return self.trace
100
+ timestamp = time.perf_counter_ns()
101
+ with self.lock:
102
+ if self.closed:
103
+ return None
104
+ if self.count >= self.limit:
105
+ self.truncated = True
106
+ self.flush()
107
+ self.write({"type": "truncated", "limit": self.limit})
108
+ self.file.flush()
109
+ return None
110
+ if path not in self.sources:
111
+ self.sources.add(path)
112
+ self.write(
113
+ {
114
+ "type": "source",
115
+ "path": path,
116
+ # Keep the actual platform's path type when tests mock os.name.
117
+ "text": type(self.root)(filename).read_text(encoding="utf-8"),
118
+ }
119
+ )
120
+ key = (path, frame.f_lineno, frame.f_code.co_qualname)
121
+ if key not in self.locations:
122
+ self.locations[key] = len(self.locations)
123
+ self.write(
124
+ {"type": "location", "id": self.locations[key], "value": key}
125
+ )
126
+ # Thread objects, rather than OS IDs, distinguish reused thread IDs.
127
+ thread = threading.current_thread()
128
+ if thread not in self.threads:
129
+ self.threads[thread] = len(self.threads)
130
+ self.write(
131
+ {"type": "thread", "id": self.threads[thread], "name": thread.name}
132
+ )
133
+ self.buffer.append(
134
+ [
135
+ timestamp,
136
+ self.threads[thread],
137
+ KINDS.index(event),
138
+ self.locations[key],
139
+ ]
140
+ )
141
+ self.count += 1
142
+ if len(self.buffer) >= 256:
143
+ self.flush()
144
+ return self.trace
145
+
146
+ def flush(self):
147
+ if self.buffer:
148
+ self.write({"type": "events", "values": self.buffer})
149
+ self.buffer = []
150
+ try:
151
+ self.file.flush()
152
+ except OSError as error:
153
+ self.capture_error = str(error)
154
+ self.active = False
155
+
156
+ def mark(self, phase, outcome=None):
157
+ with self.lock:
158
+ if not self.closed and not self.capture_error:
159
+ self.flush()
160
+ self.write(
161
+ {
162
+ "type": "phase",
163
+ "time": time.perf_counter_ns(),
164
+ "name": phase,
165
+ "outcome": outcome,
166
+ }
167
+ )
168
+ self.flush()
169
+
170
+ def close(self):
171
+ with self.lock:
172
+ if self.closed:
173
+ return
174
+ interrupted = sys.gettrace() != self.trace
175
+ self.closed = True
176
+ if not interrupted:
177
+ sys.settrace(None)
178
+ if threading.gettrace() == self.trace:
179
+ threading.settrace(None)
180
+ self.flush()
181
+ self.write(
182
+ {
183
+ "type": "end",
184
+ "events": self.count,
185
+ "interrupted": interrupted,
186
+ "error": self.capture_error,
187
+ }
188
+ )
189
+ try:
190
+ self.file.close()
191
+ except OSError:
192
+ pass # A missing end marker still flags incomplete capture.
193
+
194
+
195
+ def install():
196
+ global _recorder
197
+ if _recorder is not None:
198
+ return
199
+ is_child = "COVERAGE_FLASHLIGHT_PARENT" in os.environ
200
+ os.environ.setdefault("COVERAGE_FLASHLIGHT_PARENT", str(os.getpid()))
201
+ _recorder = Recorder(
202
+ Path(os.environ["COVERAGE_FLASHLIGHT_ROOT"]),
203
+ Path(os.environ["COVERAGE_FLASHLIGHT_DIR"]),
204
+ int(os.environ["COVERAGE_FLASHLIGHT_LIMIT"]),
205
+ active=is_child or os.environ.get("COVERAGE_FLASHLIGHT_MODE") == "run",
206
+ role="child"
207
+ if is_child
208
+ else os.environ.get("COVERAGE_FLASHLIGHT_MODE", "test"),
209
+ sources=Sources(
210
+ Path(os.environ["COVERAGE_FLASHLIGHT_ROOT"]),
211
+ json.loads(os.environ.get("COVERAGE_FLASHLIGHT_SOURCE", '["."]')),
212
+ json.loads(os.environ.get("COVERAGE_FLASHLIGHT_OMIT", "[]")),
213
+ ),
214
+ )
215
+ _recorder.start()
216
+ atexit.register(_recorder.close)
217
+ real_exit = os._exit
218
+
219
+ def recorded_exit(status):
220
+ _recorder.close()
221
+ real_exit(status)
222
+
223
+ os._exit = recorded_exit
224
+
225
+
226
+ def pytest_runtest_logstart(nodeid, location):
227
+ del nodeid, location
228
+ if _recorder:
229
+ _recorder.active = True
230
+ _recorder.mark("setup")
231
+
232
+
233
+ def pytest_runtest_call(item):
234
+ del item
235
+ if _recorder:
236
+ _recorder.mark("call")
237
+
238
+
239
+ def pytest_runtest_teardown(item):
240
+ del item
241
+ if _recorder:
242
+ _recorder.mark("teardown")
243
+
244
+
245
+ def pytest_runtest_logreport(report):
246
+ if _recorder:
247
+ _recorder.mark(report.when, report.outcome)
248
+
249
+
250
+ def pytest_runtest_logfinish(nodeid, location):
251
+ del nodeid, location
252
+ if _recorder:
253
+ _recorder.close()
254
+
255
+
256
+ def read_recording(directory: Path, sources: dict) -> dict:
257
+ """Merge by a shared monotonic clock, preserving each thread's event order.
258
+
259
+ This is observation order, not a causal ordering of concurrent processes.
260
+ A killed process may leave a partial final JSON line or no end marker.
261
+ """
262
+ events, lanes, locations, phases, warnings = [], [], [], [], []
263
+ process_count = 0
264
+ for path in sorted(directory.glob("*.jsonl")):
265
+ local_locations, local_threads = {}, {}
266
+ ended = False
267
+ process = None
268
+ with path.open(encoding="utf-8") as stream:
269
+ for line in stream:
270
+ try:
271
+ record = json.loads(line)
272
+ except json.JSONDecodeError:
273
+ warnings.append(f"{path.name}: incomplete final record")
274
+ break
275
+ kind = record["type"]
276
+ if kind == "process":
277
+ process = record
278
+ process_count += 1
279
+ elif kind == "source":
280
+ name, source = record["path"], record["text"].splitlines()
281
+ if name in sources and sources[name] != source:
282
+ raise ValueError(f"Source changed between recordings: {name}")
283
+ sources[name] = source
284
+ elif kind == "location":
285
+ local_locations[record["id"]] = len(locations)
286
+ locations.append(record["value"])
287
+ elif kind == "thread":
288
+ local_threads[record["id"]] = len(lanes)
289
+ lanes.append(
290
+ {
291
+ "pid": process["pid"],
292
+ "parent": process["parent"],
293
+ "role": process["role"],
294
+ "name": record["name"],
295
+ }
296
+ )
297
+ elif kind == "events":
298
+ events.extend(
299
+ [ns, local_threads[thread], event, local_locations[location]]
300
+ for ns, thread, event, location in record["values"]
301
+ )
302
+ elif kind == "phase":
303
+ phases.append(record)
304
+ elif kind == "truncated":
305
+ warnings.append(
306
+ f"PID {process['pid']}: stopped at {record['limit']:,} events"
307
+ )
308
+ elif kind == "end":
309
+ ended = True
310
+ if record.get("error"):
311
+ warnings.append(
312
+ f"PID {process['pid']}: capture stopped: {record['error']}"
313
+ )
314
+ if record["interrupted"]:
315
+ warnings.append(
316
+ f"PID {process['pid']}: another tracer interrupted capture"
317
+ )
318
+ if not ended:
319
+ warnings.append(
320
+ f"{path.name}: process did not finish capture; buffered events may be missing"
321
+ )
322
+ if not events:
323
+ warnings.append("No execution events captured")
324
+ events.sort(key=lambda event: event[0])
325
+ origin = events[0][0] if events else 0
326
+ for event in events:
327
+ event[0] = round((event[0] - origin) / 1_000_000, 3)
328
+ for phase in phases:
329
+ phase["time"] = round((phase["time"] - origin) / 1_000_000, 3)
330
+ return {
331
+ "events": events,
332
+ "lanes": lanes,
333
+ "locations": locations,
334
+ "phases": phases,
335
+ "warnings": warnings,
336
+ "processes": process_count,
337
+ }
338
+
339
+
340
+ def render(payload):
341
+ template = (
342
+ Path(__file__)
343
+ .with_name("execution_flashlight.html")
344
+ .read_text(encoding="utf-8")
345
+ )
346
+ encoded = json.dumps(payload, ensure_ascii=True, separators=(",", ":")).replace(
347
+ "<", "\\u003c"
348
+ )
349
+ template = template.replace(
350
+ "__FLASHLIGHT_STYLE__",
351
+ Path(__file__).with_name("flashlight.css").read_text(encoding="utf-8"),
352
+ )
353
+ return template.replace("__TRACE_DATA__", encoded)
@@ -0,0 +1,150 @@
1
+ Metadata-Version: 2.5
2
+ Name: coverage-flashlight
3
+ Version: 1.0.0
4
+ Summary: Explore Python coverage and replay execution with a code flashlight.
5
+ License-Expression: MIT
6
+ License-File: LICENSE
7
+ Requires-Python: >=3.11
8
+ Requires-Dist: coverage>=7.10.6
9
+ Provides-Extra: pytest
10
+ Requires-Dist: pytest>=8; extra == 'pytest'
11
+ Description-Content-Type: text/markdown
12
+
13
+ # Coverage flashlight
14
+
15
+ See which Python code ran, then replay its execution as a flashlight revealing
16
+ source through fog. Reports are standalone HTML with source snapshots, shared
17
+ light/dark themes, and no server or external assets.
18
+
19
+ ## Install and run
20
+
21
+ Install in the environment containing the program and its dependencies:
22
+
23
+ ```sh
24
+ uv add --dev 'coverage-flashlight[pytest]'
25
+ # Or: python -m pip install 'coverage-flashlight[pytest]'
26
+ ```
27
+
28
+ ## Usage
29
+
30
+ ```sh
31
+ # Static per-test line/branch coverage; pytest arguments follow --.
32
+ flashlight pytest -- tests/ -k database
33
+
34
+ # Ordered playback, including threads and newly launched Python processes.
35
+ flashlight pytest --trace -- tests/ -k database
36
+
37
+ # A script, with its arguments, executed once.
38
+ flashlight run -- scripts/example.py --input sample.bin
39
+ flashlight run --trace -- scripts/example.py --input sample.bin
40
+
41
+ # Python modules work too, including other test runners.
42
+ flashlight run -m my_package --help
43
+ flashlight run --trace -m unittest discover
44
+
45
+ # Render existing coverage.py branch data without another run.
46
+ flashlight report
47
+ ```
48
+
49
+ Open **htmlcov/flashlight.html** for static coverage or
50
+ **htmlcov/execution.html** for playback. Use `--output path/to/report.html`
51
+ before `--` to choose another destination. `python -m coverage_flashlight`
52
+ is equivalent to `flashlight`.
53
+
54
+ Coverage and tracing are separate modes: a command never silently runs a script
55
+ twice. `--trace` selects execution recording instead of coverage.py. To create
56
+ both reports, run the two commands explicitly. The program's exit status is
57
+ preserved and failed runs still produce a report. Pytest mode continues recording
58
+ other cases after a failure and returns a nonzero status.
59
+
60
+ Pytest is an optional adapter. Static measurement uses coverage.py; ordered
61
+ playback uses Python trace events. Script/module execution requires no pytest
62
+ installation. Each pytest case runs in its own interpreter with its own context,
63
+ which also follows newly launched Python children. This includes fixture setup
64
+ and teardown. Isolation is slower than a normal suite run and can expose fixture
65
+ or order dependencies; continue running ordinary tests for correctness.
66
+
67
+ ## Source selection
68
+
69
+ Run from the target project's root. By default, the tool includes Python files
70
+ inside that root, excluding hidden directories, virtual environments, build
71
+ outputs, and node_modules. Narrow this with `--source src --source tests` or:
72
+
73
+ ```toml
74
+ [tool.coverage-flashlight]
75
+ source = ["src/my_package"]
76
+ trace-source = ["src/my_package", "tests"]
77
+ omit = ["*/generated/*"]
78
+ ```
79
+
80
+ Paths are project-relative files or directories, not import names. `trace-source`
81
+ defaults to `source`; absent both, the project root is used. `--source` overrides
82
+ the configured selection for the current command. Keep source files inside the
83
+ project root. Selected but unexecuted files remain visible in the reports.
84
+
85
+ Coverage mode writes fresh data to `.coverage` after collection, replacing its
86
+ previous contents. Use `--data-file path` to keep a separate database. It enables
87
+ branch measurement and subprocess/`os._exit()` coverage itself; no coverage.py
88
+ configuration is required. Existing report exclusion rules are respected when
89
+ rendering. `flashlight report` can also render a combined database collected by
90
+ coverage.py or pytest-cov, preserving its context names.
91
+
92
+ Raw recordings and manifests are kept beside the report under `runs/<run-id>/`.
93
+ The default trace cap is 100,000 events per process; increase it with
94
+ `--max-events 500000`. Capture caps, interrupted hooks, source I/O failures, and
95
+ missing process completion markers are explicitly reported.
96
+
97
+ ## Reading the reports
98
+
99
+ Static coverage distinguishes executed lines, branch gaps, execution in another
100
+ scope, and unexecuted statements. Select a line to inspect its contexts and missing
101
+ branch destinations. Coverage is evidence of execution, not proof of assertions.
102
+
103
+ Playback offers Play/Pause, stepping, timeline scrubbing, Next new line, a pytest
104
+ Test body shortcut, Whole run, and process/thread selection. Unrevealed ranges
105
+ collapse to grey `…` rows; Reveal source context expands them without marking
106
+ them visited. Selecting a module pauses playback; Follow execution restores
107
+ automatic navigation. The module map and source scroll inside the window.
108
+
109
+ The timeline records observed event order, not variable history or cross-thread
110
+ causality. Speed is events per second, not original elapsed time. Generator
111
+ suspension produces Python return/call events. Native code, generated code with
112
+ synthetic filenames, pre-existing processes, and threads created outside Python's
113
+ threading module are outside capture. A forcibly killed process can lose its
114
+ buffered tail. A skipped test can have no events. The source snapshot must remain
115
+ consistent across cases; changed source is rejected rather than misattributed.
116
+
117
+ Tracing changes timing and cannot share a trace hook with coverage.py or a
118
+ debugger. Pytest-xdist parallel execution is rejected: per-case subprocess
119
+ isolation already supplies attribution. Python children using `-S` or `-I`
120
+ can bypass the opt-in bootstrap. No startup hook is installed globally.
121
+
122
+ ## Development and releases
123
+
124
+ ```sh
125
+ uv sync --all-extras
126
+ uv run pytest
127
+ uv run ruff check .
128
+ uv run ruff format --check .
129
+ uv build
130
+ ```
131
+
132
+ Tests use disposable Python projects, real threads/subprocesses, failing and
133
+ skipped cases, arbitrary pytest layouts, and actual script/module entry points.
134
+ They require no IDA installation. HTML, CSS and the opt-in bootstrap ship in the
135
+ wheel and source distribution.
136
+
137
+ The manual `Release` GitHub Actions workflow supports `release-current`, `dev`,
138
+ `release-patch`, and `release-minor`. It tests, builds and checks the wheel, commits
139
+ an optional version bump, creates a tag, publishes with `uv publish`, and creates
140
+ a GitHub release. Versioning uses `uv version`; there is only one package version.
141
+
142
+ To enable publication, create the GitHub repository and configure a PyPI trusted
143
+ publisher for project **coverage-flashlight**, your repository owner/name,
144
+ workflow **release.yml**, and environment **pypi**. Create that GitHub environment
145
+ and allow the workflow to push release commits/tags. No PyPI API token is needed.
146
+ These setup steps do not publish anything until the workflow is dispatched.
147
+
148
+ References: [uv publishing](https://docs.astral.sh/uv/guides/package/),
149
+ [coverage.py subprocess measurement](https://coverage.readthedocs.io/en/latest/subprocess.html),
150
+ [Python tracing](https://docs.python.org/3/library/sys.html#sys.settrace).
@@ -0,0 +1,17 @@
1
+ coverage_flashlight/__init__.py,sha256=pNroUo3M1ka2f0wZ5or0nVaIaK5EEK6s5RgL8xn1Sxs,70
2
+ coverage_flashlight/__main__.py,sha256=fHO_t_1Iuz93vFIYUBhGcJyekERd8vGNId6tmTe5QM4,67
3
+ coverage_flashlight/cli.py,sha256=cKOPcGaTBNlcGXms4ttyEYVwVMXgA0RV2VWo3lIlYR0,3868
4
+ coverage_flashlight/config.py,sha256=IBfVZ3bWmV0qMGmUJ5wEGpCqWcnWz8bg3N86iGb78ns,3517
5
+ coverage_flashlight/coverage.py,sha256=J_yaufg22Pr5nB8w4Y4QGMWWDl3TVTJD1gENURppbFw,3125
6
+ coverage_flashlight/coverage_flashlight.html,sha256=W2u_OMQDvKZ3-LpRbu13fEx6q4z5E8171aKu9jK-tw4,9939
7
+ coverage_flashlight/execution_flashlight.html,sha256=MwtWt7IMxEnFT1VQmLcEkGvGWO4tUStiSBXNV9VtMbw,17486
8
+ coverage_flashlight/flashlight.css,sha256=x0pXmZiM0seo8JAvLv2GdpqciwmR-fy_CaWlombUCN8,3472
9
+ coverage_flashlight/pytest_plugin.py,sha256=bQUplZbOeQsI5kE-9kroC8PvdJtR0EXoBlTUOjqppGk,918
10
+ coverage_flashlight/runner.py,sha256=ZqPYibrfHT7NjF9OyI4_A53NbCwkgmudKxPtZNqCHM8,7674
11
+ coverage_flashlight/trace.py,sha256=5tmOFaublgk4o4F7XXRfUIPjH_cnhUOJ46tutgGYbrI,12051
12
+ coverage_flashlight/bootstrap/sitecustomize.py,sha256=WYmg4f8aq4c5Zr192FedHF0-h2Q_NIcxqIVP4mfsudI,663
13
+ coverage_flashlight-1.0.0.dist-info/METADATA,sha256=YWgyKsUcjp8G2uQTI84pz3RzjSgBT7eQEsi2tkA8l44,6865
14
+ coverage_flashlight-1.0.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
15
+ coverage_flashlight-1.0.0.dist-info/entry_points.txt,sha256=J8X-ys63GVnf38C6lFZDMvyCkWRp7rW5icKKSSEuaV4,60
16
+ coverage_flashlight-1.0.0.dist-info/licenses/LICENSE,sha256=NiZqj9BzVoOUy4HNsrEk9_2uLGTBp-0J2zS00i76KVE,1359
17
+ coverage_flashlight-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ flashlight = coverage_flashlight.cli:main
@@ -0,0 +1,23 @@
1
+ Boost Software License - Version 1.0 - August 17th, 2003
2
+
3
+ Permission is hereby granted, free of charge, to any person or organization
4
+ obtaining a copy of the software and accompanying documentation covered by
5
+ this license (the "Software") to use, reproduce, display, distribute,
6
+ execute, and transmit the Software, and to prepare derivative works of the
7
+ Software, and to permit third-parties to whom the Software is furnished to
8
+ do so, all subject to the following:
9
+
10
+ The copyright notices in the Software and this entire statement, including
11
+ the above license grant, this restriction and the following disclaimer,
12
+ must be included in all copies of the Software, in whole or in part, and
13
+ all derivative works of the Software, unless such copies or derivative
14
+ works are solely in the form of machine-executable object code generated by
15
+ a source language processor.
16
+
17
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
18
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
19
+ FITNESS FOR A PARTICULAR PURPOSE, TITLE AND NON-INFRINGEMENT. IN NO EVENT
20
+ SHALL THE COPYRIGHT HOLDERS OR ANYONE DISTRIBUTING THE SOFTWARE BE LIABLE
21
+ FOR ANY DAMAGES OR OTHER LIABILITY, WHETHER IN CONTRACT, TORT OR OTHERWISE,
22
+ ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
23
+ DEALINGS IN THE SOFTWARE.