halib 0.2.30__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 (110) hide show
  1. halib/__init__.py +94 -0
  2. halib/common/__init__.py +0 -0
  3. halib/common/common.py +326 -0
  4. halib/common/rich_color.py +285 -0
  5. halib/common.py +151 -0
  6. halib/csvfile.py +48 -0
  7. halib/cuda.py +39 -0
  8. halib/dataset.py +209 -0
  9. halib/exp/__init__.py +0 -0
  10. halib/exp/core/__init__.py +0 -0
  11. halib/exp/core/base_config.py +167 -0
  12. halib/exp/core/base_exp.py +147 -0
  13. halib/exp/core/param_gen.py +170 -0
  14. halib/exp/core/wandb_op.py +117 -0
  15. halib/exp/data/__init__.py +0 -0
  16. halib/exp/data/dataclass_util.py +41 -0
  17. halib/exp/data/dataset.py +208 -0
  18. halib/exp/data/torchloader.py +165 -0
  19. halib/exp/perf/__init__.py +0 -0
  20. halib/exp/perf/flop_calc.py +190 -0
  21. halib/exp/perf/gpu_mon.py +58 -0
  22. halib/exp/perf/perfcalc.py +470 -0
  23. halib/exp/perf/perfmetrics.py +137 -0
  24. halib/exp/perf/perftb.py +778 -0
  25. halib/exp/perf/profiler.py +507 -0
  26. halib/exp/viz/__init__.py +0 -0
  27. halib/exp/viz/plot.py +754 -0
  28. halib/filesys.py +117 -0
  29. halib/filetype/__init__.py +0 -0
  30. halib/filetype/csvfile.py +192 -0
  31. halib/filetype/ipynb.py +61 -0
  32. halib/filetype/jsonfile.py +19 -0
  33. halib/filetype/textfile.py +12 -0
  34. halib/filetype/videofile.py +266 -0
  35. halib/filetype/yamlfile.py +87 -0
  36. halib/gdrive.py +179 -0
  37. halib/gdrive_mkdir.py +41 -0
  38. halib/gdrive_test.py +37 -0
  39. halib/jsonfile.py +22 -0
  40. halib/listop.py +13 -0
  41. halib/online/__init__.py +0 -0
  42. halib/online/gdrive.py +229 -0
  43. halib/online/gdrive_mkdir.py +53 -0
  44. halib/online/gdrive_test.py +50 -0
  45. halib/online/projectmake.py +131 -0
  46. halib/online/tele_noti.py +165 -0
  47. halib/plot.py +301 -0
  48. halib/projectmake.py +115 -0
  49. halib/research/__init__.py +0 -0
  50. halib/research/base_config.py +100 -0
  51. halib/research/base_exp.py +157 -0
  52. halib/research/benchquery.py +131 -0
  53. halib/research/core/__init__.py +0 -0
  54. halib/research/core/base_config.py +144 -0
  55. halib/research/core/base_exp.py +157 -0
  56. halib/research/core/param_gen.py +108 -0
  57. halib/research/core/wandb_op.py +117 -0
  58. halib/research/data/__init__.py +0 -0
  59. halib/research/data/dataclass_util.py +41 -0
  60. halib/research/data/dataset.py +208 -0
  61. halib/research/data/torchloader.py +165 -0
  62. halib/research/dataset.py +208 -0
  63. halib/research/flop_csv.py +34 -0
  64. halib/research/flops.py +156 -0
  65. halib/research/metrics.py +137 -0
  66. halib/research/mics.py +74 -0
  67. halib/research/params_gen.py +108 -0
  68. halib/research/perf/__init__.py +0 -0
  69. halib/research/perf/flop_calc.py +190 -0
  70. halib/research/perf/gpu_mon.py +58 -0
  71. halib/research/perf/perfcalc.py +363 -0
  72. halib/research/perf/perfmetrics.py +137 -0
  73. halib/research/perf/perftb.py +778 -0
  74. halib/research/perf/profiler.py +301 -0
  75. halib/research/perfcalc.py +361 -0
  76. halib/research/perftb.py +780 -0
  77. halib/research/plot.py +758 -0
  78. halib/research/profiler.py +300 -0
  79. halib/research/torchloader.py +162 -0
  80. halib/research/viz/__init__.py +0 -0
  81. halib/research/viz/plot.py +754 -0
  82. halib/research/wandb_op.py +116 -0
  83. halib/rich_color.py +285 -0
  84. halib/sys/__init__.py +0 -0
  85. halib/sys/cmd.py +8 -0
  86. halib/sys/filesys.py +124 -0
  87. halib/system/__init__.py +0 -0
  88. halib/system/_list_pc.csv +6 -0
  89. halib/system/cmd.py +8 -0
  90. halib/system/filesys.py +164 -0
  91. halib/system/path.py +106 -0
  92. halib/tele_noti.py +166 -0
  93. halib/textfile.py +13 -0
  94. halib/torchloader.py +162 -0
  95. halib/utils/__init__.py +0 -0
  96. halib/utils/dataclass_util.py +40 -0
  97. halib/utils/dict.py +317 -0
  98. halib/utils/dict_op.py +9 -0
  99. halib/utils/gpu_mon.py +58 -0
  100. halib/utils/list.py +17 -0
  101. halib/utils/listop.py +13 -0
  102. halib/utils/slack.py +86 -0
  103. halib/utils/tele_noti.py +166 -0
  104. halib/utils/video.py +82 -0
  105. halib/videofile.py +139 -0
  106. halib-0.2.30.dist-info/METADATA +237 -0
  107. halib-0.2.30.dist-info/RECORD +110 -0
  108. halib-0.2.30.dist-info/WHEEL +5 -0
  109. halib-0.2.30.dist-info/licenses/LICENSE.txt +17 -0
  110. halib-0.2.30.dist-info/top_level.txt +1 -0
@@ -0,0 +1,507 @@
1
+ import os
2
+ import time
3
+ import json
4
+ from pathlib import Path
5
+ from pprint import pprint
6
+ from threading import Lock
7
+ from functools import wraps
8
+ from loguru import logger
9
+
10
+ # Plotting libraries
11
+ from plotly.subplots import make_subplots
12
+ import plotly.graph_objects as go
13
+ import plotly.express as px # for dynamic color scales
14
+
15
+ from contextlib import contextmanager
16
+
17
+ from ...common.common import ConsoleLog
18
+ from ...system.path import *
19
+
20
+
21
+ # ==========================================
22
+ # 1. The Decorator
23
+ # ==========================================
24
+ def check_enabled(func):
25
+ """
26
+ Decorator to skip method execution if the profiler is disabled.
27
+
28
+ This acts as a 'guard clause' for the entire function. If the profiler
29
+ instance has 'enabled=False', the decorated function is not executed at all,
30
+ saving processing time and avoiding side effects.
31
+ """
32
+
33
+ @wraps(func)
34
+ def wrapper(self, *args, **kwargs):
35
+ # Gracefully handle cases where 'enabled' might not be set yet (default to True)
36
+ # print('check_enabled called')
37
+ if not getattr(self, "enabled", True):
38
+ # print('Profiler disabled, skipping function execution.')
39
+ return # Exit immediately, returning None
40
+ return func(self, *args, **kwargs)
41
+
42
+ return wrapper
43
+
44
+
45
+ # ==========================================
46
+ # 2. The Class
47
+ # ==========================================
48
+ class ContextScope:
49
+ """Helper to remember the current context name so you don't have to repeat it."""
50
+
51
+ def __init__(self, profiler, ctx_name):
52
+ self.profiler = profiler
53
+ self.ctx_name = ctx_name
54
+
55
+ @contextmanager
56
+ def step(self, step_name):
57
+ # Automatically passes the stored ctx_name + the new step_name
58
+ with self.profiler.measure(self.ctx_name, step_name):
59
+ yield
60
+
61
+
62
+ class zProfiler:
63
+ """A singleton profiler to measure execution time of contexts and steps.
64
+
65
+ Args:
66
+ interval_report (int): Frequency of periodic reports (0 to disable).
67
+ stop_to_view (bool): Pause execution to view reports if True (only in debug mode).
68
+ output_file (str): Path to save the profiling report.
69
+ report_format (str): Output format for reports ("json" or "csv").
70
+
71
+ Example (using context manager):
72
+ prof = zProfiler()
73
+ with prof.measure("my_context") as ctx:
74
+ with ctx.step("step1"):
75
+ time.sleep(0.1)
76
+ with ctx.step("step2"):
77
+ time.sleep(0.2)
78
+
79
+ Example (using raw methods):
80
+ prof = zProfiler()
81
+ prof.ctx_start("my_context")
82
+ prof.step_start("my_context", "step1")
83
+ time.sleep(0.1)
84
+ prof.step_end("my_context", "step1")
85
+ prof.ctx_end("my_context")
86
+
87
+
88
+ """
89
+
90
+ _instance = None
91
+ _lock = Lock()
92
+
93
+ def __new__(cls, *args, **kwargs):
94
+ with cls._lock:
95
+ if cls._instance is None:
96
+ cls._instance = super().__new__(cls)
97
+ return cls._instance
98
+
99
+ def __init__(self, enabled=None):
100
+ """
101
+ Args:
102
+ enabled (bool, optional):
103
+ - If True/False: Updates the enabled state immediately.
104
+ - If None: Keeps the current state (defaults to True on first init).
105
+ """
106
+ # 1. First-time initialization
107
+ if not hasattr(self, "_initialized"):
108
+ self.enabled = enabled if enabled is not None else True
109
+ self.time_dict = {}
110
+ self._initialized = True
111
+
112
+ # 2. If initialized, allow updating 'enabled' ONLY if explicitly passed
113
+ elif enabled is not None:
114
+ self.enabled = enabled
115
+
116
+ @check_enabled
117
+ def ctx_start(self, ctx_name="ctx_default"):
118
+ if not isinstance(ctx_name, str) or not ctx_name:
119
+ raise ValueError("ctx_name must be a non-empty string")
120
+ if ctx_name not in self.time_dict:
121
+ self.time_dict[ctx_name] = {
122
+ "start": time.perf_counter(),
123
+ "step_dict": {},
124
+ "report_count": 0,
125
+ }
126
+ self.time_dict[ctx_name]["report_count"] += 1
127
+
128
+ @check_enabled
129
+ def ctx_end(self, ctx_name="ctx_default", report_func=None):
130
+ if ctx_name not in self.time_dict:
131
+ return
132
+ self.time_dict[ctx_name]["end"] = time.perf_counter()
133
+ self.time_dict[ctx_name]["duration"] = (
134
+ self.time_dict[ctx_name]["end"] - self.time_dict[ctx_name]["start"]
135
+ )
136
+
137
+ @check_enabled
138
+ def step_start(self, ctx_name, step_name):
139
+ if not isinstance(step_name, str) or not step_name:
140
+ raise ValueError("step_name must be a non-empty string")
141
+ if ctx_name not in self.time_dict:
142
+ return
143
+ if step_name not in self.time_dict[ctx_name]["step_dict"]:
144
+ self.time_dict[ctx_name]["step_dict"][step_name] = []
145
+ self.time_dict[ctx_name]["step_dict"][step_name].append([time.perf_counter()])
146
+
147
+ @check_enabled
148
+ def step_end(self, ctx_name, step_name):
149
+ if (
150
+ ctx_name not in self.time_dict
151
+ or step_name not in self.time_dict[ctx_name]["step_dict"]
152
+ ):
153
+ return
154
+ self.time_dict[ctx_name]["step_dict"][step_name][-1].append(time.perf_counter())
155
+
156
+ @contextmanager
157
+ def measure(self, ctx_name, step_name=None):
158
+ if step_name is None:
159
+ # --- Context Mode ---
160
+ self.ctx_start(ctx_name)
161
+ try:
162
+ # Yield the helper object initialized with the current context name
163
+ yield ContextScope(self, ctx_name)
164
+ finally:
165
+ self.ctx_end(ctx_name)
166
+ else:
167
+ # --- Step Mode ---
168
+ self.step_start(ctx_name, step_name)
169
+ try:
170
+ yield
171
+ finally:
172
+ self.step_end(ctx_name, step_name)
173
+
174
+ def _step_dict_to_detail(self, ctx_step_dict):
175
+ """
176
+ 'ctx_step_dict': {
177
+ │ │ 'preprocess': [
178
+ │ │ │ [278090.947465806, 278090.960484853],
179
+ │ │ │ [278091.178424035, 278091.230944486],
180
+ │ │ 'infer': [
181
+ │ │ │ [278090.960490534, 278091.178424035],
182
+ │ │ │ [278091.230944486, 278091.251378469],
183
+ │ }
184
+ """
185
+ assert len(ctx_step_dict.keys()) > 0, (
186
+ "step_dict must have only one key (step_name) for detail."
187
+ )
188
+ normed_ctx_step_dict = {}
189
+ for step_name, time_list in ctx_step_dict.items():
190
+ if not isinstance(ctx_step_dict[step_name], list):
191
+ raise ValueError(f"Step data for {step_name} must be a list")
192
+ # step_name = list(ctx_step_dict.keys())[0] # ! debug
193
+ normed_time_ls = []
194
+ for idx, time_data in enumerate(time_list):
195
+ elapsed_time = -1
196
+ if len(time_data) == 2:
197
+ start, end = time_data[0], time_data[1]
198
+ elapsed_time = end - start
199
+ normed_time_ls.append((idx, elapsed_time)) # including step
200
+ normed_ctx_step_dict[step_name] = normed_time_ls
201
+ return normed_ctx_step_dict
202
+
203
+ def get_report_dict(self, with_detail=False):
204
+ report_dict = {}
205
+ for ctx_name, ctx_dict in self.time_dict.items():
206
+ report_dict[ctx_name] = {
207
+ "duration": ctx_dict.get("duration", 0.0),
208
+ "step_dict": {
209
+ "summary": {"avg_time": {}, "percent_time": {}},
210
+ "detail": {},
211
+ },
212
+ }
213
+
214
+ if with_detail:
215
+ report_dict[ctx_name]["step_dict"]["detail"] = (
216
+ self._step_dict_to_detail(ctx_dict["step_dict"])
217
+ )
218
+ avg_time_list = []
219
+ epsilon = 1e-5
220
+ for step_name, step_list in ctx_dict["step_dict"].items():
221
+ durations = []
222
+ try:
223
+ for time_data in step_list:
224
+ if len(time_data) != 2:
225
+ continue
226
+ start, end = time_data
227
+ durations.append(end - start)
228
+ except Exception as e:
229
+ logger.error(
230
+ f"Error processing step {step_name} in context {ctx_name}: {e}"
231
+ )
232
+ continue
233
+ if not durations:
234
+ continue
235
+ avg_time = sum(durations) / len(durations)
236
+ if avg_time < epsilon:
237
+ continue
238
+ avg_time_list.append((step_name, avg_time))
239
+ total_avg_time = (
240
+ sum(time for _, time in avg_time_list) or 1e-10
241
+ ) # Avoid division by zero
242
+ for step_name, avg_time in avg_time_list:
243
+ report_dict[ctx_name]["step_dict"]["summary"]["percent_time"][
244
+ f"per_{step_name}"
245
+ ] = (avg_time / total_avg_time) * 100.0
246
+ report_dict[ctx_name]["step_dict"]["summary"]["avg_time"][
247
+ f"avg_{step_name}"
248
+ ] = avg_time
249
+ report_dict[ctx_name]["step_dict"]["summary"]["total_avg_time"] = (
250
+ total_avg_time
251
+ )
252
+ report_dict[ctx_name]["step_dict"]["summary"] = dict(
253
+ sorted(report_dict[ctx_name]["step_dict"]["summary"].items())
254
+ )
255
+ return report_dict
256
+
257
+ def get_report_dataframes(self):
258
+ """
259
+ Returns two pandas DataFrames containing profiling data.
260
+
261
+ Returns:
262
+ tuple: (df_summary, df_detail)
263
+ - df_summary: Aggregated stats (Context, Step, Avg, %, Count)
264
+ - df_detail: Raw duration for every single iteration
265
+ """
266
+ try:
267
+ import pandas as pd
268
+ except ImportError:
269
+ logger.error(
270
+ "Pandas is required for get_pandas_dfs(). Please pip install pandas."
271
+ )
272
+ return None, None
273
+
274
+ # Get full data structure
275
+ data = self.get_report_dict(with_detail=True)
276
+
277
+ summary_rows = []
278
+ detail_rows = []
279
+
280
+ for ctx_name, ctx_data in data.items():
281
+ summary = ctx_data["step_dict"]["summary"]
282
+ detail_dict = ctx_data["step_dict"]["detail"]
283
+
284
+ # --- 1. Build Summary Data ---
285
+ # Iterate keys in 'avg_time' to ensure we capture all steps
286
+ for avg_key, avg_val in summary["avg_time"].items():
287
+ step_name = avg_key.replace("avg_", "")
288
+
289
+ # Get corresponding percent
290
+ per_key = f"per_{step_name}"
291
+ percent_val = summary["percent_time"].get(per_key, 0.0)
292
+
293
+ # Get sample count from detail list length
294
+ count = len(detail_dict.get(step_name, []))
295
+
296
+ summary_rows.append(
297
+ {
298
+ "context_name": ctx_name,
299
+ "step_name": step_name,
300
+ "avg_time_sec": avg_val,
301
+ "percent_total": percent_val,
302
+ "sample_count": count,
303
+ }
304
+ )
305
+
306
+ # --- 2. Build Detail Data ---
307
+ for step_name, time_list in detail_dict.items():
308
+ # time_list format: [(idx, duration), (idx, duration)...]
309
+ for item in time_list:
310
+ if len(item) == 2:
311
+ idx, duration = item
312
+ detail_rows.append(
313
+ {
314
+ "context_name": ctx_name,
315
+ "step_name": step_name,
316
+ "iteration_idx": idx,
317
+ "duration_sec": duration,
318
+ }
319
+ )
320
+
321
+ # Create DataFrames
322
+ df_summary = pd.DataFrame(summary_rows)
323
+ df_detail = pd.DataFrame(detail_rows)
324
+
325
+ # Reorder columns for readability (optional but nice)
326
+ if not df_summary.empty:
327
+ df_summary = df_summary[
328
+ [
329
+ "context_name",
330
+ "step_name",
331
+ "avg_time_sec",
332
+ "percent_total",
333
+ "sample_count",
334
+ ]
335
+ ]
336
+
337
+ if not df_detail.empty:
338
+ df_detail = df_detail[
339
+ ["context_name", "step_name", "iteration_idx", "duration_sec"]
340
+ ]
341
+
342
+ return df_summary, df_detail
343
+
344
+ def get_report_csv_files(self, outdir, tag="profiler"):
345
+ """
346
+ Exports profiling data to two CSV files:
347
+ 1. {tag}_summary.csv: Aggregated stats (Avg time, %)
348
+ 2. {tag}_detailed_logs.csv: Raw duration for every iteration
349
+
350
+ Args:
351
+ outdir (str): Directory to save files.
352
+ tag (str): Optional prefix for filenames.
353
+ """
354
+
355
+ if not os.path.exists(outdir):
356
+ os.makedirs(outdir)
357
+ tag_str = f"{tag}_" if tag else ""
358
+ summary_file = os.path.join(outdir, f"{tag_str}summary.csv")
359
+ detail_file = os.path.join(outdir, f"{tag_str}detailed_logs.csv")
360
+
361
+ df_summary, df_detail = self.get_report_dataframes()
362
+ if df_summary is not None:
363
+ df_summary.to_csv(summary_file, index=False, sep=";", encoding="utf-8")
364
+ logger.info(f"Saved summary CSV to: {summary_file}")
365
+ if df_detail is not None:
366
+ df_detail.to_csv(detail_file, index=False, sep=";", encoding="utf-8")
367
+ logger.info(f"Saved detailed logs CSV to: {detail_file}")
368
+
369
+ @classmethod
370
+ def plot_formatted_data(
371
+ cls, profiler_data, outdir=None, file_format="png", do_show=False, tag=""
372
+ ):
373
+ """
374
+ Plot each context in a separate figure with bar + pie charts.
375
+ Save each figure in the specified format (png or svg).
376
+ """
377
+
378
+ if outdir is not None:
379
+ os.makedirs(outdir, exist_ok=True)
380
+
381
+ if file_format.lower() not in ["png", "svg"]:
382
+ raise ValueError("file_format must be 'png' or 'svg'")
383
+
384
+ results = {} # {context: fig}
385
+
386
+ for ctx, ctx_data in profiler_data.items():
387
+ summary = ctx_data["step_dict"]["summary"]
388
+ avg_times = summary["avg_time"]
389
+ percent_times = summary["percent_time"]
390
+
391
+ step_names = [s.replace("avg_", "") for s in avg_times.keys()]
392
+ # pprint(f'{step_names=}')
393
+ n_steps = len(step_names)
394
+
395
+ assert n_steps > 0, "No steps found for context: {}".format(ctx)
396
+ # Generate dynamic colors
397
+ colors = (
398
+ px.colors.sample_colorscale(
399
+ "Viridis", [i / (n_steps - 1) for i in range(n_steps)]
400
+ )
401
+ if n_steps > 1
402
+ else [px.colors.sample_colorscale("Viridis", [0])[0]]
403
+ )
404
+ # pprint(f'{len(colors)} colors generated for {n_steps} steps')
405
+ color_map = dict(zip(step_names, colors))
406
+
407
+ # Create figure
408
+ fig = make_subplots(
409
+ rows=1,
410
+ cols=2,
411
+ subplot_titles=[f"Avg Time", f"% Time"],
412
+ specs=[[{"type": "bar"}, {"type": "pie"}]],
413
+ )
414
+
415
+ # Bar chart
416
+ fig.add_trace(
417
+ go.Bar(
418
+ x=step_names,
419
+ y=list(avg_times.values()),
420
+ text=[f"{v * 1000:.2f} ms" for v in avg_times.values()],
421
+ textposition="outside",
422
+ marker=dict(color=[color_map[s] for s in step_names]),
423
+ name="", # unified legend
424
+ showlegend=False,
425
+ ),
426
+ row=1,
427
+ col=1,
428
+ )
429
+
430
+ # Pie chart (colors match bar)
431
+ fig.add_trace(
432
+ go.Pie(
433
+ labels=step_names,
434
+ values=list(percent_times.values()),
435
+ marker=dict(colors=[color_map[s] for s in step_names]),
436
+ hole=0.4,
437
+ name="",
438
+ showlegend=True,
439
+ ),
440
+ row=1,
441
+ col=2,
442
+ )
443
+ tag_str = tag if tag and len(tag) > 0 else ""
444
+ # Layout
445
+ fig.update_layout(
446
+ title_text=f"[{tag_str}] Context Profiler: {ctx}",
447
+ width=1000,
448
+ height=400,
449
+ showlegend=True,
450
+ legend=dict(title="Steps", x=1.05, y=0.5, traceorder="normal"),
451
+ hovermode="x unified",
452
+ )
453
+
454
+ fig.update_xaxes(title_text="Steps", row=1, col=1)
455
+ fig.update_yaxes(title_text="Avg Time (ms)", row=1, col=1)
456
+
457
+ # Show figure
458
+ if do_show:
459
+ fig.show()
460
+
461
+ # Save figure
462
+ if outdir is not None:
463
+ file_prefix = ctx if len(tag_str) == 0 else f"{tag_str}_{ctx}"
464
+ file_path = os.path.join(
465
+ outdir, f"{file_prefix}_summary.{file_format.lower()}"
466
+ )
467
+ fig.write_image(file_path)
468
+ pprint(f"Saved figure to: 🔽")
469
+ pprint_local_path(file_path)
470
+
471
+ results[ctx] = fig
472
+
473
+ return results
474
+
475
+ def report_and_plot(self, outdir=None, file_format="png", do_show=False, tag=""):
476
+ """
477
+ Generate the profiling report and plot the formatted data.
478
+
479
+ Args:
480
+ outdir (str): Directory to save figures. If None, figures are only shown.
481
+ file_format (str): Target file format, "png" or "svg". Default is "png".
482
+ do_show (bool): Whether to display the plots. Default is False.
483
+ """
484
+ report = self.get_report_dict()
485
+ self.get_report_dict(with_detail=False)
486
+ return self.plot_formatted_data(
487
+ report, outdir=outdir, file_format=file_format, do_show=do_show, tag=tag
488
+ )
489
+
490
+ def meta_info(self):
491
+ """
492
+ Print the structure of the profiler's time dictionary.
493
+ Useful for debugging and understanding the profiler's internal state.
494
+ """
495
+ for ctx_name, ctx_dict in self.time_dict.items():
496
+ with ConsoleLog(f"Context: {ctx_name}"):
497
+ step_names = list(ctx_dict["step_dict"].keys())
498
+ for step_name in step_names:
499
+ pprint(f"Step: {step_name}")
500
+
501
+ def save_report_dict(self, output_file, with_detail=False):
502
+ try:
503
+ report = self.get_report_dict(with_detail=with_detail)
504
+ with open(output_file, "w") as f:
505
+ json.dump(report, f, indent=4)
506
+ except Exception as e:
507
+ logger.error(f"Failed to save report to {output_file}: {e}")
File without changes