concurim 0.1.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.
Files changed (133) hide show
  1. concurim/__init__.py +72 -0
  2. concurim/__main__.py +7 -0
  3. concurim/cli.py +262 -0
  4. concurim/config.py +81 -0
  5. concurim/export/__init__.py +1 -0
  6. concurim/export/cdp.py +224 -0
  7. concurim/export/cli.py +110 -0
  8. concurim/export/options.py +99 -0
  9. concurim/export/sink.py +126 -0
  10. concurim/export/video.py +136 -0
  11. concurim/instrumentation/__init__.py +1 -0
  12. concurim/instrumentation/aio_events.py +158 -0
  13. concurim/instrumentation/aio_primitives.py +334 -0
  14. concurim/instrumentation/aio_taskgroup.py +87 -0
  15. concurim/instrumentation/aio_tasks.py +152 -0
  16. concurim/instrumentation/auto.py +251 -0
  17. concurim/instrumentation/auto_events.py +56 -0
  18. concurim/instrumentation/auto_mp.py +33 -0
  19. concurim/instrumentation/auto_support.py +46 -0
  20. concurim/instrumentation/barriers.py +213 -0
  21. concurim/instrumentation/executors.py +144 -0
  22. concurim/instrumentation/futures_support.py +132 -0
  23. concurim/instrumentation/mp_pool.py +272 -0
  24. concurim/instrumentation/mp_process.py +113 -0
  25. concurim/instrumentation/mp_support.py +268 -0
  26. concurim/instrumentation/mp_tasks.py +70 -0
  27. concurim/instrumentation/process_pool.py +164 -0
  28. concurim/instrumentation/queue_support.py +145 -0
  29. concurim/instrumentation/queues.py +263 -0
  30. concurim/instrumentation/sync.py +294 -0
  31. concurim/instrumentation/sync_events.py +179 -0
  32. concurim/instrumentation/threads.py +92 -0
  33. concurim/instrumentation/work.py +65 -0
  34. concurim/program_check.py +47 -0
  35. concurim/py.typed +0 -0
  36. concurim/recorder/__init__.py +1 -0
  37. concurim/recorder/collector.py +351 -0
  38. concurim/recorder/environment.py +110 -0
  39. concurim/recorder/events.py +246 -0
  40. concurim/recorder/flush.py +143 -0
  41. concurim/recorder/identities.py +27 -0
  42. concurim/recorder/lifecycle.py +211 -0
  43. concurim/recorder/sampler.py +192 -0
  44. concurim/recorder/serialization.py +159 -0
  45. concurim/replay/__init__.py +1 -0
  46. concurim/replay/code_view.py +71 -0
  47. concurim/replay/engine.py +218 -0
  48. concurim/replay/explanations.py +141 -0
  49. concurim/replay/models.py +124 -0
  50. concurim/replay/rules_barrier.py +83 -0
  51. concurim/replay/rules_events.py +191 -0
  52. concurim/replay/rules_exec.py +178 -0
  53. concurim/replay/rules_queues.py +200 -0
  54. concurim/replay/rules_sync.py +79 -0
  55. concurim/replay/rules_threads.py +101 -0
  56. concurim/replay/spans.py +128 -0
  57. concurim/replay/state.py +323 -0
  58. concurim/replay/state_barrier.py +79 -0
  59. concurim/replay/state_events.py +108 -0
  60. concurim/replay/state_exec.py +174 -0
  61. concurim/replay/state_sync.py +88 -0
  62. concurim/replay/state_taskgroup.py +70 -0
  63. concurim/replay/wait_for.py +206 -0
  64. concurim/runner.py +90 -0
  65. concurim/samples/__init__.py +32 -0
  66. concurim/samples/asyncio-condition.concurim +0 -0
  67. concurim/samples/asyncio-queue.concurim +0 -0
  68. concurim/samples/asyncio-tasks.concurim +0 -0
  69. concurim/samples/asyncio_condition.py +40 -0
  70. concurim/samples/asyncio_queue.py +42 -0
  71. concurim/samples/asyncio_tasks.py +25 -0
  72. concurim/samples/backpressure.concurim +0 -0
  73. concurim/samples/backpressure.py +48 -0
  74. concurim/samples/barrier.concurim +0 -0
  75. concurim/samples/barrier.py +31 -0
  76. concurim/samples/condition-mailbox.concurim +0 -0
  77. concurim/samples/condition_mailbox.py +47 -0
  78. concurim/samples/cpu-threads.concurim +0 -0
  79. concurim/samples/cpu_threads.py +33 -0
  80. concurim/samples/event-gate.concurim +0 -0
  81. concurim/samples/event_gate.py +37 -0
  82. concurim/samples/future-control.concurim +0 -0
  83. concurim/samples/future_control.py +42 -0
  84. concurim/samples/lineage-pipeline.concurim +0 -0
  85. concurim/samples/lineage_pipeline.py +70 -0
  86. concurim/samples/lock-contention.concurim +0 -0
  87. concurim/samples/lock-order.concurim +0 -0
  88. concurim/samples/lock_contention.py +40 -0
  89. concurim/samples/lock_order.py +43 -0
  90. concurim/samples/manifest.json +298 -0
  91. concurim/samples/plain-queues.concurim +0 -0
  92. concurim/samples/plain-threads.concurim +0 -0
  93. concurim/samples/plain_pipeline.py +61 -0
  94. concurim/samples/process-pool-map.concurim +0 -0
  95. concurim/samples/process-pool.concurim +0 -0
  96. concurim/samples/process-share.concurim +0 -0
  97. concurim/samples/process_pool.py +31 -0
  98. concurim/samples/process_pool_map.py +27 -0
  99. concurim/samples/process_share.py +33 -0
  100. concurim/samples/producer-consumer.concurim +0 -0
  101. concurim/samples/semaphore.concurim +0 -0
  102. concurim/samples/semaphore_limit.py +34 -0
  103. concurim/samples/shutdown.concurim +0 -0
  104. concurim/samples/shutdown.py +44 -0
  105. concurim/samples/task-group.concurim +0 -0
  106. concurim/samples/task_group.py +30 -0
  107. concurim/samples/thread-pool.concurim +0 -0
  108. concurim/samples/thread_pool.py +30 -0
  109. concurim/samples/threaded_pipeline.py +72 -0
  110. concurim/schema/__init__.py +0 -0
  111. concurim/schema/event.schema.json +42 -0
  112. concurim/viewer/__init__.py +0 -0
  113. concurim/viewer/api.py +256 -0
  114. concurim/viewer/exports.py +197 -0
  115. concurim/viewer/interpreters.py +86 -0
  116. concurim/viewer/plan.py +52 -0
  117. concurim/viewer/procs.py +62 -0
  118. concurim/viewer/runs.py +273 -0
  119. concurim/viewer/server.py +197 -0
  120. concurim/viewer/session.py +43 -0
  121. concurim/viewer/static/assets/Viewer-B5HA3A02.js +8 -0
  122. concurim/viewer/static/assets/Viewer-DLioOiRN.css +1 -0
  123. concurim/viewer/static/assets/index-BHRQ-DHX.css +1 -0
  124. concurim/viewer/static/assets/index-Ch-8w8yu.js +11 -0
  125. concurim/viewer/static/assets/rolldown-runtime-WkwZ5CU-.js +1 -0
  126. concurim/viewer/static/index.html +16 -0
  127. concurim/viewer/static/logo.png +0 -0
  128. concurim/viewer/workspaces.py +144 -0
  129. concurim-0.1.0.dist-info/METADATA +95 -0
  130. concurim-0.1.0.dist-info/RECORD +133 -0
  131. concurim-0.1.0.dist-info/WHEEL +4 -0
  132. concurim-0.1.0.dist-info/entry_points.txt +2 -0
  133. concurim-0.1.0.dist-info/licenses/LICENSE +21 -0
concurim/__init__.py ADDED
@@ -0,0 +1,72 @@
1
+ """Concurim: see how Python runs together."""
2
+
3
+ from __future__ import annotations
4
+
5
+ __version__ = "0.1.0"
6
+
7
+ from concurim.config import RecorderConfig
8
+ from concurim.instrumentation.aio_events import (
9
+ TracedAsyncCondition,
10
+ TracedAsyncEvent,
11
+ )
12
+ from concurim.instrumentation.aio_primitives import (
13
+ TracedAsyncBoundedSemaphore,
14
+ TracedAsyncLock,
15
+ TracedAsyncQueue,
16
+ TracedAsyncSemaphore,
17
+ )
18
+ from concurim.instrumentation.barriers import (
19
+ TracedAsyncBarrier,
20
+ TracedBarrier,
21
+ )
22
+ from concurim.instrumentation.executors import TracedThreadPoolExecutor
23
+ from concurim.instrumentation.mp_pool import TracedPool
24
+ from concurim.instrumentation.mp_process import TracedProcess
25
+ from concurim.instrumentation.process_pool import TracedProcessPoolExecutor
26
+ from concurim.instrumentation.queues import QueueItem, TracedQueue
27
+ from concurim.instrumentation.sync import (
28
+ TracedBoundedSemaphore,
29
+ TracedLock,
30
+ TracedRLock,
31
+ TracedSemaphore,
32
+ )
33
+ from concurim.instrumentation.sync_events import (
34
+ TracedCondition,
35
+ TracedEvent,
36
+ )
37
+ from concurim.instrumentation.threads import TracedThread
38
+ from concurim.instrumentation.work import work
39
+ from concurim.recorder.lifecycle import active_recorder, record
40
+ from concurim.recorder.serialization import load_trace
41
+ from concurim.replay.engine import build_replay
42
+
43
+ __all__ = [
44
+ "QueueItem",
45
+ "RecorderConfig",
46
+ "TracedAsyncBarrier",
47
+ "TracedAsyncBoundedSemaphore",
48
+ "TracedAsyncCondition",
49
+ "TracedAsyncEvent",
50
+ "TracedAsyncLock",
51
+ "TracedAsyncQueue",
52
+ "TracedAsyncSemaphore",
53
+ "TracedBarrier",
54
+ "TracedBoundedSemaphore",
55
+ "TracedCondition",
56
+ "TracedEvent",
57
+ "TracedLock",
58
+ "TracedQueue",
59
+ "TracedRLock",
60
+ "TracedSemaphore",
61
+ "TracedThread",
62
+ "TracedPool",
63
+ "TracedProcess",
64
+ "TracedProcessPoolExecutor",
65
+ "TracedThreadPoolExecutor",
66
+ "__version__",
67
+ "active_recorder",
68
+ "build_replay",
69
+ "load_trace",
70
+ "record",
71
+ "work",
72
+ ]
concurim/__main__.py ADDED
@@ -0,0 +1,7 @@
1
+ """Allow ``python -m concurim``."""
2
+
3
+ import sys
4
+
5
+ from concurim.cli import main
6
+
7
+ sys.exit(main())
concurim/cli.py ADDED
@@ -0,0 +1,262 @@
1
+ """Command-line interface: record, view, demo, inspect."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import os
7
+ import sys
8
+ import tempfile
9
+ import webbrowser
10
+ from collections.abc import Sequence
11
+ from pathlib import Path
12
+
13
+ from concurim import __version__
14
+ from concurim.config import RecorderConfig
15
+ from concurim.export import cli as export_cli
16
+ from concurim.instrumentation.auto import LEVELS
17
+ from concurim.program_check import ProgramError
18
+ from concurim.recorder.events import TraceFormatError
19
+ from concurim.recorder.serialization import Trace, load_trace
20
+ from concurim.samples import sample_file
21
+
22
+ SHARING_WARNING = (
23
+ "Note: traces can contain file paths, thread names and (if enabled) "
24
+ "source code or payload previews. Review a trace before sharing it."
25
+ )
26
+
27
+
28
+ def build_parser() -> argparse.ArgumentParser:
29
+ parser = argparse.ArgumentParser(
30
+ prog="concurim", description="See how Python runs together."
31
+ )
32
+ parser.add_argument(
33
+ "--version", action="version", version=f"concurim {__version__}"
34
+ )
35
+ _add_server_options(parser)
36
+ parser.set_defaults(func=_cmd_home)
37
+ sub = parser.add_subparsers(dest="command")
38
+
39
+ rec = sub.add_parser(
40
+ "record",
41
+ help="run a script and record instrumented activity",
42
+ description="Run SCRIPT and record activity on Concurim-instrumented "
43
+ "objects. Options must come before SCRIPT; everything after SCRIPT "
44
+ "is passed to it unchanged.",
45
+ )
46
+ rec.add_argument(
47
+ "-o",
48
+ "--output",
49
+ type=Path,
50
+ help="trace path (default: <script>.concurim)",
51
+ )
52
+ rec.add_argument(
53
+ "--include-source",
54
+ action="store_true",
55
+ help="embed project source files in the trace",
56
+ )
57
+ rec.add_argument(
58
+ "--preview",
59
+ action="store_true",
60
+ help="record bounded previews of primitive payloads",
61
+ )
62
+ rec.add_argument(
63
+ "--auto",
64
+ choices=LEVELS,
65
+ default="threads",
66
+ help="automatic instrumentation: none (only Concurim objects), "
67
+ "threads (default), queues (threads + queue.Queue)",
68
+ )
69
+ rec.add_argument("--max-events", type=int, default=1_000_000)
70
+ rec.add_argument(
71
+ "--light",
72
+ action="store_true",
73
+ help="lightweight mode: no source locations",
74
+ )
75
+ rec.add_argument(
76
+ "--open",
77
+ action="store_true",
78
+ help="open the viewer when the recording finishes",
79
+ )
80
+ _add_server_options(rec)
81
+ rec.add_argument("script", type=Path)
82
+ rec.add_argument("script_args", nargs=argparse.REMAINDER)
83
+ rec.set_defaults(func=_cmd_record)
84
+
85
+ for name, func, help_ in (
86
+ ("view", _cmd_view, "open a recorded trace"),
87
+ ("compare", _cmd_compare, "show two recordings side by side"),
88
+ ("demo", _cmd_demo, "record and view a demo"),
89
+ ):
90
+ cmd = sub.add_parser(name, help=help_)
91
+ if name == "view":
92
+ cmd.add_argument("trace", type=Path)
93
+ if name == "compare":
94
+ cmd.add_argument("first", type=Path)
95
+ cmd.add_argument("second", type=Path)
96
+ _add_server_options(cmd)
97
+ cmd.set_defaults(func=func)
98
+
99
+ export_cli.add_parser(sub)
100
+ insp = sub.add_parser("inspect", help="print trace metadata")
101
+ insp.add_argument("trace", type=Path)
102
+ insp.set_defaults(func=_cmd_inspect)
103
+ return parser
104
+
105
+
106
+ def _add_server_options(parser: argparse.ArgumentParser) -> None:
107
+ parser.add_argument(
108
+ "--port",
109
+ type=int,
110
+ default=0,
111
+ help="port to serve on (default: any free port)",
112
+ )
113
+ parser.add_argument("--no-browser", action="store_true")
114
+
115
+
116
+ def main(argv: Sequence[str] | None = None) -> int:
117
+ """CLI entry point; returns the process exit status."""
118
+ args = build_parser().parse_args(argv)
119
+ try:
120
+ return args.func(args)
121
+ except (TraceFormatError, FileNotFoundError, ProgramError) as error:
122
+ print(f"concurim: error: {error}", file=sys.stderr)
123
+ return 2
124
+
125
+
126
+ def _cmd_record(args: argparse.Namespace) -> int:
127
+ from concurim.runner import run_recorded
128
+
129
+ script: Path = args.script
130
+ if not script.is_file():
131
+ raise FileNotFoundError(f"script not found: {script}")
132
+ output = args.output or Path(f"{script.stem}.concurim")
133
+ config = RecorderConfig(
134
+ max_events=args.max_events,
135
+ include_source=args.include_source,
136
+ preview=args.preview,
137
+ capture_source_locations=not args.light,
138
+ auto=args.auto,
139
+ # Paths are shown relative to the script, wherever it is run from.
140
+ source_root=str(script.resolve().parent),
141
+ )
142
+ code, count = run_recorded(
143
+ script, args.script_args, output, config, view_on_stop=args.open
144
+ )
145
+ print(f"concurim: recorded {count} events to {output}", file=sys.stderr)
146
+ print(f"concurim: view it with: concurim view {output}", file=sys.stderr)
147
+ print(SHARING_WARNING, file=sys.stderr)
148
+ stuck = _still_blocked(output)
149
+ if args.open and output.is_file():
150
+ _serve(
151
+ load_trace(output),
152
+ args.port,
153
+ not args.no_browser,
154
+ output.name,
155
+ )
156
+ if stuck:
157
+ # Non-daemon threads are still blocked. Returning would hang in
158
+ # interpreter shutdown and never give the terminal back.
159
+ os._exit(code)
160
+ return code
161
+
162
+
163
+ def _serve(
164
+ trace: Trace | None,
165
+ port: int,
166
+ open_browser: bool,
167
+ label: str = "trace",
168
+ compare: tuple[tuple[Trace, str], ...] = (),
169
+ ) -> int:
170
+ from concurim.viewer.server import ViewerServer
171
+
172
+ if not (Path(__file__).parent / "viewer/static/index.html").is_file():
173
+ raise FileNotFoundError("viewer assets missing (run scripts/build.sh)")
174
+ server = ViewerServer(trace, port, label=label, compare=compare)
175
+ url = server.url + ("?compare=1" if compare else "")
176
+ print(f"concurim: viewer at {url} (Ctrl-C to stop)")
177
+ if open_browser:
178
+ webbrowser.open(url)
179
+ try:
180
+ server.serve_forever()
181
+ except KeyboardInterrupt:
182
+ print("\nconcurim: stopped")
183
+ finally:
184
+ server.server_close()
185
+ return 0
186
+
187
+
188
+ def _cmd_home(args: argparse.Namespace) -> int:
189
+ return _serve(None, args.port, not args.no_browser)
190
+
191
+
192
+ def _cmd_view(args: argparse.Namespace) -> int:
193
+ return _serve(
194
+ load_trace(args.trace), args.port, not args.no_browser, args.trace.name
195
+ )
196
+
197
+
198
+ def _cmd_compare(args: argparse.Namespace) -> int:
199
+ pair = tuple((load_trace(p), p.name) for p in (args.first, args.second))
200
+ return _serve(None, args.port, not args.no_browser, compare=pair)
201
+
202
+
203
+ def _cmd_demo(args: argparse.Namespace) -> int:
204
+ from concurim.runner import run_recorded
205
+
206
+ script = sample_file("threaded_pipeline.py")
207
+ out = Path(tempfile.mkdtemp(prefix="concurim-demo-")) / "demo.concurim"
208
+ # The bundled example is trusted, so its source is embedded.
209
+ config = RecorderConfig(include_source=True, source_root=str(script.parent))
210
+ code, count = run_recorded(script, [], out, config)
211
+ print(f"concurim: recorded {count} events from {script.name}")
212
+ if code != 0:
213
+ return code
214
+ return _serve(load_trace(out), args.port, not args.no_browser, script.name)
215
+
216
+
217
+ def _still_blocked(path: Path) -> bool:
218
+ """Whether the written trace says other threads were still running."""
219
+ if not path.is_file():
220
+ return False
221
+ try:
222
+ program = load_trace(path).metadata.get("program") or {}
223
+ except (TraceFormatError, OSError):
224
+ return False
225
+ return bool(program.get("still_blocked"))
226
+
227
+
228
+ def _cmd_inspect(args: argparse.Namespace) -> int:
229
+ trace = load_trace(args.trace)
230
+ meta = trace.metadata
231
+ env = meta.get("environment", {})
232
+ print(f"trace: {args.trace}")
233
+ print(
234
+ f"concurim: {meta.get('concurim_version')} "
235
+ f"(format {meta.get('format_version')})"
236
+ )
237
+ print(f"recorded: {meta.get('created_at_utc')}")
238
+ print(f"duration: {meta.get('duration_ns', 0) / 1e9:.3f}s")
239
+ print(f"events: {len(trace.events)}")
240
+ print(
241
+ f"python: {env.get('implementation')} "
242
+ f"{env.get('python_version')} on {env.get('platform')} "
243
+ f"(free-threaded build: {env.get('free_threaded_build')}, "
244
+ f"GIL enabled: {env.get('gil_enabled')})"
245
+ )
246
+ print(f"sources: {len(trace.sources)} embedded")
247
+ program = meta.get("program") or {}
248
+ if program.get("outcome"):
249
+ print(f"outcome: {program['outcome']}")
250
+ if program.get("still_blocked"):
251
+ print("WARNING: stopped while other threads were still running")
252
+ if meta.get("truncated"):
253
+ print(
254
+ f"WARNING: truncated, {meta.get('dropped_events')} "
255
+ "events dropped (buffer limit)"
256
+ )
257
+ coverage = meta.get("coverage", {})
258
+ for key in ("observed", "not_observed"):
259
+ print(f"{key.replace('_', ' ')}:")
260
+ for line in coverage.get(key, []):
261
+ print(f" - {line}")
262
+ return 0
concurim/config.py ADDED
@@ -0,0 +1,81 @@
1
+ """Recorder configuration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from dataclasses import dataclass
7
+
8
+ # Automatic instrumentation levels; each includes the ones before it.
9
+ LEVELS = ("none", "threads", "queues", "sync", "asyncio")
10
+
11
+
12
+ @dataclass(frozen=True, slots=True)
13
+ class RecorderConfig:
14
+ """Options controlling what is recorded.
15
+
16
+ Attributes:
17
+ max_events: Hard cap on buffered events. When reached, *new* events
18
+ are dropped (the earliest events keep the trace coherent) and the
19
+ trace is marked truncated.
20
+ include_source: Embed the text of referenced source files in the
21
+ trace. Off by default because sources may be sensitive.
22
+ capture_source_locations: Record file/line/function per event. Turn
23
+ off for the lightest recording mode.
24
+ preview: Record a bounded preview of primitive payloads. Off by
25
+ default because payloads may be sensitive.
26
+ preview_max_chars: Maximum characters kept in a preview.
27
+ redact_patterns: Regexes replaced by ``[redacted]`` in previews.
28
+ auto: Automatic instrumentation level while recording, each level
29
+ including the previous: ``"none"`` (only Concurim-managed
30
+ objects), ``"threads"`` (every ``threading.Thread``),
31
+ ``"queues"`` (``queue.Queue`` objects created during the run),
32
+ ``"sync"`` (locks, semaphores and ``ThreadPoolExecutor``
33
+ created by your code) or ``"asyncio"`` (tasks and asyncio
34
+ queues, locks and semaphores).
35
+ source_root: Directory that recorded paths are shown relative to,
36
+ and the only place source files may be embedded from. Defaults
37
+ to the current directory when recording starts.
38
+ sample_interval_ms: How often (milliseconds) a background thread
39
+ samples which line each worker is running, so the code view can
40
+ follow the work between instrumented calls. Only used with
41
+ ``include_source``; ``0`` turns sampling off. Sampling is
42
+ approximate and slightly slows the program.
43
+ """
44
+
45
+ max_events: int = 1_000_000
46
+ include_source: bool = False
47
+ capture_source_locations: bool = True
48
+ preview: bool = False
49
+ preview_max_chars: int = 48
50
+ redact_patterns: tuple[str, ...] = ()
51
+ source_root: str | None = None
52
+ auto: str = "none"
53
+ sample_interval_ms: float = 5.0
54
+
55
+ def __post_init__(self) -> None:
56
+ if self.auto not in LEVELS:
57
+ raise ValueError(f"unknown auto level {self.auto!r}")
58
+ if self.max_events < 1:
59
+ raise ValueError("max_events must be positive")
60
+ if not 0 <= self.preview_max_chars <= 256:
61
+ raise ValueError("preview_max_chars must be in [0, 256]")
62
+ if not 0 <= self.sample_interval_ms <= 10_000:
63
+ raise ValueError("sample_interval_ms must be in [0, 10000]")
64
+ for pattern in self.redact_patterns:
65
+ re.compile(pattern)
66
+
67
+ @classmethod
68
+ def light(cls) -> RecorderConfig:
69
+ """Lightweight mode: no source locations, no previews."""
70
+ return cls(capture_source_locations=False)
71
+
72
+ def public_dict(self) -> dict[str, object]:
73
+ """Config values safe to store in trace metadata."""
74
+ return {
75
+ "max_events": self.max_events,
76
+ "include_source": self.include_source,
77
+ "capture_source_locations": self.capture_source_locations,
78
+ "preview": self.preview,
79
+ "auto": self.auto,
80
+ "sample_interval_ms": self.sample_interval_ms,
81
+ }
@@ -0,0 +1 @@
1
+ """Render recordings as video, GIF or PNG frames (see ``video.py``)."""
concurim/export/cdp.py ADDED
@@ -0,0 +1,224 @@
1
+ """A tiny Chrome DevTools Protocol client over a pipe (standard library).
2
+
3
+ Chrome-family browsers started with ``--remote-debugging-pipe`` read JSON
4
+ commands from file descriptor 3 and answer on file descriptor 4, each
5
+ message ending in a NUL byte. That is all the exporter needs, so no browser
6
+ automation package is required. POSIX only (macOS and Linux).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import base64
12
+ import contextlib
13
+ import json
14
+ import os
15
+ import select
16
+ import shutil
17
+ import signal
18
+ import sys
19
+ import tempfile
20
+ from pathlib import Path
21
+ from typing import Any
22
+
23
+ BROWSER_ENV = "CONCURIM_BROWSER"
24
+ _MAC_APPS = (
25
+ "Google Chrome.app/Contents/MacOS/Google Chrome",
26
+ "Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
27
+ "Chromium.app/Contents/MacOS/Chromium",
28
+ "Brave Browser.app/Contents/MacOS/Brave Browser",
29
+ )
30
+ _PATH_NAMES = (
31
+ "google-chrome",
32
+ "google-chrome-stable",
33
+ "chromium",
34
+ "chromium-browser",
35
+ "microsoft-edge",
36
+ "microsoft-edge-stable",
37
+ "brave-browser",
38
+ )
39
+ _TIMEOUT_S = 30.0
40
+
41
+
42
+ class ExportError(Exception):
43
+ """A problem the user can fix (missing browser, missing ffmpeg, ...)."""
44
+
45
+
46
+ def find_browser() -> str:
47
+ """Path of a Chrome-family browser.
48
+
49
+ ``$CONCURIM_BROWSER`` wins; otherwise common install locations and
50
+ ``PATH`` are searched. Raises ``ExportError`` with advice if none exists.
51
+ """
52
+ wanted = os.environ.get(BROWSER_ENV)
53
+ if wanted:
54
+ if shutil.which(wanted) or Path(wanted).is_file():
55
+ return shutil.which(wanted) or wanted
56
+ raise ExportError(f"{BROWSER_ENV}={wanted!r} is not an executable")
57
+ for app in _MAC_APPS:
58
+ for base in (Path("/Applications"), Path.home() / "Applications"):
59
+ if (base / app).is_file():
60
+ return str(base / app)
61
+ for name in _PATH_NAMES:
62
+ found = shutil.which(name)
63
+ if found:
64
+ return found
65
+ raise ExportError(
66
+ "no Chrome-family browser found. Install Google Chrome, Chromium "
67
+ f"or Microsoft Edge, or set {BROWSER_ENV} to its executable."
68
+ )
69
+
70
+
71
+ def _require_posix() -> None:
72
+ """Raise if video export cannot run on this operating system."""
73
+ if sys.platform == "win32":
74
+ raise ExportError("video export needs macOS or Linux")
75
+
76
+
77
+ def _spawn_browser(
78
+ executable: str,
79
+ args: list[str],
80
+ to_browser_r: int,
81
+ from_browser_w: int,
82
+ ) -> int:
83
+ """Start ``executable`` with the DevTools pipe on fds 3 and 4.
84
+
85
+ ``to_browser_r`` becomes fd 3 and ``from_browser_w`` becomes fd 4.
86
+ Returns the child pid. macOS and Linux only. The platform test is
87
+ inline so the type checker can see ``os.posix_spawn`` on Unix.
88
+ """
89
+ if sys.platform == "win32":
90
+ raise ExportError("video export needs macOS or Linux")
91
+ actions = [
92
+ (os.POSIX_SPAWN_DUP2, to_browser_r, 3),
93
+ (os.POSIX_SPAWN_DUP2, from_browser_w, 4),
94
+ # Browser chatter is not the user's business.
95
+ (os.POSIX_SPAWN_OPEN, 1, os.devnull, os.O_WRONLY, 0),
96
+ (os.POSIX_SPAWN_OPEN, 2, os.devnull, os.O_WRONLY, 0),
97
+ ]
98
+ return os.posix_spawn(executable, args, os.environ, file_actions=actions)
99
+
100
+
101
+ class Browser:
102
+ """A headless browser with one page, driven over the DevTools pipe."""
103
+
104
+ def __init__(self, executable: str, width: int, height: int) -> None:
105
+ _require_posix()
106
+ self._profile = tempfile.mkdtemp(prefix="concurim-browser-")
107
+ self._next_id = 0
108
+ self._buffer = b""
109
+ to_browser_r, self._to_browser = os.pipe()
110
+ self._from_browser, from_browser_w = os.pipe()
111
+ args = [
112
+ executable,
113
+ "--headless=new",
114
+ "--remote-debugging-pipe",
115
+ f"--user-data-dir={self._profile}",
116
+ "--no-first-run",
117
+ "--no-default-browser-check",
118
+ "--hide-scrollbars",
119
+ "--force-device-scale-factor=1",
120
+ "--font-render-hinting=none",
121
+ "--disable-background-timer-throttling",
122
+ "--mute-audio",
123
+ f"--window-size={width},{height}",
124
+ "about:blank",
125
+ ]
126
+ self._pid = _spawn_browser(
127
+ executable, args, to_browser_r, from_browser_w
128
+ )
129
+ os.close(to_browser_r)
130
+ os.close(from_browser_w)
131
+ self._session = self._open_page(width, height)
132
+
133
+ def _open_page(self, width: int, height: int) -> str:
134
+ target = self.call("Target.createTarget", {"url": "about:blank"})
135
+ attached = self.call(
136
+ "Target.attachToTarget",
137
+ {"targetId": target["targetId"], "flatten": True},
138
+ )
139
+ session = attached["sessionId"]
140
+ self.call(
141
+ "Emulation.setDeviceMetricsOverride",
142
+ {
143
+ "width": width,
144
+ "height": height,
145
+ "deviceScaleFactor": 1,
146
+ "mobile": False,
147
+ },
148
+ session,
149
+ )
150
+ self.call("Page.enable", session=session)
151
+ return session
152
+
153
+ def call(
154
+ self,
155
+ method: str,
156
+ params: dict[str, Any] | None = None,
157
+ session: str | None = None,
158
+ ) -> dict[str, Any]:
159
+ """Send one command and return its result (events are skipped)."""
160
+ self._next_id += 1
161
+ message: dict[str, Any] = {
162
+ "id": self._next_id,
163
+ "method": method,
164
+ "params": params or {},
165
+ }
166
+ if session:
167
+ message["sessionId"] = session
168
+ os.write(self._to_browser, json.dumps(message).encode() + b"\0")
169
+ while True:
170
+ reply = self._read()
171
+ if reply.get("id") != self._next_id:
172
+ continue
173
+ if "error" in reply:
174
+ raise ExportError(f"{method}: {reply['error']}")
175
+ return reply.get("result", {})
176
+
177
+ def _read(self) -> dict[str, Any]:
178
+ while b"\0" not in self._buffer:
179
+ ready, _, _ = select.select(
180
+ [self._from_browser], [], [], _TIMEOUT_S
181
+ )
182
+ chunk = os.read(self._from_browser, 1 << 16) if ready else b""
183
+ if not chunk:
184
+ raise ExportError("the browser stopped responding")
185
+ self._buffer += chunk
186
+ raw, self._buffer = self._buffer.split(b"\0", 1)
187
+ return json.loads(raw)
188
+
189
+ def page(
190
+ self, method: str, params: dict[str, Any] | None = None
191
+ ) -> dict[str, Any]:
192
+ """Send a command to the page."""
193
+ return self.call(method, params, self._session)
194
+
195
+ def evaluate(self, expression: str) -> Any:
196
+ """Run JavaScript in the page (awaiting promises); return the value."""
197
+ out = self.page(
198
+ "Runtime.evaluate",
199
+ {
200
+ "expression": expression,
201
+ "awaitPromise": True,
202
+ "returnByValue": True,
203
+ },
204
+ )
205
+ if "exceptionDetails" in out:
206
+ raise ExportError(f"page error: {out['exceptionDetails']}")
207
+ return out["result"].get("value")
208
+
209
+ def screenshot(self) -> bytes:
210
+ """The visible page as PNG bytes."""
211
+ out = self.page("Page.captureScreenshot", {"format": "png"})
212
+ return base64.b64decode(out["data"])
213
+
214
+ def close(self) -> None:
215
+ """Stop the browser and delete its temporary profile."""
216
+ for fd in (self._to_browser, self._from_browser):
217
+ with contextlib.suppress(OSError):
218
+ os.close(fd)
219
+ try:
220
+ os.kill(self._pid, signal.SIGTERM)
221
+ os.waitpid(self._pid, 0)
222
+ except (ProcessLookupError, ChildProcessError):
223
+ pass
224
+ shutil.rmtree(self._profile, ignore_errors=True)