Coverage for pyTooling/Stopwatch/__init__.py: 86%
209 statements
« prev ^ index » next coverage.py v7.10.3, created at 2025-08-12 20:40 +0000
« prev ^ index » next coverage.py v7.10.3, created at 2025-08-12 20:40 +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"""
37from datetime import datetime
38from inspect import Traceback
39from time import perf_counter_ns
40from types import TracebackType
41from typing import List, Optional as Nullable, Iterator, Tuple, Type
43# Python 3.11: use Self if returning the own object: , Self
45try:
46 from pyTooling.Decorators import export, readonly
47 from pyTooling.MetaClasses import SlottedObject
48 from pyTooling.Exceptions import ToolingException
49 from pyTooling.Platform import CurrentPlatform
50except (ImportError, ModuleNotFoundError): # pragma: no cover
51 print("[pyTooling.Stopwatch] Could not import from 'pyTooling.*'!")
53 try:
54 from Decorators import export, readonly
55 from MetaClasses import SlottedObject
56 from Exceptions import ToolingException
57 from Platform import CurrentPlatform
58 except (ImportError, ModuleNotFoundError) as ex: # pragma: no cover
59 print("[pyTooling.Stopwatch] Could not import directly!")
60 raise ex
63@export
64class StopwatchException(ToolingException):
65 """This exception is caused by wrong usage of the stopwatch."""
68@export
69class ExcludeContextManager:
70 """
71 A stopwatch context manager for excluding certain time spans from measurement.
73 While a normal stopwatch's embedded context manager (re)starts the stopwatch on every *enter* event and pauses the
74 stopwatch on every *exit* event, this context manager pauses on *enter* events and restarts on every *exit* event.
75 """
76 _stopwatch: "Stopwatch" #: Reference to the stopwatch.
78 def __init__(self, stopwatch: "Stopwatch") -> None:
79 """
80 Initializes an excluding context manager.
82 :param stopwatch: Reference to the stopwatch.
83 """
84 self._stopwatch = stopwatch
86 def __enter__(self) -> "ExcludeContextManager": # TODO: Python 3.11: -> Self:
87 """
88 Enter the context and pause the stopwatch.
90 :returns: Excluding stopwatch context manager instance.
91 """
92 self._stopwatch.Pause()
94 return self
96 def __exit__(
97 self,
98 exc_type: Nullable[Type[BaseException]] = None,
99 exc_val: Nullable[BaseException] = None,
100 exc_tb: Nullable[TracebackType] = None
101 ) -> Nullable[bool]:
102 """
103 Exit the context and restart stopwatch.
105 :param exc_type: Exception type
106 :param exc_val: Exception instance
107 :param exc_tb: Exception's traceback.
108 :returns: ``None``
109 """
110 self._stopwatch.Resume()
113@export
114class Stopwatch(SlottedObject):
115 """
116 The stopwatch implements a solution to measure and collect timings.
118 The time measurement can be started, paused, resumed and stopped. More over, split times can be taken too. The
119 measurement is based on :func:`time.perf_counter_ns`. Additionally, starting and stopping is preserved as absolute
120 time via :meth:`datetime.datetime.now`.
122 Every split time taken is a time delta to the previous operation. These are preserved in an internal sequence of
123 splits. This sequence includes time deltas of activity and inactivity. Thus, a running stopwatch can be split as well
124 as a paused stopwatch.
126 The stopwatch can also be used in a :ref:`with-statement <with>`, because it implements the :ref:`context manager protocol <context-managers>`.
127 """
129 _name: Nullable[str]
130 _preferPause: bool
132 _beginTime: Nullable[datetime]
133 _endTime: Nullable[datetime]
134 _startTime: Nullable[int]
135 _resumeTime: Nullable[int]
136 _pauseTime: Nullable[int]
137 _stopTime: Nullable[int]
138 _totalTime: Nullable[int]
139 _splits: List[Tuple[float, bool]]
141 _excludeContextManager: ExcludeContextManager
143 def __init__(self, name: str = None, started: bool = False, preferPause: bool = False) -> None:
144 """
145 Initializes the fields of the stopwatch.
147 If parameter ``started`` is set to true, the stopwatch will immediately start.
149 :param name: Optional name of the stopwatch.
150 :param preferPause: Optional setting, if __exit__(...) in a contex should prefer pause or stop behavior.
151 :param started: Optional flag, if the stopwatch should be started immediately.
152 """
153 self._name = name
154 self._preferPause = preferPause
156 self._endTime = None
157 self._pauseTime = None
158 self._stopTime = None
159 self._totalTime = None
160 self._splits = []
162 self._excludeContextManager = None
164 if started is False: 164 ↛ 169line 164 didn't jump to line 169 because the condition on line 164 was always true
165 self._beginTime = None
166 self._startTime = None
167 self._resumeTime = None
168 else:
169 self._beginTime = datetime.now()
170 self._resumeTime = self._startTime = perf_counter_ns()
172 def Start(self) -> None:
173 """
174 Start the stopwatch.
176 A stopwatch can only be started once. There is no restart or reset operation provided.
178 :raises StopwatchException: If stopwatch was already started.
179 :raises StopwatchException: If stopwatch was already started and stopped.
180 """
181 if self._startTime is not None:
182 raise StopwatchException("Stopwatch was already started.")
183 if self._stopTime is not None: 183 ↛ 184line 183 didn't jump to line 184 because the condition on line 183 was never true
184 raise StopwatchException("Stopwatch was already used (started and stopped).")
186 self._beginTime = datetime.now()
187 self._resumeTime = self._startTime = perf_counter_ns()
189 def Split(self) -> float:
190 """
191 Take a split time and return the time delta to the previous stopwatch operation.
193 The stopwatch needs to be running to take a split time. See property :data:`IsRunning` to check if the stopwatch
194 is running and the split operation is possible. |br|
195 Depending on the previous operation, the time delta will be:
197 * the duration from start operation to the first split.
198 * the duration from last resume to this split.
200 :returns: Duration in seconds since last stopwatch operation
201 :raises StopwatchException: If stopwatch was not started or resumed.
202 """
203 pauseTime = perf_counter_ns()
205 if self._resumeTime is None:
206 raise StopwatchException("Stopwatch was not started or resumed.")
208 diff = (pauseTime - self._resumeTime) / 1e9
209 self._splits.append((diff, True))
210 self._resumeTime = pauseTime
212 return diff
214 def Pause(self) -> float:
215 """
216 Pause the stopwatch and return the time delta to the previous stopwatch operation.
218 The stopwatch needs to be running to pause it. See property :data:`IsRunning` to check if the stopwatch is running
219 and the pause operation is possible. |br|
220 Depending on the previous operation, the time delta will be:
222 * the duration from start operation to the first pause.
223 * the duration from last resume to this pause.
225 :returns: Duration in seconds since last stopwatch operation
226 :raises StopwatchException: If stopwatch was not started or resumed.
227 """
228 self._pauseTime = perf_counter_ns()
230 if self._resumeTime is None:
231 raise StopwatchException("Stopwatch was not started or resumed.")
233 diff = (self._pauseTime - self._resumeTime) / 1e9
234 self._splits.append((diff, True))
235 self._resumeTime = None
237 return diff
239 def Resume(self) -> float:
240 """
241 Resume the stopwatch and return the time delta to the previous pause operation.
243 The stopwatch needs to be paused to resume it. See property :data:`IsPaused` to check if the stopwatch is paused
244 and the resume operation is possible. |br|
245 The time delta will be the duration from last pause to this resume.
247 :returns: Duration in seconds since last pause operation
248 :raises StopwatchException: If stopwatch was not paused.
249 """
250 self._resumeTime = perf_counter_ns()
252 if self._pauseTime is None:
253 raise StopwatchException("Stopwatch was not paused.")
255 diff = (self._resumeTime - self._pauseTime) / 1e9
256 self._splits.append((diff, False))
257 self._pauseTime = None
259 return diff
261 def Stop(self):
262 """
263 Stop the stopwatch and return the time delta to the previous stopwatch operation.
265 The stopwatch needs to be started to stop it. See property :data:`IsStarted` to check if the stopwatch was started
266 and the stop operation is possible. |br|
267 Depending on the previous operation, the time delta will be:
269 * the duration from start operation to the stop operation.
270 * the duration from last resume to the stop operation.
272 :returns: Duration in seconds since last stopwatch operation
273 :raises StopwatchException: If stopwatch was not started.
274 :raises StopwatchException: If stopwatch was already stopped.
275 """
276 self._stopTime = perf_counter_ns()
277 self._endTime = datetime.now()
279 if self._startTime is None:
280 raise StopwatchException("Stopwatch was never started.")
281 if self._totalTime is not None: 281 ↛ 282line 281 didn't jump to line 282 because the condition on line 281 was never true
282 raise StopwatchException("Stopwatch was already stopped.")
284 if len(self._splits) == 0: # was never paused
285 diff = (self._stopTime - self._startTime) / 1e9
286 elif self._resumeTime is None: # is paused
287 diff = (self._stopTime - self._pauseTime) / 1e9
288 self._splits.append((diff, False))
289 else: # is running
290 diff = (self._stopTime - self._resumeTime) / 1e9
291 self._splits.append((diff, True))
293 self._pauseTime = None
294 self._resumeTime = None
295 self._totalTime = self._stopTime - self._startTime
297 beginEndDiff = self._endTime - self._beginTime
299 return diff
301 @readonly
302 def Name(self) -> Nullable[str]:
303 """
304 Read-only property returning the name of the stopwatch.
306 :return: Name of the stopwatch.
307 """
308 return self._name
310 @readonly
311 def IsStarted(self) -> bool:
312 """
313 Read-only property returning the IsStarted state of the stopwatch.
315 :return: True, if stopwatch was started.
316 """
317 return self._startTime is not None and self._stopTime is None
319 @readonly
320 def IsRunning(self) -> bool:
321 """
322 Read-only property returning the IsRunning state of the stopwatch.
324 :return: True, if stopwatch was started and is currently not paused.
325 """
326 return self._startTime is not None and self._resumeTime is not None
328 @readonly
329 def IsPaused(self) -> bool:
330 """
331 Read-only property returning the IsPaused state of the stopwatch.
333 :return: True, if stopwatch was started and is currently paused.
334 """
335 return self._startTime is not None and self._pauseTime is not None
337 @readonly
338 def IsStopped(self) -> bool:
339 """
340 Read-only property returning the IsStopped state of the stopwatch.
342 :return: True, if stopwatch was stopped.
343 """
344 return self._stopTime is not None
346 @readonly
347 def StartTime(self) -> Nullable[datetime]:
348 """
349 Read-only property returning the absolute time when the stopwatch was started.
351 :return: The time when the stopwatch was started, otherwise None.
352 """
353 return self._beginTime
355 @readonly
356 def StopTime(self) -> Nullable[datetime]:
357 """
358 Read-only property returning the absolute time when the stopwatch was stopped.
360 :return: The time when the stopwatch was stopped, otherwise None.
361 """
362 return self._endTime
364 @readonly
365 def HasSplitTimes(self) -> bool:
366 """
367 Read-only property checking if split times have been taken.
369 :return: True, if split times have been taken.
370 """
371 return len(self._splits) > 1
373 @readonly
374 def SplitCount(self) -> int:
375 """
376 Read-only property returning the number of split times.
378 :return: Number of split times.
379 """
380 return len(self._splits)
382 @readonly
383 def ActiveCount(self) -> int:
384 """
385 Read-only property returning the number of active split times.
387 :return: Number of active split times.
389 .. warning::
391 This won't include all activities, unless the stopwatch got stopped.
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
396 return len(list(t for t, a in self._splits if a is True))
398 @readonly
399 def InactiveCount(self) -> int:
400 """
401 Read-only property returning the number of active split times.
403 :return: Number of active split times.
405 .. warning::
407 This won't include all inactivities, unless the stopwatch got stopped.
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
412 return len(list(t for t, a in self._splits if a is False))
414 @readonly
415 def Activity(self) -> float:
416 """
417 Read-only property returning the duration of all active split times.
419 If the stopwatch is currently running, the duration since start or last resume operation will be included.
421 :return: Duration of all active split times in seconds. If the stopwatch was never started, the return value will
422 be 0.0.
423 """
424 if self._startTime is None: 424 ↛ 425line 424 didn't jump to line 425 because the condition on line 424 was never true
425 return 0.0
427 currentDiff = 0.0 if self._resumeTime is None else ((perf_counter_ns() - self._resumeTime) / 1e9)
428 return sum(t for t, a in self._splits if a is True) + currentDiff
430 @readonly
431 def Inactivity(self) -> float:
432 """
433 Read-only property returning the duration of all inactive split times.
435 If the stopwatch is currently paused, the duration since last pause operation will be included.
437 :return: Duration of all inactive split times in seconds. If the stopwatch was never started, the return value will
438 be 0.0.
439 """
440 if self._startTime is None: 440 ↛ 441line 440 didn't jump to line 441 because the condition on line 440 was never true
441 return 0.0
443 currentDiff = 0.0 if self._pauseTime is None else ((perf_counter_ns() - self._pauseTime) / 1e9)
444 return sum(t for t, a in self._splits if a is False) + currentDiff
446 @readonly
447 def Duration(self) -> float:
448 """
449 Read-only property returning the duration from start operation to stop operation.
451 If the stopwatch is not yet stopped, the duration from start to now is returned.
453 :return: Duration since stopwatch was started in seconds. If the stopwatch was never started, the return value will
454 be 0.0.
455 """
456 if self._startTime is None: 456 ↛ 457line 456 didn't jump to line 457 because the condition on line 456 was never true
457 return 0.0
459 return ((perf_counter_ns() - self._startTime) if self._stopTime is None else self._totalTime) / 1e9
461 @readonly
462 def Exclude(self) -> ExcludeContextManager:
463 """
464 Return an *exclude* context manager for the stopwatch instance.
466 :returns: An excluding context manager.
467 """
468 if self._excludeContextManager is None:
469 excludeContextManager = ExcludeContextManager(self)
470 self._excludeContextManager = excludeContextManager
472 return excludeContextManager
474 def __enter__(self) -> "Stopwatch": # TODO: Python 3.11: -> Self:
475 """
476 Implementation of the :ref:`context manager protocol's <context-managers>` ``__enter__(...)`` method.
478 An unstarted stopwatch will be started. A paused stopwatch will be resumed.
480 :return: The stopwatch itself.
481 """
482 if self._startTime is None: # start stopwatch
483 self._beginTime = datetime.now()
484 self._resumeTime = self._startTime = perf_counter_ns()
485 elif self._pauseTime is not None: # resume after pause
486 self._resumeTime = perf_counter_ns()
488 diff = (self._resumeTime - self._pauseTime) / 1e9
489 self._splits.append((diff, False))
490 self._pauseTime = None
491 elif self._resumeTime is not None: # is running? 491 ↛ 492line 491 didn't jump to line 492 because the condition on line 491 was never true
492 raise StopwatchException("Stopwatch is currently running and can not be started/resumed again.")
493 elif self._stopTime is not None: # is stopped? 493 ↛ 496line 493 didn't jump to line 496 because the condition on line 493 was always true
494 raise StopwatchException(f"Stopwatch was already stopped.")
495 else:
496 raise StopwatchException(f"Internal error.")
498 return self
500 def __exit__(self, exc_type: Type[Exception], exc_val: Exception, exc_tb: Traceback) -> bool:
501 """
502 Implementation of the :ref:`context manager protocol's <context-managers>` ``__exit__(...)`` method.
504 A running stopwatch will be paused or stopped depending on the configured ``preferPause`` behavior.
506 :param exc_type: Exception type, otherwise None.
507 :param exc_val: Exception object, otherwise None.
508 :param exc_tb: Exception's traceback, otherwise None.
509 :returns: True, if exceptions should be suppressed.
510 """
511 if self._startTime is None: # never started? 511 ↛ 512line 511 didn't jump to line 512 because the condition on line 511 was never true
512 raise StopwatchException("Stopwatch was never started.")
513 elif self._stopTime is not None: 513 ↛ 514line 513 didn't jump to line 514 because the condition on line 513 was never true
514 raise StopwatchException("Stopwatch was already stopped.")
515 elif self._resumeTime is not None: # pause or stop 515 ↛ 532line 515 didn't jump to line 532 because the condition on line 515 was always true
516 if self._preferPause:
517 self._pauseTime = perf_counter_ns()
518 diff = (self._pauseTime - self._resumeTime) / 1e9
519 self._splits.append((diff, True))
520 self._resumeTime = None
521 else:
522 self._stopTime = perf_counter_ns()
523 self._endTime = datetime.now()
525 diff = (self._stopTime - self._resumeTime) / 1e9
526 self._splits.append((diff, True))
528 self._pauseTime = None
529 self._resumeTime = None
530 self._totalTime = self._stopTime - self._startTime
531 else:
532 raise StopwatchException("Stopwatch was not resumed.")
535 def __len__(self):
536 """
537 Implementation of ``len(...)`` to return the number of split times.
539 :return: Number of split times.
540 """
541 return len(self._splits)
543 def __getitem__(self, index: int) -> Tuple[float, bool]:
544 """
545 Implementation of ``split = object[i]`` to return the i-th split time.
547 :param index: Index to access the i-th split time.
548 :return: i-th split time as a tuple of: |br|
549 (1) delta time to the previous stopwatch operation and |br|
550 (2) a boolean indicating if the split was an activity (true) or inactivity (false).
551 :raises KeyError: If index *i* doesn't exist.
552 """
553 return self._splits[index]
555 def __iter__(self) -> Iterator[Tuple[float, bool]]:
556 """
557 Return an iterator of tuples to iterate all split times.
559 If the stopwatch is not stopped yet, the last split won't be included.
561 :return: Iterator of split time tuples of: |br|
562 (1) delta time to the previous stopwatch operation and |br|
563 (2) a boolean indicating if the split was an activity (true) or inactivity (false).
564 """
565 return self._splits.__iter__()
567 def __str__(self):
568 """
569 Returns the stopwatch's state and its measured time span.
571 :returns: The string equivalent of the stopwatch.
572 """
573 name = f" {self._name}" if self._name is not None else ""
574 if self.IsStopped:
575 return f"Stopwatch{name} (stopped): {self._beginTime} -> {self._endTime}: {self._totalTime}"
576 elif self.IsRunning:
577 return f"Stopwatch{name} (running): {self._beginTime} -> now: {self.Duration}"
578 elif self.IsPaused:
579 return f"Stopwatch{name} (paused): {self._beginTime} -> now: {self.Duration}"
580 else:
581 return f"Stopwatch{name}: not started"