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.
- py_app_runner/__init__.py +11 -0
- py_app_runner/audit/__init__.py +29 -0
- py_app_runner/audit/_service.py +91 -0
- py_app_runner/audit/_service_args.py +44 -0
- py_app_runner/audit/audit.py +319 -0
- py_app_runner/audit/commands.py +151 -0
- py_app_runner/audit/diff.py +202 -0
- py_app_runner/audit/errors.py +8 -0
- py_app_runner/audit/event.py +130 -0
- py_app_runner/audit/store.py +134 -0
- py_app_runner/bridge/__init__.py +0 -0
- py_app_runner/bridge/_service.py +265 -0
- py_app_runner/bridge/_service_args.py +24 -0
- py_app_runner/bridge/api.py +138 -0
- py_app_runner/bridge/encoders/__init__.py +5 -0
- py_app_runner/bridge/encoders/base.py +24 -0
- py_app_runner/bridge/encoders/json_encoder.py +26 -0
- py_app_runner/bridge/encoders/msgpack_encoder.py +58 -0
- py_app_runner/bridge/web_app.py +31 -0
- py_app_runner/bridge/websocket.py +313 -0
- py_app_runner/colors.py +73 -0
- py_app_runner/config.py +132 -0
- py_app_runner/crypto/__init__.py +14 -0
- py_app_runner/crypto/_service.py +75 -0
- py_app_runner/crypto/_service_args.py +54 -0
- py_app_runner/crypto/commands.py +164 -0
- py_app_runner/crypto/envelope.py +144 -0
- py_app_runner/crypto/errors.py +8 -0
- py_app_runner/crypto/fields.py +300 -0
- py_app_runner/crypto/passwords.py +66 -0
- py_app_runner/db_pools.py +20 -0
- py_app_runner/http_exception.py +31 -0
- py_app_runner/logger_handlers.py +167 -0
- py_app_runner/migrations/__init__.py +5 -0
- py_app_runner/migrations/_service.py +296 -0
- py_app_runner/migrations/_service_args.py +91 -0
- py_app_runner/migrations/commands.py +386 -0
- py_app_runner/migrations/discovery.py +108 -0
- py_app_runner/migrations/states.py +63 -0
- py_app_runner/migrations/tracker.py +141 -0
- py_app_runner/py.typed +0 -0
- py_app_runner/pybridge.py +64 -0
- py_app_runner/queue/__init__.py +25 -0
- py_app_runner/queue/_service.py +231 -0
- py_app_runner/queue/_service_args.py +67 -0
- py_app_runner/queue/commands.py +180 -0
- py_app_runner/queue/driver_pg.py +464 -0
- py_app_runner/queue/driver_redis.py +613 -0
- py_app_runner/queue/handler.py +90 -0
- py_app_runner/queue/interface.py +63 -0
- py_app_runner/queue/job.py +46 -0
- py_app_runner/queue/worker.py +221 -0
- py_app_runner/registry.py +54 -0
- py_app_runner/request_handler/__init__.py +0 -0
- py_app_runner/request_handler/auth_service.py +123 -0
- py_app_runner/request_handler/decorators.py +304 -0
- py_app_runner/request_handler/handlers.py +604 -0
- py_app_runner/request_handler/pagination.py +24 -0
- py_app_runner/return_model.py +78 -0
- py_app_runner/runner.py +182 -0
- py_app_runner/throttle/__init__.py +5 -0
- py_app_runner/throttle/throttle.py +217 -0
- py_app_runner/tick_service.py +308 -0
- py_app_runner/timer.py +289 -0
- py_app_runner/utils.py +346 -0
- py_app_runner/wbcm/__init__.py +0 -0
- py_app_runner/wbcm/device_connections.py +89 -0
- py_app_runner/wbcm/factory.py +113 -0
- py_app_runner/wbcm/wb_connection_manager.py +333 -0
- py_app_runner/wbcm/ws_interface.py +56 -0
- py_app_runner-0.5.49.dev0.dist-info/METADATA +134 -0
- py_app_runner-0.5.49.dev0.dist-info/RECORD +75 -0
- py_app_runner-0.5.49.dev0.dist-info/WHEEL +5 -0
- py_app_runner-0.5.49.dev0.dist-info/licenses/LICENSE +21 -0
- 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()])
|