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,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,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.
|