Coverage for pyTooling/Stopwatch/__init__.py: 85%
208 statements
« prev ^ index » next coverage.py v7.8.0, created at 2025-04-25 22:22 +0000
« prev ^ index » next coverage.py v7.8.0, created at 2025-04-25 22:22 +0000
1# ==================================================================================================================== #
2# _____ _ _ ____ _ _ _ #
3# _ __ _ |_ _|__ ___ | (_)_ __ __ _ / ___|| |_ ___ _ ____ ____ _| |_ ___| |__ #
4# | '_ \| | | || |/ _ \ / _ \| | | '_ \ / _` | \___ \| __/ _ \| '_ \ \ /\ / / _` | __/ __| '_ \ #
5# | |_) | |_| || | (_) | (_) | | | | | | (_| |_ ___) | || (_) | |_) \ V V / (_| | || (__| | | | #
6# | .__/ \__, ||_|\___/ \___/|_|_|_| |_|\__, (_)____/ \__\___/| .__/ \_/\_/ \__,_|\__\___|_| |_| #
7# |_| |___/ |___/ |_| #
8# ==================================================================================================================== #
9# Authors: #
10# Patrick Lehmann #
11# #
12# License: #
13# ==================================================================================================================== #
14# Copyright 2017-2025 Patrick Lehmann - Bötzingen, Germany #
15# #
16# Licensed under the Apache License, Version 2.0 (the "License"); #
17# you may not use this file except in compliance with the License. #
18# You may obtain a copy of the License at #
19# #
20# http://www.apache.org/licenses/LICENSE-2.0 #
21# #
22# Unless required by applicable law or agreed to in writing, software #
23# distributed under the License is distributed on an "AS IS" BASIS, #
24# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. #
25# See the License for the specific language governing permissions and #
26# limitations under the License. #
27# #
28# SPDX-License-Identifier: Apache-2.0 #
29# ==================================================================================================================== #
30#
31"""
32A stopwatch to measure execution times.
34.. hint:: See :ref:`high-level help <COMMON/Stopwatch>` for explanations and usage examples.
35"""
36from datetime import datetime
37from inspect import Traceback
38from time import perf_counter_ns
39from typing import List, Optional as Nullable, Iterator, Tuple, Type, ContextManager
41# Python 3.11: use Self if returning the own object: , Self
43try:
44 from pyTooling.Decorators import export, readonly
45 from pyTooling.MetaClasses import SlottedObject
46 from pyTooling.Exceptions import ToolingException
47 from pyTooling.Platform import CurrentPlatform
48except (ImportError, ModuleNotFoundError): # pragma: no cover
49 print("[pyTooling.Stopwatch] Could not import from 'pyTooling.*'!")
51 try:
52 from Decorators import export, readonly
53 from MetaClasses import SlottedObject
54 from Exceptions import ToolingException
55 from Platform import CurrentPlatform
56 except (ImportError, ModuleNotFoundError) as ex: # pragma: no cover
57 print("[pyTooling.Stopwatch] Could not import directly!")
58 raise ex
61@export
62class StopwatchException(ToolingException):
63 """This exception is caused by wrong usage of the stopwatch."""
66@export
67class ExcludeContextManager:
68 _stopwatch: "Stopwatch"
70 def __init__(self, stopwatch: "Stopwatch") -> None:
71 self._stopwatch = stopwatch
73 def __enter__(self) -> "ExcludeContextManager": # TODO: Python 3.11: -> Self:
74 self._stopwatch.Pause()
76 return self
78 def __exit__(self, exc_type: Type[Exception], exc_val: Exception, exc_tb: Traceback) -> bool:
79 self._stopwatch.Resume()
82@export
83class Stopwatch(SlottedObject):
84 """
85 The stopwatch implements a solution to measure and collect timings.
87 The time measurement can be started, paused, resumed and stopped. More over, split times can be taken too. The
88 measurement is based on :func:`time.perf_counter_ns`. Additionally, starting and stopping is preserved as absolute
89 time via :meth:`datetime.datetime.now`.
91 Every split time taken is a time delta to the previous operation. These are preserved in an internal sequence of
92 splits. This sequence includes time deltas of activity and inactivity. Thus, a running stopwatch can be split as well
93 as a paused stopwatch.
95 The stopwatch can also be used in a :ref:`with-statement <with>`, because it implements the :ref:`context manager protocol <context-managers>`.
96 """
98 _name: Nullable[str]
99 _preferPause: bool
101 _beginTime: Nullable[datetime]
102 _endTime: Nullable[datetime]
103 _startTime: Nullable[int]
104 _resumeTime: Nullable[int]
105 _pauseTime: Nullable[int]
106 _stopTime: Nullable[int]
107 _totalTime: Nullable[int]
108 _splits: List[Tuple[float, bool]]
110 _excludeContextManager: ExcludeContextManager
112 def __init__(self, name: str = None, started: bool = False, preferPause: bool = False) -> None:
113 """
114 Initializes the fields of the stopwatch.
116 If parameter ``started`` is set to true, the stopwatch will immediately start.
118 :param name: Optional name of the stopwatch.
119 :param preferPause: Optional setting, if __exit__(...) in a contex should prefer pause or stop behavior.
120 :param started: Optional flag, if the stopwatch should be started immediately.
121 """
122 self._name = name
123 self._preferPause = preferPause
125 self._endTime = None
126 self._pauseTime = None
127 self._stopTime = None
128 self._totalTime = None
129 self._splits = []
131 self._excludeContextManager = None
133 if started is False: 133 ↛ 138line 133 didn't jump to line 138 because the condition on line 133 was always true
134 self._beginTime = None
135 self._startTime = None
136 self._resumeTime = None
137 else:
138 self._beginTime = datetime.now()
139 self._resumeTime = self._startTime = perf_counter_ns()
141 def Start(self) -> None:
142 """
143 Start the stopwatch.
145 A stopwatch can only be started once. There is no restart or reset operation provided.
147 :raises StopwatchException: If stopwatch was already started.
148 :raises StopwatchException: If stopwatch was already started and stopped.
149 """
150 if self._startTime is not None:
151 raise StopwatchException("Stopwatch was already started.")
152 if self._stopTime is not None: 152 ↛ 153line 152 didn't jump to line 153 because the condition on line 152 was never true
153 raise StopwatchException("Stopwatch was already used (started and stopped).")
155 self._beginTime = datetime.now()
156 self._resumeTime = self._startTime = perf_counter_ns()
158 def Split(self) -> float:
159 """
160 Take a split time and return the time delta to the previous stopwatch operation.
162 The stopwatch needs to be running to take a split time. See property :data:`IsRunning` to check if the stopwatch
163 is running and the split operation is possible. |br|
164 Depending on the previous operation, the time delta will be:
166 * the duration from start operation to the first split.
167 * the duration from last resume to this split.
169 :returns: Duration in seconds since last stopwatch operation
170 :raises StopwatchException: If stopwatch was not started or resumed.
171 """
172 pauseTime = perf_counter_ns()
174 if self._resumeTime is None:
175 raise StopwatchException("Stopwatch was not started or resumed.")
177 diff = (pauseTime - self._resumeTime) / 1e9
178 self._splits.append((diff, True))
179 self._resumeTime = pauseTime
181 return diff
183 def Pause(self) -> float:
184 """
185 Pause the stopwatch and return the time delta to the previous stopwatch operation.
187 The stopwatch needs to be running to pause it. See property :data:`IsRunning` to check if the stopwatch is running
188 and the pause operation is possible. |br|
189 Depending on the previous operation, the time delta will be:
191 * the duration from start operation to the first pause.
192 * the duration from last resume to this pause.
194 :returns: Duration in seconds since last stopwatch operation
195 :raises StopwatchException: If stopwatch was not started or resumed.
196 """
197 self._pauseTime = perf_counter_ns()
199 if self._resumeTime is None:
200 raise StopwatchException("Stopwatch was not started or resumed.")
202 diff = (self._pauseTime - self._resumeTime) / 1e9
203 self._splits.append((diff, True))
204 self._resumeTime = None
206 return diff
208 def Resume(self) -> float:
209 """
210 Resume the stopwatch and return the time delta to the previous pause operation.
212 The stopwatch needs to be paused to resume it. See property :data:`IsPaused` to check if the stopwatch is paused
213 and the resume operation is possible. |br|
214 The time delta will be the duration from last pause to this resume.
216 :returns: Duration in seconds since last pause operation
217 :raises StopwatchException: If stopwatch was not paused.
218 """
219 self._resumeTime = perf_counter_ns()
221 if self._pauseTime is None:
222 raise StopwatchException("Stopwatch was not paused.")
224 diff = (self._resumeTime - self._pauseTime) / 1e9
225 self._splits.append((diff, False))
226 self._pauseTime = None
228 return diff
230 def Stop(self):
231 """
232 Stop the stopwatch and return the time delta to the previous stopwatch operation.
234 The stopwatch needs to be started to stop it. See property :data:`IsStarted` to check if the stopwatch was started
235 and the stop operation is possible. |br|
236 Depending on the previous operation, the time delta will be:
238 * the duration from start operation to the stop operation.
239 * the duration from last resume to the stop operation.
241 :returns: Duration in seconds since last stopwatch operation
242 :raises StopwatchException: If stopwatch was not started.
243 :raises StopwatchException: If stopwatch was already stopped.
244 """
245 self._stopTime = perf_counter_ns()
246 self._endTime = datetime.now()
248 if self._startTime is None:
249 raise StopwatchException("Stopwatch was never started.")
250 if self._totalTime is not None: 250 ↛ 251line 250 didn't jump to line 251 because the condition on line 250 was never true
251 raise StopwatchException("Stopwatch was already stopped.")
253 if len(self._splits) == 0: # was never paused
254 diff = (self._stopTime - self._startTime) / 1e9
255 elif self._resumeTime is None: # is paused
256 diff = (self._stopTime - self._pauseTime) / 1e9
257 self._splits.append((diff, False))
258 else: # is running
259 diff = (self._stopTime - self._resumeTime) / 1e9
260 self._splits.append((diff, True))
262 self._pauseTime = None
263 self._resumeTime = None
264 self._totalTime = self._stopTime - self._startTime
266 beginEndDiff = self._endTime - self._beginTime
268 return diff
270 @readonly
271 def Name(self) -> Nullable[str]:
272 """
273 Read-only property returning the name of the stopwatch.
275 :return: Name of the stopwatch.
276 """
277 return self._name
279 @readonly
280 def IsStarted(self) -> bool:
281 """
282 Read-only property returning the IsStarted state of the stopwatch.
284 :return: True, if stopwatch was started.
285 """
286 return self._startTime is not None and self._stopTime is None
288 @readonly
289 def IsRunning(self) -> bool:
290 """
291 Read-only property returning the IsRunning state of the stopwatch.
293 :return: True, if stopwatch was started and is currently not paused.
294 """
295 return self._startTime is not None and self._resumeTime is not None
297 @readonly
298 def IsPaused(self) -> bool:
299 """
300 Read-only property returning the IsPaused state of the stopwatch.
302 :return: True, if stopwatch was started and is currently paused.
303 """
304 return self._startTime is not None and self._pauseTime is not None
306 @readonly
307 def IsStopped(self) -> bool:
308 """
309 Read-only property returning the IsStopped state of the stopwatch.
311 :return: True, if stopwatch was stopped.
312 """
313 return self._stopTime is not None
315 @readonly
316 def StartTime(self) -> Nullable[datetime]:
317 """
318 Read-only property returning the absolute time when the stopwatch was started.
320 :return: The time when the stopwatch was started, otherwise None.
321 """
322 return self._beginTime
324 @readonly
325 def StopTime(self) -> Nullable[datetime]:
326 """
327 Read-only property returning the absolute time when the stopwatch was stopped.
329 :return: The time when the stopwatch was stopped, otherwise None.
330 """
331 return self._endTime
333 @readonly
334 def HasSplitTimes(self) -> bool:
335 """
336 Read-only property checking if split times have been taken.
338 :return: True, if split times have been taken.
339 """
340 return len(self._splits) > 1
342 @readonly
343 def SplitCount(self) -> int:
344 """
345 Read-only property returning the number of split times.
347 :return: Number of split times.
348 """
349 return len(self._splits)
351 @readonly
352 def ActiveCount(self) -> int:
353 """
354 Read-only property returning the number of active split times.
356 :return: Number of active split times.
358 .. warning::
360 This won't include all activities, unless the stopwatch got stopped.
361 """
362 if self._startTime is None: 362 ↛ 363line 362 didn't jump to line 363 because the condition on line 362 was never true
363 return 0
365 return len(list(t for t, a in self._splits if a is True))
367 @readonly
368 def InactiveCount(self) -> int:
369 """
370 Read-only property returning the number of active split times.
372 :return: Number of active split times.
374 .. warning::
376 This won't include all inactivities, unless the stopwatch got stopped.
377 """
378 if self._startTime is None: 378 ↛ 379line 378 didn't jump to line 379 because the condition on line 378 was never true
379 return 0
381 return len(list(t for t, a in self._splits if a is False))
383 @readonly
384 def Activity(self) -> float:
385 """
386 Read-only property returning the duration of all active split times.
388 If the stopwatch is currently running, the duration since start or last resume operation will be included.
390 :return: Duration of all active split times in seconds. If the stopwatch was never started, the return value will
391 be 0.0.
392 """
393 if self._startTime is None: 393 ↛ 394line 393 didn't jump to line 394 because the condition on line 393 was never true
394 return 0.0
396 currentDiff = 0.0 if self._resumeTime is None else ((perf_counter_ns() - self._resumeTime) / 1e9)
397 return sum(t for t, a in self._splits if a is True) + currentDiff
399 @readonly
400 def Inactivity(self) -> float:
401 """
402 Read-only property returning the duration of all inactive split times.
404 If the stopwatch is currently paused, the duration since last pause operation will be included.
406 :return: Duration of all inactive split times in seconds. If the stopwatch was never started, the return value will
407 be 0.0.
408 """
409 if self._startTime is None: 409 ↛ 410line 409 didn't jump to line 410 because the condition on line 409 was never true
410 return 0.0
412 currentDiff = 0.0 if self._pauseTime is None else ((perf_counter_ns() - self._pauseTime) / 1e9)
413 return sum(t for t, a in self._splits if a is False) + currentDiff
415 @readonly
416 def Duration(self) -> float:
417 """
418 Read-only property returning the duration from start operation to stop operation.
420 If the stopwatch is not yet stopped, the duration from start to now is returned.
422 :return: Duration since stopwatch was started in seconds. If the stopwatch was never started, the return value will
423 be 0.0.
424 """
425 if self._startTime is None: 425 ↛ 426line 425 didn't jump to line 426 because the condition on line 425 was never true
426 return 0.0
428 return ((perf_counter_ns() - self._startTime) if self._stopTime is None else self._totalTime) / 1e9
430 @readonly
431 def Exclude(self) -> ExcludeContextManager:
432 if self._excludeContextManager is None:
433 excludeContextManager = ExcludeContextManager(self)
434 self._excludeContextManager = excludeContextManager
436 return excludeContextManager
438 def __enter__(self) -> "Stopwatch": # TODO: Python 3.11: -> Self:
439 """
440 Implementation of the :ref:`context manager protocol's <context-managers>` ``__enter__(...)`` method.
442 An unstarted stopwatch will be started. A paused stopwatch will be resumed.
444 :return: The stopwatch itself.
445 """
446 if self._startTime is None: # start stopwatch
447 self._beginTime = datetime.now()
448 self._resumeTime = self._startTime = perf_counter_ns()
449 elif self._pauseTime is not None: # resume after pause
450 self._resumeTime = perf_counter_ns()
452 diff = (self._resumeTime - self._pauseTime) / 1e9
453 self._splits.append((diff, False))
454 self._pauseTime = None
455 elif self._resumeTime is not None: # is running? 455 ↛ 456line 455 didn't jump to line 456 because the condition on line 455 was never true
456 raise StopwatchException("Stopwatch is currently running and can not be started/resumed again.")
457 elif self._stopTime is not None: # is stopped? 457 ↛ 460line 457 didn't jump to line 460 because the condition on line 457 was always true
458 raise StopwatchException(f"Stopwatch was already stopped.")
459 else:
460 raise StopwatchException(f"Internal error.")
462 return self
464 def __exit__(self, exc_type: Type[Exception], exc_val: Exception, exc_tb: Traceback) -> bool:
465 """
466 Implementation of the :ref:`context manager protocol's <context-managers>` ``__exit__(...)`` method.
468 A running stopwatch will be paused or stopped depending on the configured ``preferPause`` behavior.
470 :param exc_type: Exception type, otherwise None.
471 :param exc_val: Exception object, otherwise None.
472 :param exc_tb: Exception's traceback, otherwise None.
473 :returns: True, if exceptions should be suppressed.
474 """
475 if self._startTime is None: # never started? 475 ↛ 476line 475 didn't jump to line 476 because the condition on line 475 was never true
476 raise StopwatchException("Stopwatch was never started.")
477 elif self._stopTime is not None: 477 ↛ 478line 477 didn't jump to line 478 because the condition on line 477 was never true
478 raise StopwatchException("Stopwatch was already stopped.")
479 elif self._resumeTime is not None: # pause or stop 479 ↛ 496line 479 didn't jump to line 496 because the condition on line 479 was always true
480 if self._preferPause:
481 self._pauseTime = perf_counter_ns()
482 diff = (self._pauseTime - self._resumeTime) / 1e9
483 self._splits.append((diff, True))
484 self._resumeTime = None
485 else:
486 self._stopTime = perf_counter_ns()
487 self._endTime = datetime.now()
489 diff = (self._stopTime - self._resumeTime) / 1e9
490 self._splits.append((diff, True))
492 self._pauseTime = None
493 self._resumeTime = None
494 self._totalTime = self._stopTime - self._startTime
495 else:
496 raise StopwatchException("Stopwatch was not resumed.")
499 def __len__(self):
500 """
501 Implementation of ``len(...)`` to return the number of split times.
503 :return: Number of split times.
504 """
505 return len(self._splits)
507 def __getitem__(self, index: int) -> Tuple[float, bool]:
508 """
509 Implementation of ``split = object[i]`` to return the i-th split time.
511 :param index: Index to access the i-th split time.
512 :return: i-th split time as a tuple of: |br|
513 (1) delta time to the previous stopwatch operation and |br|
514 (2) a boolean indicating if the split was an activity (true) or inactivity (false).
515 :raises KeyError: If index *i* doesn't exist.
516 """
517 return self._splits[index]
519 def __iter__(self) -> Iterator[Tuple[float, bool]]:
520 """
521 Return an iterator of tuples to iterate all split times.
523 If the stopwatch is not stopped yet, the last split won't be included.
525 :return: Iterator of split time tuples of: |br|
526 (1) delta time to the previous stopwatch operation and |br|
527 (2) a boolean indicating if the split was an activity (true) or inactivity (false).
528 """
529 return self._splits.__iter__()
531 def __str__(self):
532 name = f" {self._name}" if self._name is not None else ""
533 if self.IsStopped:
534 return f"Stopwatch{name} (stopped): {self._beginTime} -> {self._endTime}: {self._totalTime}"
535 elif self.IsRunning:
536 return f"Stopwatch{name} (running): {self._beginTime} -> now: {self.Duration}"
537 elif self.IsPaused:
538 return f"Stopwatch{name} (paused): {self._beginTime} -> now: {self.Duration}"
539 else:
540 return f"Stopwatch{name}: not started"