Coverage for pyTooling / Stopwatch / __init__.py: 85%
208 statements
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-08 23:46 +0000
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-08 23:46 +0000
1# ==================================================================================================================== #
2# _____ _ _ ____ _ _ _ #
3# _ __ _ |_ _|__ ___ | (_)_ __ __ _ / ___|| |_ ___ _ ____ ____ _| |_ ___| |__ #
4# | '_ \| | | || |/ _ \ / _ \| | | '_ \ / _` | \___ \| __/ _ \| '_ \ \ /\ / / _` | __/ __| '_ \ #
5# | |_) | |_| || | (_) | (_) | | | | | | (_| |_ ___) | || (_) | |_) \ V V / (_| | || (__| | | | #
6# | .__/ \__, ||_|\___/ \___/|_|_|_| |_|\__, (_)____/ \__\___/| .__/ \_/\_/ \__,_|\__\___|_| |_| #
7# |_| |___/ |___/ |_| #
8# ==================================================================================================================== #
9# Authors: #
10# Patrick Lehmann #
11# #
12# License: #
13# ==================================================================================================================== #
14# Copyright 2017-2026 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::
36 See :ref:`high-level help <COMMON/Stopwatch>` for explanations and usage examples.
37"""
39from datetime import datetime
40from time import perf_counter_ns
41from types import TracebackType
42from typing import List, Optional as Nullable, Iterator, Tuple, Type, Self
44try:
45 from pyTooling.Decorators import export, readonly
46 from pyTooling.MetaClasses import SlottedObject
47 from pyTooling.Exceptions import ToolingException
48 from pyTooling.Platform import CurrentPlatform
49except (ImportError, ModuleNotFoundError): # pragma: no cover
50 print("[pyTooling.Stopwatch] Could not import from 'pyTooling.*'!")
52 try:
53 from Decorators import export, readonly
54 from MetaClasses import SlottedObject
55 from Exceptions import ToolingException
56 from Platform import CurrentPlatform
57 except (ImportError, ModuleNotFoundError) as ex: # pragma: no cover
58 print("[pyTooling.Stopwatch] Could not import directly!")
59 raise ex
62@export
63class StopwatchException(ToolingException):
64 """This exception is caused by wrong usage of the stopwatch."""
67@export
68class ExcludeContextManager:
69 """
70 A stopwatch context manager for excluding certain time spans from measurement.
72 While a normal stopwatch's embedded context manager (re)starts the stopwatch on every *enter* event and pauses the
73 stopwatch on every *exit* event, this context manager pauses on *enter* events and restarts on every *exit* event.
74 """
75 _stopwatch: "Stopwatch" #: Reference to the stopwatch.
77 def __init__(self, stopwatch: "Stopwatch") -> None:
78 """
79 Initializes an excluding context manager.
81 :param stopwatch: Reference to the stopwatch.
82 """
83 self._stopwatch = stopwatch
85 def __enter__(self) -> Self:
86 """
87 Enter the context and pause the stopwatch.
89 :returns: Excluding stopwatch context manager instance.
90 """
91 self._stopwatch.Pause()
93 return self
95 def __exit__(
96 self,
97 exc_type: Nullable[Type[BaseException]] = None,
98 exc_val: Nullable[BaseException] = None,
99 exc_tb: Nullable[TracebackType] = None
100 ) -> Nullable[bool]:
101 """
102 Exit the context and restart stopwatch.
104 :param exc_type: Exception type
105 :param exc_val: Exception instance
106 :param exc_tb: Exception's traceback.
107 :returns: ``None``
108 """
109 self._stopwatch.Resume()
112@export
113class Stopwatch(SlottedObject):
114 """
115 The stopwatch implements a solution to measure and collect timings.
117 The time measurement can be started, paused, resumed and stopped. More over, split times can be taken too. The
118 measurement is based on :func:`time.perf_counter_ns`. Additionally, starting and stopping is preserved as absolute
119 time via :meth:`datetime.datetime.now`.
121 Every split time taken is a time delta to the previous operation. These are preserved in an internal sequence of
122 splits. This sequence includes time deltas of activity and inactivity. Thus, a running stopwatch can be split as well
123 as a paused stopwatch.
125 The stopwatch can also be used in a :ref:`with-statement <with>`, because it implements the :ref:`context manager protocol <context-managers>`.
126 """
128 _name: Nullable[str]
129 _preferPause: bool
131 _beginTime: Nullable[datetime]
132 _endTime: Nullable[datetime]
133 _startTime: Nullable[int]
134 _resumeTime: Nullable[int]
135 _pauseTime: Nullable[int]
136 _stopTime: Nullable[int]
137 _totalTime: Nullable[int]
138 _splits: List[Tuple[float, bool]]
140 _excludeContextManager: ExcludeContextManager
142 def __init__(self, name: str = None, started: bool = False, preferPause: bool = False) -> None:
143 """
144 Initializes the fields of the stopwatch.
146 If parameter ``started`` is set to true, the stopwatch will immediately start.
148 :param name: Optional name of the stopwatch.
149 :param preferPause: Optional setting, if __exit__(...) in a contex should prefer pause or stop behavior.
150 :param started: Optional flag, if the stopwatch should be started immediately.
151 """
152 self._name = name
153 self._preferPause = preferPause
155 self._endTime = None
156 self._pauseTime = None
157 self._stopTime = None
158 self._totalTime = None
159 self._splits = []
161 self._excludeContextManager = None
163 if started is False: 163 ↛ 168line 163 didn't jump to line 168 because the condition on line 163 was always true
164 self._beginTime = None
165 self._startTime = None
166 self._resumeTime = None
167 else:
168 self._beginTime = datetime.now()
169 self._resumeTime = self._startTime = perf_counter_ns()
171 def Start(self) -> None:
172 """
173 Start the stopwatch.
175 A stopwatch can only be started once. There is no restart or reset operation provided.
177 :raises StopwatchException: If stopwatch was already started.
178 :raises StopwatchException: If stopwatch was already started and stopped.
179 """
180 if self._startTime is not None:
181 raise StopwatchException("Stopwatch was already started.")
182 if self._stopTime is not None: 182 ↛ 183line 182 didn't jump to line 183 because the condition on line 182 was never true
183 raise StopwatchException("Stopwatch was already used (started and stopped).")
185 self._beginTime = datetime.now()
186 self._resumeTime = self._startTime = perf_counter_ns()
188 def Split(self) -> float:
189 """
190 Take a split time and return the time delta to the previous stopwatch operation.
192 The stopwatch needs to be running to take a split time. See property :data:`IsRunning` to check if the stopwatch
193 is running and the split operation is possible. |br|
194 Depending on the previous operation, the time delta will be:
196 * the duration from start operation to the first split.
197 * the duration from last resume to this split.
199 :returns: Duration in seconds since last stopwatch operation
200 :raises StopwatchException: If stopwatch was not started or resumed.
201 """
202 pauseTime = perf_counter_ns()
204 if self._resumeTime is None:
205 raise StopwatchException("Stopwatch was not started or resumed.")
207 diff = (pauseTime - self._resumeTime) / 1e9
208 self._splits.append((diff, True))
209 self._resumeTime = pauseTime
211 return diff
213 def Pause(self) -> float:
214 """
215 Pause the stopwatch and return the time delta to the previous stopwatch operation.
217 The stopwatch needs to be running to pause it. See property :data:`IsRunning` to check if the stopwatch is running
218 and the pause operation is possible. |br|
219 Depending on the previous operation, the time delta will be:
221 * the duration from start operation to the first pause.
222 * the duration from last resume to this pause.
224 :returns: Duration in seconds since last stopwatch operation
225 :raises StopwatchException: If stopwatch was not started or resumed.
226 """
227 self._pauseTime = perf_counter_ns()
229 if self._resumeTime is None:
230 raise StopwatchException("Stopwatch was not started or resumed.")
232 diff = (self._pauseTime - self._resumeTime) / 1e9
233 self._splits.append((diff, True))
234 self._resumeTime = None
236 return diff
238 def Resume(self) -> float:
239 """
240 Resume the stopwatch and return the time delta to the previous pause operation.
242 The stopwatch needs to be paused to resume it. See property :data:`IsPaused` to check if the stopwatch is paused
243 and the resume operation is possible. |br|
244 The time delta will be the duration from last pause to this resume.
246 :returns: Duration in seconds since last pause operation
247 :raises StopwatchException: If stopwatch was not paused.
248 """
249 self._resumeTime = perf_counter_ns()
251 if self._pauseTime is None:
252 raise StopwatchException("Stopwatch was not paused.")
254 diff = (self._resumeTime - self._pauseTime) / 1e9
255 self._splits.append((diff, False))
256 self._pauseTime = None
258 return diff
260 def Stop(self) -> float:
261 """
262 Stop the stopwatch and return the time delta to the previous stopwatch operation.
264 The stopwatch needs to be started to stop it. See property :data:`IsStarted` to check if the stopwatch was started
265 and the stop operation is possible. |br|
266 Depending on the previous operation, the time delta will be:
268 * the duration from start operation to the stop operation.
269 * the duration from last resume to the stop operation.
271 :returns: Duration in seconds since last stopwatch operation
272 :raises StopwatchException: If stopwatch was not started.
273 :raises StopwatchException: If stopwatch was already stopped.
274 """
275 self._stopTime = perf_counter_ns()
276 self._endTime = datetime.now()
278 if self._startTime is None:
279 raise StopwatchException("Stopwatch was never started.")
280 if self._totalTime is not None: 280 ↛ 281line 280 didn't jump to line 281 because the condition on line 280 was never true
281 raise StopwatchException("Stopwatch was already stopped.")
283 if len(self._splits) == 0: # was never paused
284 diff = (self._stopTime - self._startTime) / 1e9
285 elif self._resumeTime is None: # is paused
286 diff = (self._stopTime - self._pauseTime) / 1e9
287 self._splits.append((diff, False))
288 else: # is running
289 diff = (self._stopTime - self._resumeTime) / 1e9
290 self._splits.append((diff, True))
292 self._pauseTime = None
293 self._resumeTime = None
294 self._totalTime = self._stopTime - self._startTime
296 # FIXME: why is this unused?
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) -> 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__(
501 self,
502 exc_type: Nullable[Type[BaseException]] = None,
503 exc_val: Nullable[BaseException] = None,
504 exc_tb: Nullable[TracebackType] = None
505 ) -> Nullable[bool]:
506 """
507 Implementation of the :ref:`context manager protocol's <context-managers>` ``__exit__(...)`` method.
509 A running stopwatch will be paused or stopped depending on the configured ``preferPause`` behavior.
511 :param exc_type: Exception type, otherwise None.
512 :param exc_val: Exception object, otherwise None.
513 :param exc_tb: Exception's traceback, otherwise None.
514 :returns: True, if exceptions should be suppressed.
515 """
516 if self._startTime is None: # never started? 516 ↛ 517line 516 didn't jump to line 517 because the condition on line 516 was never true
517 raise StopwatchException("Stopwatch was never started.")
518 elif self._stopTime is not None: 518 ↛ 519line 518 didn't jump to line 519 because the condition on line 518 was never true
519 raise StopwatchException("Stopwatch was already stopped.")
520 elif self._resumeTime is not None: # pause or stop 520 ↛ 537line 520 didn't jump to line 537 because the condition on line 520 was always true
521 if self._preferPause:
522 self._pauseTime = perf_counter_ns()
523 diff = (self._pauseTime - self._resumeTime) / 1e9
524 self._splits.append((diff, True))
525 self._resumeTime = None
526 else:
527 self._stopTime = perf_counter_ns()
528 self._endTime = datetime.now()
530 diff = (self._stopTime - self._resumeTime) / 1e9
531 self._splits.append((diff, True))
533 self._pauseTime = None
534 self._resumeTime = None
535 self._totalTime = self._stopTime - self._startTime
536 else:
537 raise StopwatchException("Stopwatch was not resumed.")
539 def __len__(self) -> int:
540 """
541 Implementation of ``len(...)`` to return the number of split times.
543 :return: Number of split times.
544 """
545 return len(self._splits)
547 def __getitem__(self, index: int) -> Tuple[float, bool]:
548 """
549 Implementation of ``split = object[i]`` to return the i-th split time.
551 :param index: Index to access the i-th split time.
552 :return: i-th split time as a tuple of: |br|
553 (1) delta time to the previous stopwatch operation and |br|
554 (2) a boolean indicating if the split was an activity (true) or inactivity (false).
555 :raises KeyError: If index *i* doesn't exist.
556 """
557 return self._splits[index]
559 def __iter__(self) -> Iterator[Tuple[float, bool]]:
560 """
561 Return an iterator of tuples to iterate all split times.
563 If the stopwatch is not stopped yet, the last split won't be included.
565 :return: Iterator of split time tuples of: |br|
566 (1) delta time to the previous stopwatch operation and |br|
567 (2) a boolean indicating if the split was an activity (true) or inactivity (false).
568 """
569 return self._splits.__iter__()
571 def __str__(self) -> str:
572 """
573 Returns the stopwatch's state and its measured time span.
575 :returns: The string equivalent of the stopwatch.
576 """
577 name = f" {self._name}" if self._name is not None else ""
578 if self.IsStopped:
579 return f"Stopwatch{name} (stopped): {self._beginTime} -> {self._endTime}: {self._totalTime}"
580 elif self.IsRunning:
581 return f"Stopwatch{name} (running): {self._beginTime} -> now: {self.Duration}"
582 elif self.IsPaused:
583 return f"Stopwatch{name} (paused): {self._beginTime} -> now: {self.Duration}"
584 else:
585 return f"Stopwatch{name}: not started"