davinci-resolve-mcp 2.69.3 → 2.70.0

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,542 @@
1
+ #!/usr/bin/env python3
2
+ """Stress a Resolve render session until it breaks, and record how it broke.
3
+
4
+ The capability differential (`scripts/headless_differential.py`) answers "can
5
+ headless do this at all". It cannot answer "does headless survive a real
6
+ delivery", because its render is two seconds of 640x360 H.264. This harness
7
+ answers the second question, on synthetic media, without needing production
8
+ assets:
9
+
10
+ # 20 ProRes renders in one headless session, 60s of DCP-like J2K each
11
+ python scripts/render_stress.py --mode headless --iterations 20 --duration 60
12
+
13
+ # the same run with a UI, for the comparison that makes it mean something
14
+ python scripts/render_stress.py --mode gui --iterations 20 --duration 60
15
+
16
+ # once production assets are available, same harness, real source
17
+ python scripts/render_stress.py --mode headless --source /path/to/dcp_video.mxf
18
+
19
+ The synthetic source is JPEG 2000 in MXF OP1A at 12 bits, 1998x1080 — which
20
+ Resolve identifies as `JPEG 2000 / MXF OP1A / 12 bit`, the same decode path a
21
+ DCP video track file takes. The render target is ProRes in QuickTime, because
22
+ **this Resolve offers ProRes in no MXF flavour** — `GetRenderCodecs` returns
23
+ ProRes only under `mov`, so "MXF to ProRes" means J2K-MXF in, ProRes-QuickTime
24
+ out.
25
+
26
+ What it is actually looking for:
27
+
28
+ - **Crashes at end of write.** Each iteration is bracketed by a snapshot of
29
+ `crash_archive.txt`, so a new crash block is captured with its stack rather
30
+ than being noticed later as "it died at some point".
31
+ - **Process death.** The driver runs outside Resolve and watches the PID, so a
32
+ hard exit is recorded as a result instead of hanging the run.
33
+ - **Leaks.** Resident memory is sampled through every render, so growth across
34
+ iterations is visible. A teardown bug and a slow leak both present as "it
35
+ crashes eventually"; the RSS curve separates them.
36
+ - **Degradation.** Per-iteration wall time is recorded for the same reason.
37
+
38
+ Outputs are deleted after each iteration is verified unless `--keep`, because
39
+ ProRes 422 HQ at this resolution is roughly 2 GB per minute.
40
+ """
41
+
42
+ from __future__ import annotations
43
+
44
+ import argparse
45
+ import json
46
+ import os
47
+ import shutil
48
+ import subprocess
49
+ import sys
50
+ import tempfile
51
+ import time
52
+ from datetime import datetime, timezone
53
+ from pathlib import Path
54
+ from typing import Any, Dict, List, Optional
55
+
56
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
57
+
58
+ from src.utils import resolve_runtime as rr # noqa: E402
59
+ from src.utils.project_cleanup import save_project_if_safe # noqa: E402
60
+
61
+ CRASH_ARCHIVE = Path(
62
+ os.path.expanduser(
63
+ "~/Library/Application Support/Blackmagic Design/DaVinci Resolve/crash_archive.txt"
64
+ )
65
+ )
66
+ RESOLVE_LOG = Path(
67
+ os.path.expanduser(
68
+ "~/Library/Application Support/Blackmagic Design/DaVinci Resolve/logs/ResolveDebug.txt"
69
+ )
70
+ )
71
+
72
+ DEFAULT_API = "/Library/Application Support/Blackmagic Design/DaVinci Resolve/Developer/Scripting"
73
+
74
+
75
+ # ── connection ───────────────────────────────────────────────────────────────
76
+
77
+
78
+ def connect(timeout: float = 420.0) -> Optional[Any]:
79
+ """Wait for a scriptable Resolve.
80
+
81
+ The timeout is generous on purpose. A clean headless boot is scriptable in
82
+ under 3 seconds, but a boot *after an unclean shutdown* took 2 minutes 5
83
+ seconds to start its script server — measured, after force-killing a wedged
84
+ instance. A 90-second wait concluded "headless will not start" fifteen
85
+ seconds before it did, which is exactly the false negative that leads a
86
+ supervisor to launch a second instance on top of a starting one.
87
+ """
88
+ os.environ.pop("DAVINCI_RESOLVE_BRIDGE", None)
89
+ api = os.environ.get("RESOLVE_SCRIPT_API", DEFAULT_API)
90
+ modules = str(Path(api) / "Modules")
91
+ if modules not in sys.path:
92
+ sys.path.append(modules)
93
+ os.environ.setdefault("RESOLVE_SCRIPT_API", api)
94
+ os.environ.setdefault(
95
+ "RESOLVE_SCRIPT_LIB",
96
+ "/Applications/DaVinci Resolve/DaVinci Resolve.app/Contents/Libraries/Fusion/fusionscript.so",
97
+ )
98
+ import DaVinciResolveScript as dvr
99
+
100
+ deadline = time.monotonic() + timeout
101
+ while time.monotonic() < deadline:
102
+ resolve = dvr.scriptapp("Resolve")
103
+ if resolve is not None:
104
+ try:
105
+ pm = resolve.GetProjectManager()
106
+ if pm is not None and pm.GetProjectListInCurrentFolder() is not None:
107
+ return resolve
108
+ except Exception:
109
+ pass
110
+ time.sleep(1.0)
111
+ return None
112
+
113
+
114
+ def resolve_pid() -> Optional[int]:
115
+ try:
116
+ out = subprocess.run(["ps", "-Ao", "pid=,command="], capture_output=True, text=True, timeout=10)
117
+ for line in out.stdout.splitlines():
118
+ if any(p in line for p in rr.RESOLVE_PROCESS_PATTERNS):
119
+ return int(line.split()[0])
120
+ except Exception:
121
+ pass
122
+ return None
123
+
124
+
125
+ def rss_mb(pid: Optional[int]) -> Optional[float]:
126
+ if pid is None:
127
+ return None
128
+ try:
129
+ out = subprocess.run(["ps", "-o", "rss=", "-p", str(pid)], capture_output=True, text=True, timeout=5)
130
+ return round(int(out.stdout.strip()) / 1024, 1)
131
+ except Exception:
132
+ return None
133
+
134
+
135
+ # ── crash capture ────────────────────────────────────────────────────────────
136
+
137
+
138
+ class CrashWatch:
139
+ """Bracket an operation and capture whatever Resolve appended to its archive.
140
+
141
+ Resolve appends every crash stack to one growing file with no timestamps, so
142
+ "did this iteration crash" is only answerable by remembering where the file
143
+ ended before it started. Reading the file afterwards and looking for
144
+ something that seems recent is guesswork.
145
+ """
146
+
147
+ def __init__(self) -> None:
148
+ self.offset = self._size()
149
+
150
+ @staticmethod
151
+ def _size() -> int:
152
+ try:
153
+ return CRASH_ARCHIVE.stat().st_size
154
+ except OSError:
155
+ return 0
156
+
157
+ def take(self) -> Optional[str]:
158
+ """New crash text since the last call, or None."""
159
+ size = self._size()
160
+ if size <= self.offset:
161
+ self.offset = size # rotation/truncation resets the mark
162
+ return None
163
+ try:
164
+ with CRASH_ARCHIVE.open("r", errors="replace") as handle:
165
+ handle.seek(self.offset)
166
+ text = handle.read()
167
+ except OSError:
168
+ return None
169
+ self.offset = size
170
+ return text or None
171
+
172
+
173
+ def log_tail(lines: int = 80) -> str:
174
+ try:
175
+ content = RESOLVE_LOG.read_text(errors="replace").splitlines()
176
+ except OSError:
177
+ return ""
178
+ return "\n".join(content[-lines:])
179
+
180
+
181
+ # ── source media ─────────────────────────────────────────────────────────────
182
+
183
+
184
+ def build_j2k_source(work: Path, seconds: int, width: int, height: int, fps: int) -> Path:
185
+ """DCP-like JPEG 2000 in MXF OP1A, 12-bit.
186
+
187
+ Kept short and repeated on the timeline rather than generated at full length:
188
+ the decode work per frame is identical either way, and a 5-minute J2K source
189
+ is several gigabytes of scratch for no extra signal.
190
+ """
191
+ out = work / "j2k_source.mxf"
192
+ subprocess.run(
193
+ [
194
+ "ffmpeg", "-hide_banner", "-loglevel", "error",
195
+ "-f", "lavfi", "-i", f"testsrc2=size={width}x{height}:rate={fps}:duration={seconds}",
196
+ "-c:v", "jpeg2000", "-pix_fmt", "xyz12le", "-q:v", "1",
197
+ "-f", "mxf", str(out), "-y",
198
+ ],
199
+ check=True, timeout=900,
200
+ )
201
+ return out
202
+
203
+
204
+ # ── the run ──────────────────────────────────────────────────────────────────
205
+
206
+
207
+ class StressRun:
208
+ def __init__(self, args: argparse.Namespace) -> None:
209
+ self.args = args
210
+ self.work = Path(tempfile.mkdtemp(prefix="render_stress_"))
211
+ self.out_dir = Path(args.out) if args.out else (self.work / "renders")
212
+ self.out_dir.mkdir(parents=True, exist_ok=True)
213
+ self.project_name = f"RENDER_STRESS_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
214
+ self.crash = CrashWatch()
215
+ self.iterations: List[Dict[str, Any]] = []
216
+ self.resolve: Any = None
217
+ self.pid: Optional[int] = None
218
+ self.source: Optional[Path] = None
219
+ self.incumbent: Optional[str] = None
220
+
221
+ # -- setup ---------------------------------------------------------------
222
+
223
+ def attach(self) -> bool:
224
+ self.resolve = connect()
225
+ self.pid = resolve_pid()
226
+ return self.resolve is not None
227
+
228
+ def build_project(self) -> Dict[str, Any]:
229
+ pm = self.resolve.GetProjectManager()
230
+ incumbent = pm.GetCurrentProject()
231
+ self.incumbent = incumbent.GetName() if incumbent else None
232
+ # Guarded: SaveProject BLOCKS FOREVER headless on the never-saved
233
+ # 'Untitled Project', which is exactly the project a fresh headless
234
+ # boot is sitting on. This call hung two stress runs before it was
235
+ # traced.
236
+ save_project_if_safe(pm)
237
+ if not pm.CreateProject(self.project_name):
238
+ raise RuntimeError(f"could not create {self.project_name}")
239
+ pm.LoadProject(self.project_name)
240
+ project = pm.GetCurrentProject()
241
+
242
+ if self.args.source:
243
+ self.source = Path(self.args.source).expanduser()
244
+ if not self.source.exists():
245
+ raise RuntimeError(f"--source not found: {self.source}")
246
+ else:
247
+ self.source = build_j2k_source(
248
+ self.work, self.args.clip_seconds, self.args.width, self.args.height, self.args.fps
249
+ )
250
+
251
+ storage = self.resolve.GetMediaStorage()
252
+ clips = storage.AddItemListToMediaPool([str(self.source)]) or []
253
+ if not clips:
254
+ raise RuntimeError(f"Resolve would not import {self.source}")
255
+ clip = clips[0]
256
+ pool = project.GetMediaPool()
257
+
258
+ # Repeat the clip until the timeline reaches the requested duration. The
259
+ # decode path is per-frame, so N copies of a short clip stresses it the
260
+ # same as one long clip while keeping the scratch bounded.
261
+ #
262
+ # NOT via CreateTimelineFromClips([clip] * n): that call **deduplicates
263
+ # repeated references to the same MediaPoolItem**. Passing one clip 15
264
+ # times built a 96-frame timeline — one copy — and reported no error, so
265
+ # every "60 second" render in the first run was actually 4 seconds. The
266
+ # harness looked like it was working and was testing almost nothing.
267
+ # AppendToTimeline with explicit clipInfo dicts appends each instance.
268
+ frames = int(clip.GetClipProperty("Frames") or 0)
269
+ fps = float(clip.GetClipProperty("FPS") or self.args.fps)
270
+ want = max(1, int(round(self.args.duration * fps / max(frames, 1))))
271
+ timeline = pool.CreateEmptyTimeline("STRESS_TL")
272
+ if timeline is None:
273
+ raise RuntimeError("could not create the stress timeline")
274
+ project.SetCurrentTimeline(timeline)
275
+ # endFrame is EXCLUSIVE-adjacent in this API: the last frame index is
276
+ # frames - 1, and passing `frames` appends nothing.
277
+ appended = pool.AppendToTimeline(
278
+ [{"mediaPoolItem": clip, "startFrame": 0, "endFrame": max(frames - 1, 0)}] * want
279
+ )
280
+ if not appended:
281
+ raise RuntimeError("AppendToTimeline appended nothing")
282
+ built = timeline.GetEndFrame() - timeline.GetStartFrame()
283
+ # Assert the timeline is the length that was asked for. The whole failure
284
+ # above was a silent short-build that no check would have caught.
285
+ if built < want * max(frames - 1, 1) * 0.9:
286
+ raise RuntimeError(
287
+ f"timeline short-built: wanted ~{want * frames} frames, got {built}"
288
+ )
289
+
290
+ codecs = project.GetRenderCodecs(self.args.format) or {}
291
+ # GetRenderCodecs returns {description: id} and rejects the description.
292
+ codec_id = codecs.get(self.args.codec) or self.args.codec
293
+ if codec_id not in codecs.values():
294
+ raise RuntimeError(
295
+ f"codec {self.args.codec!r} not offered for format {self.args.format!r}. "
296
+ f"Available: {sorted(codecs)}"
297
+ )
298
+ if not project.SetCurrentRenderFormatAndCodec(self.args.format, codec_id):
299
+ raise RuntimeError(f"SetCurrentRenderFormatAndCodec({self.args.format}, {codec_id}) refused")
300
+
301
+ return {
302
+ "project": self.project_name,
303
+ "source": str(self.source),
304
+ "source_codec": clip.GetClipProperty("Video Codec"),
305
+ "source_format": clip.GetClipProperty("Format"),
306
+ "source_resolution": clip.GetClipProperty("Resolution"),
307
+ "clips_on_timeline": want,
308
+ "timeline_frames": timeline.GetEndFrame() - timeline.GetStartFrame(),
309
+ "render_format": self.args.format,
310
+ "render_codec": codec_id,
311
+ }
312
+
313
+ # -- one iteration -------------------------------------------------------
314
+
315
+ def render_once(self, index: int) -> Dict[str, Any]:
316
+ record: Dict[str, Any] = {"iteration": index}
317
+ project = self.resolve.GetProjectManager().GetCurrentProject()
318
+ name = f"stress_{index:03d}"
319
+ project.SetRenderSettings({
320
+ "TargetDir": str(self.out_dir),
321
+ "CustomName": name,
322
+ "SelectAllFrames": True,
323
+ })
324
+ job = project.AddRenderJob()
325
+ record["job"] = bool(job)
326
+ if not job:
327
+ record["outcome"] = "add_render_job_failed"
328
+ return record
329
+
330
+ started = time.monotonic()
331
+ record["start_rendering"] = project.StartRendering(job, isInteractiveMode=False)
332
+ peak = 0.0
333
+ samples = 0
334
+ deadline = started + self.args.timeout
335
+ died = False
336
+ while time.monotonic() < deadline:
337
+ current = rss_mb(self.pid)
338
+ if current is None:
339
+ died = True
340
+ break
341
+ peak = max(peak, current)
342
+ samples += 1
343
+ try:
344
+ if not project.IsRenderingInProgress():
345
+ break
346
+ except Exception:
347
+ # A dead Resolve makes the handle raise rather than answer. That
348
+ # is the crash, not an error in the harness.
349
+ died = True
350
+ break
351
+ time.sleep(1.0)
352
+
353
+ record["elapsed_seconds"] = round(time.monotonic() - started, 1)
354
+ record["peak_rss_mb"] = peak or None
355
+ record["rss_samples"] = samples
356
+ produced = sorted(p for p in self.out_dir.glob(f"{name}*") if p.is_file())
357
+ record["output_files"] = [p.name for p in produced]
358
+ record["output_bytes"] = sum(p.stat().st_size for p in produced)
359
+
360
+ if died or resolve_pid() != self.pid:
361
+ record["outcome"] = "resolve_died"
362
+ else:
363
+ try:
364
+ status = project.GetRenderJobStatus(job) or {}
365
+ record["job_status"] = status.get("JobStatus")
366
+ record["outcome"] = "complete" if status.get("JobStatus") == "Complete" else "incomplete"
367
+ project.DeleteRenderJob(job)
368
+ except Exception as exc:
369
+ record["outcome"] = "resolve_died"
370
+ record["exception"] = f"{type(exc).__name__}: {exc}"
371
+
372
+ crash = self.crash.take()
373
+ if crash:
374
+ # The whole point of the harness: a stack, attached to the iteration
375
+ # that produced it, rather than a file someone reads tomorrow.
376
+ record["crash_captured"] = True
377
+ record["crash_text"] = crash[-6000:]
378
+ record["log_tail"] = log_tail()
379
+ if not self.args.keep:
380
+ for path in produced:
381
+ path.unlink(missing_ok=True)
382
+ return record
383
+
384
+ # -- recovery ------------------------------------------------------------
385
+
386
+ def recover(self) -> bool:
387
+ """Bring Resolve back after a death, so the run continues past the first one.
388
+
389
+ Stopping at the first crash would answer "does it crash" and nothing
390
+ else. Whether it crashes on iteration 2 or iteration 40, and whether the
391
+ RSS was climbing first, are the parts that point at a cause.
392
+ """
393
+ if not self.args.restart_on_crash:
394
+ return False
395
+ headless = self.args.mode == "headless"
396
+ command = rr.launch_command(headless=headless)
397
+ if command is None:
398
+ return False
399
+ subprocess.Popen(command, stdin=subprocess.DEVNULL,
400
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
401
+ if not self.attach():
402
+ return False
403
+ pm = self.resolve.GetProjectManager()
404
+ pm.LoadProject(self.project_name)
405
+ return pm.GetCurrentProject() is not None
406
+
407
+ # -- teardown ------------------------------------------------------------
408
+
409
+ def teardown(self) -> Dict[str, Any]:
410
+ """Hand the session back on a *named* project, never on the fallback.
411
+
412
+ Closing the stress project drops Resolve onto a fresh, never-saved
413
+ `Untitled Project`, which cannot be saved — so the next close or switch
414
+ raises a modal a human has to clear. This harness did exactly that to a
415
+ live GUI session: it finished cleanly and left the application prompting
416
+ with no way to cancel.
417
+
418
+ Loading the incumbent first closes the stress project silently (it was
419
+ just saved) and never rests on the fallback. Delete afterwards.
420
+ """
421
+ notes: Dict[str, Any] = {}
422
+ try:
423
+ pm = self.resolve.GetProjectManager()
424
+ save_project_if_safe(pm)
425
+ # Order matters, and both steps are load-bearing:
426
+ # 1. CloseProject releases the session's lock. Switching away with
427
+ # LoadProject does NOT — measured: DeleteProject then returns
428
+ # False forever, and retrying never helps. After a CloseProject
429
+ # the same delete succeeds on the first attempt.
430
+ # 2. LoadProject(incumbent) immediately afterwards, so the session
431
+ # does not rest on the never-saved `Untitled Project` fallback
432
+ # that CloseProject drops it onto — that fallback cannot be
433
+ # saved, so the next close or switch raises a modal a human has
434
+ # to clear. This harness did that to a live GUI session.
435
+ notes["closed"] = bool(pm.CloseProject(pm.GetCurrentProject()))
436
+ if self.incumbent and self.incumbent != self.project_name:
437
+ notes["restored_incumbent"] = bool(pm.LoadProject(self.incumbent))
438
+ notes["deleted"] = bool(pm.DeleteProject(self.project_name))
439
+ except Exception as exc:
440
+ notes["teardown_error"] = repr(exc)
441
+ shutil.rmtree(self.work, ignore_errors=True)
442
+ return notes
443
+
444
+
445
+ def main() -> int:
446
+ parser = argparse.ArgumentParser(
447
+ description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
448
+ )
449
+ parser.add_argument("--mode", choices=("headless", "gui", "current"), default="current",
450
+ help="which session to stress; 'current' uses whatever is running")
451
+ parser.add_argument("--iterations", type=int, default=10)
452
+ parser.add_argument("--duration", type=int, default=60, help="timeline length in seconds")
453
+ parser.add_argument("--clip-seconds", type=int, default=4, help="length of the generated source clip")
454
+ parser.add_argument("--width", type=int, default=1998)
455
+ parser.add_argument("--height", type=int, default=1080)
456
+ parser.add_argument("--fps", type=int, default=24)
457
+ parser.add_argument("--format", default="mov")
458
+ parser.add_argument("--codec", default="Apple ProRes 422 HQ")
459
+ parser.add_argument("--source", help="use real media instead of the synthetic J2K MXF")
460
+ parser.add_argument("--out", help="render output directory (default: temp, deleted after)")
461
+ parser.add_argument("--keep", action="store_true", help="keep rendered files")
462
+ parser.add_argument("--timeout", type=float, default=3600, help="per-render timeout in seconds")
463
+ parser.add_argument("--restart-on-crash", action="store_true",
464
+ help="relaunch Resolve and keep going after a death")
465
+ parser.add_argument("--report", type=Path, help="write the JSON report here")
466
+ args = parser.parse_args()
467
+
468
+ mode = rr.runtime_mode()
469
+ if args.mode != "current":
470
+ want_headless = args.mode == "headless"
471
+ if mode["running"] and bool(mode["headless"]) != want_headless:
472
+ print(f"REFUSE: a Resolve is already running in the other mode: {mode['command_lines']}",
473
+ file=sys.stderr)
474
+ return 1
475
+ if not mode["running"]:
476
+ command = rr.launch_command(headless=want_headless)
477
+ if command is None:
478
+ print("REFUSE: no Resolve install found.", file=sys.stderr)
479
+ return 3
480
+ print(f"starting: {' '.join(command)}")
481
+ subprocess.Popen(command, stdin=subprocess.DEVNULL,
482
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
483
+
484
+ run = StressRun(args)
485
+ if not run.attach():
486
+ print("REFUSE: no Resolve answered the scripting API.", file=sys.stderr)
487
+ return 4
488
+
489
+ observed = rr.runtime_mode()
490
+ print(f"stressing: headless={observed['headless']} pid={run.pid}", flush=True)
491
+ setup = run.build_project()
492
+ print(json.dumps(setup, indent=2), flush=True)
493
+
494
+ for index in range(1, args.iterations + 1):
495
+ record = run.render_once(index)
496
+ run.iterations.append(record)
497
+ flag = " CRASH" if record.get("crash_captured") else ""
498
+ print(f" [{index:3d}/{args.iterations}] {record.get('outcome'):16} "
499
+ f"{record.get('elapsed_seconds')}s peak_rss={record.get('peak_rss_mb')}MB "
500
+ f"bytes={record.get('output_bytes')}{flag}", flush=True)
501
+ if record["outcome"] == "resolve_died":
502
+ if not run.recover():
503
+ print(" Resolve died and was not recovered; stopping.")
504
+ break
505
+ print(" recovered; continuing", flush=True)
506
+
507
+ report = {
508
+ "metadata": {
509
+ "recorded_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
510
+ "headless": observed["headless"],
511
+ "iterations_requested": args.iterations,
512
+ "duration_seconds": args.duration,
513
+ "format": args.format,
514
+ "codec": args.codec,
515
+ },
516
+ "setup": setup,
517
+ "iterations": run.iterations,
518
+ "teardown": run.teardown(),
519
+ }
520
+ deaths = [i for i in run.iterations if i.get("outcome") == "resolve_died"]
521
+ crashes = [i for i in run.iterations if i.get("crash_captured")]
522
+ report["summary"] = {
523
+ "completed": sum(1 for i in run.iterations if i.get("outcome") == "complete"),
524
+ "deaths": len(deaths),
525
+ "crash_blocks_captured": len(crashes),
526
+ "first_death_iteration": deaths[0]["iteration"] if deaths else None,
527
+ "peak_rss_mb": max((i.get("peak_rss_mb") or 0) for i in run.iterations) if run.iterations else None,
528
+ "rss_first_to_last": [
529
+ run.iterations[0].get("peak_rss_mb") if run.iterations else None,
530
+ run.iterations[-1].get("peak_rss_mb") if run.iterations else None,
531
+ ],
532
+ }
533
+ print(json.dumps(report["summary"], indent=2))
534
+ if args.report:
535
+ args.report.parent.mkdir(parents=True, exist_ok=True)
536
+ args.report.write_text(json.dumps(report, indent=2, default=str), encoding="utf-8")
537
+ print(f"report: {args.report}")
538
+ return 0
539
+
540
+
541
+ if __name__ == "__main__":
542
+ raise SystemExit(main())