Coverage for pyTooling / Stopwatch / __init__.py: 85%
206 statements
« prev ^ index » next coverage.py v7.13.3, created at 2026-02-07 17:18 +0000
« prev ^ index » next coverage.py v7.13.3, created at 2026-02-07 17:18 +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
44from pyTooling.Decorators import export, readonly
45from pyTooling.MetaClasses import SlottedObject
46from pyTooling.Exceptions import ToolingException
49@export
50class StopwatchException(ToolingException):
51 """This exception is caused by wrong usage of the stopwatch."""
54@export
55class ExcludeContextManager:
56 """
57 A stopwatch context manager for excluding certain time spans from measurement.
59 While a normal stopwatch's embedded context manager (re)starts the stopwatch on every *enter* event and pauses the
60 stopwatch on every *exit* event, this context manager pauses on *enter* events and restarts on every *exit* event.
61 """
62 _stopwatch: "Stopwatch" #: Reference to the stopwatch.
64 def __init__(self, stopwatch: "Stopwatch") -> None:
65 """
66 Initializes an excluding context manager.
68 :param stopwatch: Reference to the stopwatch.
69 """
70 self._stopwatch = stopwatch
72 def __enter__(self) -> Self:
73 """
74 Enter the context and pause the stopwatch.
76 :returns: Excluding stopwatch context manager instance.
77 """
78 self._stopwatch.Pause()
80 return self
82 def __exit__(
83 self,
84 exc_type: Nullable[Type[BaseException]] = None,
85 exc_val: Nullable[BaseException] = None,
86 exc_tb: Nullable[TracebackType] = None
87 ) -> Nullable[bool]:
88 """
89 Exit the context and restart stopwatch.
91 :param exc_type: Exception type
92 :param exc_val: Exception instance
93 :param exc_tb: Exception's traceback.
94 :returns: ``None``
95 """
96 self._stopwatch.Resume()
99@export
100class Stopwatch(SlottedObject):
101 """
102 The stopwatch implements a solution to measure and collect timings.
104 The time measurement can be started, paused, resumed and stopped. More over, split times can be taken too. The
105 measurement is based on :func:`time.perf_counter_ns`. Additionally, starting and stopping is preserved as absolute
106 time via :meth:`datetime.datetime.now`.
108 Every split time taken is a time delta to the previous operation. These are preserved in an internal sequence of
109 splits. This sequence includes time deltas of activity and inactivity. Thus, a running stopwatch can be split as well
110 as a paused stopwatch.
112 The stopwatch can also be used in a :ref:`with-statement <with>`, because it implements the :ref:`context manager protocol <context-managers>`.
113 """
115 _name: Nullable[str]
116 _preferPause: bool
118 _beginTime: Nullable[datetime]
119 _endTime: Nullable[datetime]
120 _startTime: Nullable[int]
121 _resumeTime: Nullable[int]
122 _pauseTime: Nullable[int]
123 _stopTime: Nullable[int]
124 _totalTime: Nullable[int]
125 _splits: List[Tuple[float, bool]]
127 _excludeContextManager: ExcludeContextManager
129 def __init__(self, name: str = None, started: bool = False, preferPause: bool = False) -> None:
130 """
131 Initializes the fields of the stopwatch.
133 If parameter ``started`` is set to true, the stopwatch will immediately start.
135 :param name: Optional name of the stopwatch.
136 :param preferPause: Optional setting, if __exit__(...) in a contex should prefer pause or stop behavior.
137 :param started: Optional flag, if the stopwatch should be started immediately.
138 """
139 self._name = name
140 self._preferPause = preferPause
142 self._endTime = None
143 self._pauseTime = None
144 self._stopTime = None
145 self._totalTime = None
146 self._splits = []
148 self._excludeContextManager = None
150 if started is False: 150 ↛ 155line 150 didn't jump to line 155 because the condition on line 150 was always true
151 self._beginTime = None
152 self._startTime = None
153 self._resumeTime = None
154 else:
155 self._beginTime = datetime.now()
156 self._resumeTime = self._startTime = perf_counter_ns()
158 def Start(self) -> None:
159 """
160 Start the stopwatch.
162 A stopwatch can only be started once. There is no restart or reset operation provided.
164 :raises StopwatchException: If stopwatch was already started.
165 :raises StopwatchException: If stopwatch was already started and stopped.
166 """
167 if self._startTime is not None:
168 raise StopwatchException("Stopwatch was already started.")
169 if self._stopTime is not None: 169 ↛ 170line 169 didn't jump to line 170 because the condition on line 169 was never true
170 raise StopwatchException("Stopwatch was already used (started and stopped).")
172 self._beginTime = datetime.now()
173 self._resumeTime = self._startTime = perf_counter_ns()
175 def Split(self) -> float:
176 """
177 Take a split time and return the time delta to the previous stopwatch operation.
179 The stopwatch needs to be running to take a split time. See property :data:`IsRunning` to check if the stopwatch
180 is running and the split operation is possible. |br|
181 Depending on the previous operation, the time delta will be:
183 * the duration from start operation to the first split.
184 * the duration from last resume to this split.
186 :returns: Duration in seconds since last stopwatch operation
187 :raises StopwatchException: If stopwatch was not started or resumed.
188 """
189 pauseTime = perf_counter_ns()
191 if self._resumeTime is None:
192 raise StopwatchException("Stopwatch was not started or resumed.")
194 diff = (pauseTime - self._resumeTime) / 1e9
195 self._splits.append((diff, True))
196 self._resumeTime = pauseTime
198 return diff
200 def Pause(self) -> float:
201 """
202 Pause the stopwatch and return the time delta to the previous stopwatch operation.
204 The stopwatch needs to be running to pause it. See property :data:`IsRunning` to check if the stopwatch is running
205 and the pause operation is possible. |br|
206 Depending on the previous operation, the time delta will be:
208 * the duration from start operation to the first pause.
209 * the duration from last resume to this pause.
211 :returns: Duration in seconds since last stopwatch operation
212 :raises StopwatchException: If stopwatch was not started or resumed.
213 """
214 self._pauseTime = perf_counter_ns()
216 if self._resumeTime is None:
217 raise StopwatchException("Stopwatch was not started or resumed.")
219 diff = (self._pauseTime - self._resumeTime) / 1e9
220 self._splits.append((diff, True))
221 self._resumeTime = None
223 return diff
225 def Resume(self) -> float:
226 """
227 Resume the stopwatch and return the time delta to the previous pause operation.
229 The stopwatch needs to be paused to resume it. See property :data:`IsPaused` to check if the stopwatch is paused
230 and the resume operation is possible. |br|
231 The time delta will be the duration from last pause to this resume.
233 :returns: Duration in seconds since last pause operation
234 :raises StopwatchException: If stopwatch was not paused.
235 """
236 self._resumeTime = perf_counter_ns()
238 if self._pauseTime is None:
239 raise StopwatchException("Stopwatch was not paused.")
241 diff = (self._resumeTime - self._pauseTime) / 1e9
242 self._splits.append((diff, False))
243 self._pauseTime = None
245 return diff
247 def Stop(self) -> float:
248 """
249 Stop the stopwatch and return the time delta to the previous stopwatch operation.
251 The stopwatch needs to be started to stop it. See property :data:`IsStarted` to check if the stopwatch was started
252 and the stop operation is possible. |br|
253 Depending on the previous operation, the time delta will be:
255 * the duration from start operation to the stop operation.
256 * the duration from last resume to the stop operation.
258 :returns: Duration in seconds since last stopwatch operation
259 :raises StopwatchException: If stopwatch was not started.
260 :raises StopwatchException: If stopwatch was already stopped.
261 """
262 self._stopTime = perf_counter_ns()
263 self._endTime = datetime.now()
265 if self._startTime is None:
266 raise StopwatchException("Stopwatch was never started.")
267 if self._totalTime is not None: 267 ↛ 268line 267 didn't jump to line 268 because the condition on line 267 was never true
268 raise StopwatchException("Stopwatch was already stopped.")
270 if len(self._splits) == 0: # was never paused
271 diff = (self._stopTime - self._startTime) / 1e9
272 elif self._resumeTime is None: # is paused
273 diff = (self._stopTime - self._pauseTime) / 1e9
274 self._splits.append((diff, False))
275 else: # is running
276 diff = (self._stopTime - self._resumeTime) / 1e9
277 self._splits.append((diff, True))
279 self._pauseTime = None
280 self._resumeTime = None
281 self._totalTime = self._stopTime - self._startTime
283 # FIXME: why is this unused?
284 beginEndDiff = self._endTime - self._beginTime
286 return diff
288 @readonly
289 def Name(self) -> Nullable[str]:
290 """
291 Read-only property returning the name of the stopwatch.
293 :return: Name of the stopwatch.
294 """
295 return self._name
297 @readonly
298 def IsStarted(self) -> bool:
299 """
300 Read-only property returning the IsStarted state of the stopwatch.
302 :return: True, if stopwatch was started.
303 """
304 return self._startTime is not None and self._stopTime is None
306 @readonly
307 def IsRunning(self) -> bool:
308 """
309 Read-only property returning the IsRunning state of the stopwatch.
311 :return: True, if stopwatch was started and is currently not paused.
312 """
313 return self._startTime is not None and self._resumeTime is not None
315 @readonly
316 def IsPaused(self) -> bool:
317 """
318 Read-only property returning the IsPaused state of the stopwatch.
320 :return: True, if stopwatch was started and is currently paused.
321 """
322 return self._startTime is not None and self._pauseTime is not None
324 @readonly
325 def IsStopped(self) -> bool:
326 """
327 Read-only property returning the IsStopped state of the stopwatch.
329 :return: True, if stopwatch was stopped.
330 """
331 return self._stopTime is not None
333 @readonly
334 def StartTime(self) -> Nullable[datetime]:
335 """
336 Read-only property returning the absolute time when the stopwatch was started.
338 :return: The time when the stopwatch was started, otherwise None.
339 """
340 return self._beginTime
342 @readonly
343 def StopTime(self) -> Nullable[datetime]:
344 """
345 Read-only property returning the absolute time when the stopwatch was stopped.
347 :return: The time when the stopwatch was stopped, otherwise None.
348 """
349 return self._endTime
351 @readonly
352 def HasSplitTimes(self) -> bool:
353 """
354 Read-only property checking if split times have been taken.
356 :return: True, if split times have been taken.
357 """
358 return len(self._splits) > 1
360 @readonly
361 def SplitCount(self) -> int:
362 """
363 Read-only property returning the number of split times.
365 :return: Number of split times.
366 """
367 return len(self._splits)
369 @readonly
370 def ActiveCount(self) -> int:
371 """
372 Read-only property returning the number of active split times.
374 :return: Number of active split times.
376 .. warning::
378 This won't include all activities, unless the stopwatch got stopped.
379 """
380 if self._startTime is None: 380 ↛ 381line 380 didn't jump to line 381 because the condition on line 380 was never true
381 return 0
383 return len(list(t for t, a in self._splits if a is True))
385 @readonly
386 def InactiveCount(self) -> int:
387 """
388 Read-only property returning the number of active split times.
390 :return: Number of active split times.
392 .. warning::
394 This won't include all inactivities, unless the stopwatch got stopped.
395 """
396 if self._startTime is None: 396 ↛ 397line 396 didn't jump to line 397 because the condition on line 396 was never true
397 return 0
399 return len(list(t for t, a in self._splits if a is False))
401 @readonly
402 def Activity(self) -> float:
403 """
404 Read-only property returning the duration of all active split times.
406 If the stopwatch is currently running, the duration since start or last resume operation will be included.
408 :return: Duration of all active split times in seconds. If the stopwatch was never started, the return value will
409 be 0.0.
410 """
411 if self._startTime is None: 411 ↛ 412line 411 didn't jump to line 412 because the condition on line 411 was never true
412 return 0.0
414 currentDiff = 0.0 if self._resumeTime is None else ((perf_counter_ns() - self._resumeTime) / 1e9)
415 return sum(t for t, a in self._splits if a is True) + currentDiff
417 @readonly
418 def Inactivity(self) -> float:
419 """
420 Read-only property returning the duration of all inactive split times.
422 If the stopwatch is currently paused, the duration since last pause operation will be included.
424 :return: Duration of all inactive split times in seconds. If the stopwatch was never started, the return value will
425 be 0.0.
426 """
427 if self._startTime is None: 427 ↛ 428line 427 didn't jump to line 428 because the condition on line 427 was never true
428 return 0.0
430 currentDiff = 0.0 if self._pauseTime is None else ((perf_counter_ns() - self._pauseTime) / 1e9)
431 return sum(t for t, a in self._splits if a is False) + currentDiff
433 @readonly
434 def Duration(self) -> float:
435 """
436 Read-only property returning the duration from start operation to stop operation.
438 If the stopwatch is not yet stopped, the duration from start to now is returned.
440 :return: Duration since stopwatch was started in seconds. If the stopwatch was never started, the return value will
441 be 0.0.
442 """
443 if self._startTime is None: 443 ↛ 444line 443 didn't jump to line 444 because the condition on line 443 was never true
444 return 0.0
446 return ((perf_counter_ns() - self._startTime) if self._stopTime is None else self._totalTime) / 1e9
448 @readonly
449 def Exclude(self) -> ExcludeContextManager:
450 """
451 Return an *exclude* context manager for the stopwatch instance.
453 :returns: An excluding context manager.
454 """
455 if self._excludeContextManager is None:
456 excludeContextManager = ExcludeContextManager(self)
457 self._excludeContextManager = excludeContextManager
459 return excludeContextManager
461 def __enter__(self) -> Self:
462 """
463 Implementation of the :ref:`context manager protocol's <context-managers>` ``__enter__(...)`` method.
465 An unstarted stopwatch will be started. A paused stopwatch will be resumed.
467 :return: The stopwatch itself.
468 """
469 if self._startTime is None: # start stopwatch
470 self._beginTime = datetime.now()
471 self._resumeTime = self._startTime = perf_counter_ns()
472 elif self._pauseTime is not None: # resume after pause
473 self._resumeTime = perf_counter_ns()
475 diff = (self._resumeTime - self._pauseTime) / 1e9
476 self._splits.append((diff, False))
477 self._pauseTime = None
478 elif self._resumeTime is not None: # is running? 478 ↛ 479line 478 didn't jump to line 479 because the condition on line 478 was never true
479 raise StopwatchException("Stopwatch is currently running and can not be started/resumed again.")
480 elif self._stopTime is not None: # is stopped? 480 ↛ 483line 480 didn't jump to line 483 because the condition on line 480 was always true
481 raise StopwatchException(f"Stopwatch was already stopped.")
482 else:
483 raise StopwatchException(f"Internal error.")
485 return self
487 def __exit__(
488 self,
489 exc_type: Nullable[Type[BaseException]] = None,
490 exc_val: Nullable[BaseException] = None,
491 exc_tb: Nullable[TracebackType] = None
492 ) -> Nullable[bool]:
493 """
494 Implementation of the :ref:`context manager protocol's <context-managers>` ``__exit__(...)`` method.
496 A running stopwatch will be paused or stopped depending on the configured ``preferPause`` behavior.
498 :param exc_type: Exception type, otherwise None.
499 :param exc_val: Exception object, otherwise None.
500 :param exc_tb: Exception's traceback, otherwise None.
501 :returns: True, if exceptions should be suppressed.
502 """
503 if self._startTime is None: # never started? 503 ↛ 504line 503 didn't jump to line 504 because the condition on line 503 was never true
504 raise StopwatchException("Stopwatch was never started.")
505 elif self._stopTime is not None: 505 ↛ 506line 505 didn't jump to line 506 because the condition on line 505 was never true
506 raise StopwatchException("Stopwatch was already stopped.")
507 elif self._resumeTime is not None: # pause or stop 507 ↛ 524line 507 didn't jump to line 524 because the condition on line 507 was always true
508 if self._preferPause:
509 self._pauseTime = perf_counter_ns()
510 diff = (self._pauseTime - self._resumeTime) / 1e9
511 self._splits.append((diff, True))
512 self._resumeTime = None
513 else:
514 self._stopTime = perf_counter_ns()
515 self._endTime = datetime.now()
517 diff = (self._stopTime - self._resumeTime) / 1e9
518 self._splits.append((diff, True))
520 self._pauseTime = None
521 self._resumeTime = None
522 self._totalTime = self._stopTime - self._startTime
523 else:
524 raise StopwatchException("Stopwatch was not resumed.")
526 def __len__(self) -> int:
527 """
528 Implementation of ``len(...)`` to return the number of split times.
530 :return: Number of split times.
531 """
532 return len(self._splits)
534 def __getitem__(self, index: int) -> Tuple[float, bool]:
535 """
536 Implementation of ``split = object[i]`` to return the i-th split time.
538 :param index: Index to access the i-th split time.
539 :return: i-th split time as a tuple of: |br|
540 (1) delta time to the previous stopwatch operation and |br|
541 (2) a boolean indicating if the split was an activity (true) or inactivity (false).
542 :raises KeyError: If index *i* doesn't exist.
543 """
544 return self._splits[index]
546 def __iter__(self) -> Iterator[Tuple[float, bool]]:
547 """
548 Return an iterator of tuples to iterate all split times.
550 If the stopwatch is not stopped yet, the last split won't be included.
552 :return: Iterator of split time tuples 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 """
556 return self._splits.__iter__()
558 def __str__(self) -> str:
559 """
560 Returns the stopwatch's state and its measured time span.
562 :returns: The string equivalent of the stopwatch.
563 """
564 name = f" {self._name}" if self._name is not None else ""
565 if self.IsStopped:
566 return f"Stopwatch{name} (stopped): {self._beginTime} -> {self._endTime}: {self._totalTime}"
567 elif self.IsRunning:
568 return f"Stopwatch{name} (running): {self._beginTime} -> now: {self.Duration}"
569 elif self.IsPaused:
570 return f"Stopwatch{name} (paused): {self._beginTime} -> now: {self.Duration}"
571 else:
572 return f"Stopwatch{name}: not started"