py-app-runner 0.5.49.dev0__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 (75) hide show
  1. py_app_runner/__init__.py +11 -0
  2. py_app_runner/audit/__init__.py +29 -0
  3. py_app_runner/audit/_service.py +91 -0
  4. py_app_runner/audit/_service_args.py +44 -0
  5. py_app_runner/audit/audit.py +319 -0
  6. py_app_runner/audit/commands.py +151 -0
  7. py_app_runner/audit/diff.py +202 -0
  8. py_app_runner/audit/errors.py +8 -0
  9. py_app_runner/audit/event.py +130 -0
  10. py_app_runner/audit/store.py +134 -0
  11. py_app_runner/bridge/__init__.py +0 -0
  12. py_app_runner/bridge/_service.py +265 -0
  13. py_app_runner/bridge/_service_args.py +24 -0
  14. py_app_runner/bridge/api.py +138 -0
  15. py_app_runner/bridge/encoders/__init__.py +5 -0
  16. py_app_runner/bridge/encoders/base.py +24 -0
  17. py_app_runner/bridge/encoders/json_encoder.py +26 -0
  18. py_app_runner/bridge/encoders/msgpack_encoder.py +58 -0
  19. py_app_runner/bridge/web_app.py +31 -0
  20. py_app_runner/bridge/websocket.py +313 -0
  21. py_app_runner/colors.py +73 -0
  22. py_app_runner/config.py +132 -0
  23. py_app_runner/crypto/__init__.py +14 -0
  24. py_app_runner/crypto/_service.py +75 -0
  25. py_app_runner/crypto/_service_args.py +54 -0
  26. py_app_runner/crypto/commands.py +164 -0
  27. py_app_runner/crypto/envelope.py +144 -0
  28. py_app_runner/crypto/errors.py +8 -0
  29. py_app_runner/crypto/fields.py +300 -0
  30. py_app_runner/crypto/passwords.py +66 -0
  31. py_app_runner/db_pools.py +20 -0
  32. py_app_runner/http_exception.py +31 -0
  33. py_app_runner/logger_handlers.py +167 -0
  34. py_app_runner/migrations/__init__.py +5 -0
  35. py_app_runner/migrations/_service.py +296 -0
  36. py_app_runner/migrations/_service_args.py +91 -0
  37. py_app_runner/migrations/commands.py +386 -0
  38. py_app_runner/migrations/discovery.py +108 -0
  39. py_app_runner/migrations/states.py +63 -0
  40. py_app_runner/migrations/tracker.py +141 -0
  41. py_app_runner/py.typed +0 -0
  42. py_app_runner/pybridge.py +64 -0
  43. py_app_runner/queue/__init__.py +25 -0
  44. py_app_runner/queue/_service.py +231 -0
  45. py_app_runner/queue/_service_args.py +67 -0
  46. py_app_runner/queue/commands.py +180 -0
  47. py_app_runner/queue/driver_pg.py +464 -0
  48. py_app_runner/queue/driver_redis.py +613 -0
  49. py_app_runner/queue/handler.py +90 -0
  50. py_app_runner/queue/interface.py +63 -0
  51. py_app_runner/queue/job.py +46 -0
  52. py_app_runner/queue/worker.py +221 -0
  53. py_app_runner/registry.py +54 -0
  54. py_app_runner/request_handler/__init__.py +0 -0
  55. py_app_runner/request_handler/auth_service.py +123 -0
  56. py_app_runner/request_handler/decorators.py +304 -0
  57. py_app_runner/request_handler/handlers.py +604 -0
  58. py_app_runner/request_handler/pagination.py +24 -0
  59. py_app_runner/return_model.py +78 -0
  60. py_app_runner/runner.py +182 -0
  61. py_app_runner/throttle/__init__.py +5 -0
  62. py_app_runner/throttle/throttle.py +217 -0
  63. py_app_runner/tick_service.py +308 -0
  64. py_app_runner/timer.py +289 -0
  65. py_app_runner/utils.py +346 -0
  66. py_app_runner/wbcm/__init__.py +0 -0
  67. py_app_runner/wbcm/device_connections.py +89 -0
  68. py_app_runner/wbcm/factory.py +113 -0
  69. py_app_runner/wbcm/wb_connection_manager.py +333 -0
  70. py_app_runner/wbcm/ws_interface.py +56 -0
  71. py_app_runner-0.5.49.dev0.dist-info/METADATA +134 -0
  72. py_app_runner-0.5.49.dev0.dist-info/RECORD +75 -0
  73. py_app_runner-0.5.49.dev0.dist-info/WHEEL +5 -0
  74. py_app_runner-0.5.49.dev0.dist-info/licenses/LICENSE +21 -0
  75. py_app_runner-0.5.49.dev0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,308 @@
1
+ import asyncio
2
+ import inspect
3
+ import logging
4
+ import random
5
+ from collections.abc import Awaitable, Callable
6
+ from signal import SIGHUP, SIGINT, SIGQUIT, SIGTERM, Signals
7
+
8
+ from .timer import Timer
9
+
10
+ CALLBACK_TYPE = Callable[[int, float], None] | Callable[[int, float], Awaitable[None]]
11
+ SHUTDOWN_CALLBACK_TYPE = Callable[[], None] | Callable[[], Awaitable[None]] | None
12
+
13
+
14
+ class ShutdownRequestedException(Exception):
15
+ """
16
+ This class can be used to signal parts of the service/app that a shutdown was requested.
17
+ When needToShutdown is called with withException=True, this exception will be raised.
18
+ This is useful for example when you want to stop a long running task, but don't want
19
+ shutdown exception to be logged.
20
+ """
21
+
22
+ pass
23
+
24
+
25
+ class TickService:
26
+ # Intervals
27
+ default_tick_interval: float
28
+ tick_interval: float
29
+ tick_count: int
30
+ tick_time: float
31
+
32
+ # Callbacks
33
+ callbacks: list[CALLBACK_TYPE]
34
+ shut_down_callback: SHUTDOWN_CALLBACK_TYPE
35
+ back_off_callback: CALLBACK_TYPE | None
36
+
37
+ # Various
38
+ shutdown_event: asyncio.Event
39
+ current_loop: asyncio.AbstractEventLoop
40
+ back_off_mode: bool
41
+ back_off_count: int
42
+ logger_name: str
43
+ logger: logging.Logger
44
+ timer: Timer
45
+
46
+ skip_signal_handling: bool = False
47
+ """Skip signal handling. Be careful with this, as it can lead to unstoppable processes"""
48
+
49
+ #######################
50
+ ### Class Lifecycle ###
51
+ #######################
52
+
53
+ def __del__(self) -> None:
54
+ self.logger.debug("Dealloc")
55
+
56
+ def __init__(
57
+ self,
58
+ tick_interval: float,
59
+ callback: CALLBACK_TYPE | None = None,
60
+ shut_down_callback: SHUTDOWN_CALLBACK_TYPE = None,
61
+ back_off_callback: CALLBACK_TYPE | None = None,
62
+ instance_name: str | None = None,
63
+ ):
64
+ # Intervals
65
+ self.default_tick_interval = tick_interval
66
+ self.tick_interval = tick_interval
67
+ self.tick_count = 0
68
+ self.tick_time = 0
69
+
70
+ # Callbacks
71
+ self.callbacks = [] if callback is None else [callback]
72
+ self.shut_down_callback = shut_down_callback
73
+ self.back_off_callback = back_off_callback
74
+
75
+ # Various
76
+ self.shutdown_event = asyncio.Event()
77
+ self.back_off_mode = False
78
+ self.back_off_count = 0
79
+
80
+ instance_name = f".{instance_name}" if instance_name is not None else ""
81
+ self.logger_name = f"{self.__class__.__name__}{instance_name}"
82
+ self.logger = logging.getLogger(self.logger_name)
83
+ self.timer = Timer("TickService")
84
+
85
+ ###############
86
+ ### Helpers ###
87
+ ###############
88
+
89
+ def add_callback(self, callback: CALLBACK_TYPE) -> None:
90
+ self.callbacks.append(callback)
91
+
92
+ def set_interval(self, interval: float) -> None:
93
+ self.tick_interval = interval
94
+ self.logger.debug(f"Tick interval set to {self.tick_interval} seconds")
95
+
96
+ def increase_interval_by(self, amount: float) -> None:
97
+ """
98
+ Increases the tick interval by a specified amount.
99
+
100
+ :param amount: The amount in seconds to increase the tick interval by.
101
+ """
102
+ self.tick_interval += amount
103
+ self.logger.debug(f"Tick interval increased by {amount} seconds, now at {self.tick_interval} seconds")
104
+
105
+ def increase_interval_exp(self, factor: float = 1.1, max_interval: float = 300) -> None:
106
+ """
107
+ Increases the tick interval exponentially for the next tick.
108
+
109
+ :param factor: The exponential factor to multiply the current interval by. Default is 1.1.
110
+ :param max_interval: The maximum interval in seconds. Default is 300.
111
+ """
112
+ self.tick_interval = min(self.tick_interval * factor, max_interval)
113
+ self.tick_interval *= random.uniform(0.9, 1.1) # Little jitter
114
+ self.logger.debug(f"Tick interval increased to {self.tick_interval} seconds")
115
+
116
+ def reset_interval(self) -> None:
117
+ """
118
+ Resets the tick interval to the default value.
119
+ """
120
+ self.tick_interval = self.default_tick_interval
121
+ self.logger.debug(f"Tick interval reset to {self.tick_interval} seconds")
122
+
123
+ def set_backoff(self, start: bool = True) -> None:
124
+ self.back_off_mode = start
125
+ self.back_off_count = 0
126
+
127
+ def format_time(self, seconds: float) -> str:
128
+ """
129
+ Formats seconds to human readable time.
130
+
131
+ :param seconds: The seconds to format.
132
+ :return: The formatted time.
133
+ """
134
+ if seconds < 60:
135
+ return f"{seconds:.2f}s"
136
+
137
+ if seconds < 3600:
138
+ m, s = divmod(seconds, 60)
139
+ return f"{int(m)}m {int(s)}s"
140
+
141
+ m, s = divmod(seconds, 60)
142
+ h, m = divmod(m, 60)
143
+ return f"{int(h)}h {int(m)}m {int(s)}s"
144
+
145
+ async def need_to_shutdown(self, with_exception: bool = False) -> bool:
146
+ """
147
+ Waits for 0.001 seconds and checks if the shutdown event is set.
148
+
149
+ Returns:
150
+ bool: True if the shutdown event is set, False otherwise.
151
+ """
152
+ try:
153
+ await asyncio.sleep(0.001)
154
+ except asyncio.CancelledError:
155
+ self.logger.debug("needToShutdown: Sleep was cancelled")
156
+
157
+ if with_exception:
158
+ if self.shutdown_event.is_set():
159
+ raise ShutdownRequestedException("Shutdown requested")
160
+
161
+ return self.shutdown_event.is_set()
162
+
163
+ #######################
164
+ ### Loop management ###
165
+ #######################
166
+
167
+ async def shutdown(self, signal: Signals) -> None:
168
+ self.logger.debug(f"Shutdown signal received: {signal.name} ({signal.value})")
169
+
170
+ # Lets first shutdown our event loop
171
+ self.shutdown_event.set()
172
+
173
+ # Then call external shutdown callbacks
174
+ if self.shut_down_callback is not None:
175
+ # Need to catch exceptions here, otherwise the loop will be stopped with a interval delay
176
+ try:
177
+ if inspect.iscoroutinefunction(self.shut_down_callback):
178
+ await self.shut_down_callback()
179
+ else:
180
+ self.shut_down_callback()
181
+ except Exception as e:
182
+ self.logger.exception(e)
183
+
184
+ # And finally we cancel all tasks so for example asyncio.sleep is terminated
185
+ tasks = [
186
+ t
187
+ for t in asyncio.all_tasks(loop=self.current_loop)
188
+ if t is not asyncio.current_task(loop=self.current_loop)
189
+ ]
190
+ [task.cancel() for task in tasks]
191
+
192
+ logging.debug(f"Cancelling {len(tasks)} outstanding tasks")
193
+ # return_exceptions: the CancelledError we just caused would otherwise
194
+ # propagate here and abort the rest of the shutdown.
195
+ await asyncio.gather(*tasks, return_exceptions=True)
196
+ logging.debug("All outstanding tasks are cancelled")
197
+
198
+ # Remove callback handlers to avoid circular references
199
+ self.callbacks = []
200
+ self.shut_down_callback = None
201
+
202
+ def shutdown_handler(self, signal: Signals) -> None:
203
+ asyncio.create_task(self.shutdown(signal))
204
+
205
+ def init_signals(self) -> None:
206
+ if self.skip_signal_handling:
207
+ self.logger.debug("Skipping signal handlers")
208
+ return
209
+
210
+ self.logger.debug("Initializing signal handlers")
211
+
212
+ # Attach signal handlers
213
+ signals = (SIGHUP, SIGTERM, SIGINT, SIGQUIT)
214
+ for s in signals:
215
+ self.current_loop.add_signal_handler(s, lambda s=s: self.shutdown_handler(s))
216
+
217
+ async def loop_wait(self, timeout: int | float | None = None) -> None:
218
+ if timeout is None:
219
+ timeout = self.tick_interval
220
+
221
+ self.logger.debug(f"loop_wait: Sleeping for {self.format_time(timeout)}")
222
+
223
+ try:
224
+ await asyncio.sleep(timeout)
225
+ except asyncio.CancelledError:
226
+ self.logger.debug("loop_wait: Sleep was cancelled")
227
+
228
+ async def run_loop(self) -> None:
229
+ self.current_loop = asyncio.get_running_loop()
230
+ self.init_signals()
231
+
232
+ # Start infinite loop
233
+ self.logger.debug("Starting process loop")
234
+ while not self.shutdown_event.is_set():
235
+ # Tick
236
+ self.logger.debug(f"Tick #{self.tick_count + 1} started")
237
+ async with self.timer.aenter("TickService.tick"):
238
+ await self.tick()
239
+
240
+ # Timer stats
241
+ self.timer.print_timer_stats()
242
+ tick_total = self.timer.total_times.get("TickService.tick", {}).get("total", 0)
243
+ self.logger.debug(f"Tick #{self.tick_count} finished in {self.format_time(tick_total)}")
244
+
245
+ # Per-run timings key on run_index; without a reset the runtimes dicts
246
+ # grow unbounded on long-running services.
247
+ self.timer.reset_timers()
248
+ self.timer.run_index += 1
249
+
250
+ # Sleep
251
+ if not self.shutdown_event.is_set():
252
+ await self.loop_wait()
253
+
254
+ self.logger.debug("Process loop is stopped")
255
+
256
+ async def stop_loop(self, timeout: float = 10.0) -> None:
257
+ """Ask the loop to stop and wait for the shutdown to finish.
258
+
259
+ Safe to call from another thread's event loop: the coroutine is scheduled on
260
+ the loop that owns this service and awaited from the caller's loop.
261
+ """
262
+ self.logger.debug("Stopping process loop")
263
+
264
+ current_loop = getattr(self, "current_loop", None)
265
+ if current_loop is None or current_loop.is_closed():
266
+ self.logger.debug("Loop was never started or is already closed")
267
+ self.shutdown_event.set()
268
+ return
269
+
270
+ future = asyncio.run_coroutine_threadsafe(self.shutdown(SIGTERM), current_loop)
271
+ try:
272
+ await asyncio.wait_for(asyncio.wrap_future(future), timeout=timeout)
273
+ except TimeoutError:
274
+ self.logger.warning(f"Shutdown did not finish within {timeout}s, continuing anyway")
275
+ except Exception as e:
276
+ self.logger.warning(f"Shutdown failed: {e}")
277
+
278
+ ####################
279
+ ### Tick methods ###
280
+ ####################
281
+
282
+ async def tick(self) -> None:
283
+ self.tick_count += 1
284
+ self.tick_time += self.tick_interval
285
+
286
+ # Backoff mode
287
+ if self.back_off_mode:
288
+ self.back_off_count += 1
289
+ if self.back_off_callback is not None:
290
+ if inspect.iscoroutinefunction(self.back_off_callback):
291
+ await self.back_off_callback(self.back_off_count, self.tick_time)
292
+ else:
293
+ self.back_off_callback(self.back_off_count, self.tick_time)
294
+
295
+ # Regular tick
296
+ else:
297
+ for callback in self.callbacks:
298
+ try:
299
+ if inspect.iscoroutinefunction(callback):
300
+ await callback(self.tick_count, self.tick_time)
301
+ else:
302
+ callback(self.tick_count, self.tick_time)
303
+ except asyncio.CancelledError:
304
+ self.logger.debug("Tick callback cancelled")
305
+ raise
306
+
307
+ if await self.need_to_shutdown():
308
+ break
py_app_runner/timer.py ADDED
@@ -0,0 +1,289 @@
1
+ import logging
2
+ import time
3
+ from collections.abc import AsyncGenerator, Generator
4
+ from contextlib import asynccontextmanager, contextmanager
5
+ from typing import Any, TypedDict
6
+
7
+
8
+ class TimerProperties(TypedDict):
9
+ level: int
10
+ total: float
11
+ runtimes: dict[int, float]
12
+
13
+
14
+ class Timer:
15
+ name: str
16
+ logger: logging.Logger
17
+
18
+ start_times: dict[str, float]
19
+ total_times: dict[str, TimerProperties]
20
+
21
+ timers: list[tuple[int, str]]
22
+ level: int
23
+ run_index: int
24
+
25
+ def __init__(self, name: str):
26
+ self.name = name
27
+ self.logger = logging.getLogger(f"{self.__class__.__name__}")
28
+
29
+ self.start_times = {}
30
+ self.total_times = {}
31
+ self.timers = []
32
+ self.level = 0
33
+ self.run_index = 0
34
+
35
+ # * Class generator
36
+ @contextmanager
37
+ def enter(self, name: str) -> Generator["Timer", None, None]:
38
+ self.level += 1
39
+ self.start(name=name)
40
+
41
+ try:
42
+ yield self
43
+ finally:
44
+ self.stop(name=name)
45
+ self.level -= 1
46
+
47
+ @asynccontextmanager
48
+ async def aenter(self, name: str) -> AsyncGenerator["Timer", None]:
49
+ self.level += 1
50
+ self.start(name=name)
51
+
52
+ try:
53
+ yield self
54
+ finally:
55
+ self.stop(name=name)
56
+ self.level -= 1
57
+
58
+ # * Class context manager
59
+ def __enter__(self) -> "Timer":
60
+ self.start()
61
+ return self
62
+
63
+ def __exit__(self, exc_type: Any, exc_value: Any, traceback: Any) -> None:
64
+ self.stop()
65
+
66
+ async def __aenter__(self) -> "Timer":
67
+ self.start()
68
+ return self
69
+
70
+ async def __aexit__(self, exc_type: Any, exc_value: Any, traceback: Any) -> None:
71
+ self.stop()
72
+
73
+ # * Start / stop
74
+ def start(self, name: str | None = None, skip_append: bool = False) -> None:
75
+ if name is None:
76
+ name = self.name
77
+
78
+ if len(self.timers) > 0:
79
+ (
80
+ level,
81
+ prev_timer_name,
82
+ ) = self.timers[-1]
83
+ if prev_timer_name != name and level != self.level:
84
+ self.stop(name=prev_timer_name, skip_remove=True)
85
+
86
+ self.start_times[name] = time.perf_counter()
87
+
88
+ if not skip_append:
89
+ self.timers.append(
90
+ (
91
+ self.level,
92
+ name,
93
+ )
94
+ )
95
+
96
+ def stop(
97
+ self,
98
+ name: str | None = None,
99
+ skip_remove: bool = False,
100
+ ) -> None:
101
+ if name is None:
102
+ name = self.name
103
+ end_time = time.perf_counter()
104
+
105
+ if name not in self.start_times:
106
+ self.logger.warning(f"Timer {name} stopped before it was started")
107
+ return None
108
+
109
+ elapsed = end_time - self.start_times[name]
110
+ if name not in self.total_times:
111
+ self.total_times[name] = TimerProperties(level=self.level, total=0, runtimes={})
112
+
113
+ self.total_times[name]["runtimes"][self.run_index] = elapsed
114
+ self.total_times[name]["total"] += elapsed
115
+
116
+ if not skip_remove:
117
+ # Remove current timer
118
+ self.timers.pop()
119
+
120
+ # Continue previous timer
121
+ if len(self.timers) > 0:
122
+ (
123
+ level,
124
+ prev_timer_name,
125
+ ) = self.timers[-1]
126
+ if prev_timer_name != name and level != self.level:
127
+ self.start(name=prev_timer_name, skip_append=True)
128
+
129
+ # * Stats
130
+ def reset_timers(self) -> None:
131
+ """Resets the current timer. Call this at the start of an iteration."""
132
+ self.start_times = {}
133
+ self.total_times = {}
134
+
135
+ # * Output
136
+ def print_timer_stats_table(self, return_data: bool = False) -> str | None:
137
+ """Prints the current timing information into a table."""
138
+ timer_stats = "\n\n"
139
+
140
+ all_fn_names = [k for k in self.total_times.keys()]
141
+ if len(all_fn_names) == 0:
142
+ return None
143
+
144
+ # Max width of level column
145
+ max_level_width = max([self.total_times[k].get("level", 0) for k in all_fn_names])
146
+ if max_level_width % 2 == 1:
147
+ max_level_width += 1
148
+ max_level_width = max(max_level_width, 6)
149
+
150
+ # Max width of name column
151
+ max_name_width = max([len(k) for k in all_fn_names] + [4])
152
+ if max_name_width % 2 == 1:
153
+ max_name_width += 1
154
+
155
+ # Format string
156
+ format_str = (
157
+ f"{{:>{max_level_width}}} | {{:>{max_name_width}}} | "
158
+ f"{{:>10.4f}} | {{:>10.4f}} | {{:>10.4f}} | {{:>10.4f}} | {{:>5}}"
159
+ )
160
+
161
+ header = (
162
+ f"{{:>{max_level_width}}} | {{:>{max_name_width}}} | {{:^10}} | {{:^10}} | {{:^10}} | {{:^10}} | {{:^5}}"
163
+ ).format("Level", "Name", "Total (ms)", "Min (ms)", "Max (ms)", "Avg (ms)", "Count")
164
+ timer_stats += f"{header}\n"
165
+
166
+ sep_idx = header.find("|")
167
+ sep_text = ("-" * sep_idx) + "+" + ("-" * (len(header) - sep_idx - 1))
168
+ timer_stats += f"{sep_text}\n"
169
+
170
+ for name in all_fn_names:
171
+ runtimes = self.total_times[name].get("runtimes", {}).values()
172
+ total_time = self.total_times[name].get("total", 0) * 1000
173
+ min_time = min(runtimes) * 1000 if runtimes else 0
174
+ max_time = max(runtimes) * 1000 if runtimes else 0
175
+ avg_time = (sum(runtimes) / len(runtimes)) * 1000 if runtimes else 0
176
+ run_count = len(runtimes)
177
+
178
+ timer_stats += format_str.format(
179
+ self.total_times[name].get("level"),
180
+ name,
181
+ total_time,
182
+ min_time,
183
+ max_time,
184
+ avg_time,
185
+ run_count,
186
+ )
187
+ timer_stats += "\n"
188
+
189
+ totals_format_str = (
190
+ f"{{:>{max_level_width}}} | {{:>{max_name_width}}} | "
191
+ f"{{:>10.4f}} | {{:>10.4}} | {{:>10.4}} | {{:>10.4}} | {{:>10.4}}"
192
+ )
193
+ timer_stats += f"{sep_text}\n"
194
+ timer_stats += totals_format_str.format(
195
+ "",
196
+ "Total",
197
+ self.total_time(self.total_times) * 1000,
198
+ "",
199
+ "",
200
+ "",
201
+ "",
202
+ )
203
+ timer_stats += "\n\n"
204
+
205
+ if return_data:
206
+ return timer_stats
207
+
208
+ self.logger.debug(timer_stats)
209
+ return None
210
+
211
+ def print_timer_stats_json(self, return_data: bool = False) -> str | None:
212
+ """Prints the current timing information into a JSON string."""
213
+ pass
214
+
215
+ def print_timer_stats_csv(self, return_data: bool = False) -> str | None:
216
+ """Prints the current timing information into a CSV string."""
217
+ all_fn_names = list(self.total_times.keys())
218
+ if not all_fn_names:
219
+ return None
220
+
221
+ # Prepare header
222
+ header = all_fn_names + ["Total"]
223
+
224
+ # Get all runtime indices and find the maximum index
225
+ all_indices: set[int] = set()
226
+ for name in all_fn_names:
227
+ all_indices.update(self.total_times[name].get("runtimes", {}).keys())
228
+ max_index = max(all_indices) if all_indices else 0
229
+
230
+ # Prepare data rows
231
+ data_rows: list[list[str]] = []
232
+ for i in range(max_index + 1):
233
+ row: list[str] = []
234
+ row_total = 0.0
235
+ for name in all_fn_names:
236
+ runtime = self.total_times[name].get("runtimes", {}).get(i)
237
+ if runtime is not None:
238
+ value = runtime * 1000
239
+ row.append(f"{value:.4f}")
240
+ row_total += value
241
+ else:
242
+ row.append("")
243
+ row.append(f"{row_total:.4f}")
244
+ data_rows.append(row)
245
+
246
+ # Prepare total row
247
+ total_row: list[str] = []
248
+ grand_total = 0.0
249
+ for name in all_fn_names:
250
+ total = sum(self.total_times[name].get("runtimes", {}).values()) * 1000
251
+ total_row.append(f"{total:.4f}")
252
+ grand_total += total
253
+ total_row.append(f"{grand_total:.4f}")
254
+
255
+ # Combine all rows
256
+ all_rows = [header] + data_rows + [total_row]
257
+
258
+ # Convert to CSV string
259
+ output = "\n".join([",".join(str(cell) for cell in row) for row in all_rows])
260
+
261
+ if return_data:
262
+ return output
263
+
264
+ self.logger.info(f"\n{output}")
265
+ return None
266
+
267
+ def print_timer_stats(
268
+ self,
269
+ output_format: str = "table",
270
+ return_data: bool = False,
271
+ ) -> str | None:
272
+ """
273
+ Prints the current timing information into a table.
274
+
275
+ output_format: str - choices: ["table", "json", "csv"]
276
+ """
277
+
278
+ if output_format == "table":
279
+ return self.print_timer_stats_table(return_data=return_data)
280
+ elif output_format == "json":
281
+ return self.print_timer_stats_json(return_data=return_data)
282
+ elif output_format == "csv":
283
+ return self.print_timer_stats_csv(return_data=return_data)
284
+
285
+ return None
286
+
287
+ def total_time(self, total_times: dict[str, TimerProperties]) -> float:
288
+ """Returns the total amount accumulated across all functions in seconds."""
289
+ return sum([timer.get("total", 0) for _name, timer in total_times.items()])