Coverage for pyTooling/Stopwatch/__init__.py: 85%
207 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-22 21:29 +0000
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-22 21:29 +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.
38.. seealso::
40 :mod:`pyTooling.Tracing`
41 |rarr| Nested timespans instead of a single measurement, for tracing an execution.
42 :mod:`pyTooling.Process`
43 |rarr| The process' memory usage, next to its runtime.
44"""
45from __future__ import annotations
47from datetime import datetime
48from time import perf_counter_ns
49from types import TracebackType
50from typing import Optional as Nullable, Iterator, Self
52from pyTooling.Decorators import export, readonly
53from pyTooling.MetaClasses import SlottedObject
54from pyTooling.Exceptions import ToolingException
57@export
58class StopwatchException(ToolingException):
59 """This exception is caused by wrong usage of the stopwatch."""
62@export
63class ExcludeContextManager:
64 """
65 A stopwatch context manager for excluding certain time spans from measurement.
67 While a normal stopwatch's embedded context manager (re)starts the stopwatch on every *enter* event and pauses the
68 stopwatch on every *exit* event, this context manager pauses on *enter* events and restarts on every *exit* event.
69 """
70 _stopwatch: Stopwatch #: Reference to the stopwatch.
72 def __init__(self, stopwatch: Stopwatch) -> None:
73 """
74 Initializes an excluding context manager.
76 :param stopwatch: Reference to the stopwatch.
77 """
78 self._stopwatch = stopwatch
80 def __enter__(self) -> Self:
81 """
82 Enter the context and pause the stopwatch.
84 :returns: Excluding stopwatch context manager instance.
85 """
86 self._stopwatch.Pause()
88 return self
90 def __exit__(
91 self,
92 exc_type: Nullable[type[BaseException]] = None,
93 exc_val: Nullable[BaseException] = None,
94 exc_tb: Nullable[TracebackType] = None
95 ) -> Nullable[bool]:
96 """
97 Exit the context and restart stopwatch.
99 :param exc_type: Exception type
100 :param exc_val: Exception instance
101 :param exc_tb: Exception's traceback.
102 :returns: ``None``
103 """
104 self._stopwatch.Resume()
107@export
108class Stopwatch(SlottedObject):
109 """
110 The stopwatch implements a solution to measure and collect timings.
112 The time measurement can be started, paused, resumed and stopped. More over, split times can be taken too. The
113 measurement is based on :func:`time.perf_counter_ns`. Additionally, starting and stopping is preserved as absolute
114 time via :meth:`datetime.datetime.now`.
116 Every split time taken is a time delta to the previous operation. These are preserved in an internal sequence of
117 splits. This sequence includes time deltas of activity and inactivity. Thus, a running stopwatch can be split as well
118 as a paused stopwatch.
120 The stopwatch can also be used in a :ref:`with-statement <with>`, because it implements the :ref:`context manager protocol <context-managers>`.
121 """
123 _name: Nullable[str] #: Optional name of the stopwatch.
124 _preferPause: bool #: If ``True``, the context manager pauses instead of stopping on exit.
126 _beginTime: Nullable[datetime] #: Absolute time when the stopwatch was started.
127 _endTime: Nullable[datetime] #: Absolute time when the stopwatch was stopped.
128 _startTime: Nullable[int] #: Performance counter in ns when the stopwatch was started.
129 _resumeTime: Nullable[int] #: Performance counter in ns of the latest resume operation.
130 _pauseTime: Nullable[int] #: Performance counter in ns of the latest pause operation.
131 _stopTime: Nullable[int] #: Performance counter in ns when the stopwatch was stopped.
132 _totalTime: Nullable[int] #: Duration in ns from starting to stopping, activity and inactivity.
133 _splits: list[tuple[float, bool]] #: Split times as (duration, is-active) pairs, in the order they were taken.
135 _excludeContextManager: ExcludeContextManager #: The nested context manager excluding time spans from measurement.
137 def __init__(self, name: str = None, started: bool = False, preferPause: bool = False) -> None:
138 """
139 Initializes the fields of the stopwatch.
141 If parameter ``started`` is set to true, the stopwatch will immediately start.
143 :param name: Optional, name of the stopwatch.
144 :param started: Optional, if ``True``, start the stopwatch immediately.
145 :param preferPause: Optional, if ``True``, ``__exit__(...)`` prefers pause over stop behavior.
146 """
147 self._name = name
148 self._preferPause = preferPause
150 self._endTime = None
151 self._pauseTime = None
152 self._stopTime = None
153 self._totalTime = None
154 self._splits = []
156 self._excludeContextManager = None
158 if started is False: 158 ↛ 163line 158 didn't jump to line 163 because the condition on line 158 was always true
159 self._beginTime = None
160 self._startTime = None
161 self._resumeTime = None
162 else:
163 self._beginTime = datetime.now()
164 self._resumeTime = self._startTime = perf_counter_ns()
166 def Start(self) -> None:
167 """
168 Start the stopwatch.
170 A stopwatch can only be started once. There is no restart or reset operation provided.
172 :raises StopwatchException: If stopwatch was already started.
173 :raises StopwatchException: If stopwatch was already started and stopped.
174 """
175 if self._startTime is not None:
176 raise StopwatchException("Stopwatch was already started.")
177 if self._stopTime is not None: 177 ↛ 178line 177 didn't jump to line 178 because the condition on line 177 was never true
178 raise StopwatchException("Stopwatch was already used (started and stopped).")
180 self._beginTime = datetime.now()
181 self._resumeTime = self._startTime = perf_counter_ns()
183 def Split(self) -> float:
184 """
185 Take a split time and return the time delta to the previous stopwatch operation.
187 The stopwatch needs to be running to take a split time. See property :data:`IsRunning` to check if the stopwatch
188 is running and the split operation is possible. |br|
189 Depending on the previous operation, the time delta will be:
191 * the duration from start operation to the first split.
192 * the duration from last resume to this split.
194 :returns: Duration in seconds since last stopwatch operation
195 :raises StopwatchException: If stopwatch was not started or resumed.
196 """
197 pauseTime = perf_counter_ns()
199 if self._resumeTime is None:
200 raise StopwatchException("Stopwatch was not started or resumed.")
202 diff = (pauseTime - self._resumeTime) / 1e9
203 self._splits.append((diff, True))
204 self._resumeTime = pauseTime
206 return diff
208 def Pause(self) -> float:
209 """
210 Pause the stopwatch and return the time delta to the previous stopwatch operation.
212 The stopwatch needs to be running to pause it. See property :data:`IsRunning` to check if the stopwatch is running
213 and the pause operation is possible. |br|
214 Depending on the previous operation, the time delta will be:
216 * the duration from start operation to the first pause.
217 * the duration from last resume to this pause.
219 :returns: Duration in seconds since last stopwatch operation
220 :raises StopwatchException: If stopwatch was not started or resumed.
221 """
222 self._pauseTime = perf_counter_ns()
224 if self._resumeTime is None:
225 raise StopwatchException("Stopwatch was not started or resumed.")
227 diff = (self._pauseTime - self._resumeTime) / 1e9
228 self._splits.append((diff, True))
229 self._resumeTime = None
231 return diff
233 def Resume(self) -> float:
234 """
235 Resume the stopwatch and return the time delta to the previous pause operation.
237 The stopwatch needs to be paused to resume it. See property :data:`IsPaused` to check if the stopwatch is paused
238 and the resume operation is possible. |br|
239 The time delta will be the duration from last pause to this resume.
241 :returns: Duration in seconds since last pause operation
242 :raises StopwatchException: If stopwatch was not paused.
243 """
244 self._resumeTime = perf_counter_ns()
246 if self._pauseTime is None:
247 raise StopwatchException("Stopwatch was not paused.")
249 diff = (self._resumeTime - self._pauseTime) / 1e9
250 self._splits.append((diff, False))
251 self._pauseTime = None
253 return diff
255 def Stop(self) -> float:
256 """
257 Stop the stopwatch and return the time delta to the previous stopwatch operation.
259 The stopwatch needs to be started to stop it. See property :data:`IsStarted` to check if the stopwatch was started
260 and the stop operation is possible. |br|
261 Depending on the previous operation, the time delta will be:
263 * the duration from start operation to the stop operation.
264 * the duration from last resume to the stop operation.
266 :returns: Duration in seconds since last stopwatch operation
267 :raises StopwatchException: If stopwatch was not started.
268 :raises StopwatchException: If stopwatch was already stopped.
269 """
270 self._stopTime = perf_counter_ns()
271 self._endTime = datetime.now()
273 if self._startTime is None:
274 raise StopwatchException("Stopwatch was never started.")
275 if self._totalTime is not None: 275 ↛ 276line 275 didn't jump to line 276 because the condition on line 275 was never true
276 raise StopwatchException("Stopwatch was already stopped.")
278 if len(self._splits) == 0: # was never paused
279 diff = (self._stopTime - self._startTime) / 1e9
280 elif self._resumeTime is None: # is paused
281 diff = (self._stopTime - self._pauseTime) / 1e9
282 self._splits.append((diff, False))
283 else: # is running
284 diff = (self._stopTime - self._resumeTime) / 1e9
285 self._splits.append((diff, True))
287 self._pauseTime = None
288 self._resumeTime = None
289 self._totalTime = self._stopTime - self._startTime
291 # FIXME: why is this unused?
292 beginEndDiff = self._endTime - self._beginTime
294 return diff
296 @readonly
297 def Name(self) -> Nullable[str]:
298 """
299 Read-only property returning the name of the stopwatch.
301 :returns: Name of the stopwatch.
302 """
303 return self._name
305 @readonly
306 def IsStarted(self) -> bool:
307 """
308 Read-only property returning the IsStarted state of the stopwatch.
310 :returns: True, if stopwatch was started.
311 """
312 return self._startTime is not None and self._stopTime is None
314 @readonly
315 def IsRunning(self) -> bool:
316 """
317 Read-only property returning the IsRunning state of the stopwatch.
319 :returns: True, if stopwatch was started and is currently not paused.
320 """
321 return self._startTime is not None and self._resumeTime is not None
323 @readonly
324 def IsPaused(self) -> bool:
325 """
326 Read-only property returning the IsPaused state of the stopwatch.
328 :returns: True, if stopwatch was started and is currently paused.
329 """
330 return self._startTime is not None and self._pauseTime is not None
332 @readonly
333 def IsStopped(self) -> bool:
334 """
335 Read-only property returning the IsStopped state of the stopwatch.
337 :returns: True, if stopwatch was stopped.
338 """
339 return self._stopTime is not None
341 @readonly
342 def StartTime(self) -> Nullable[datetime]:
343 """
344 Read-only property returning the absolute time when the stopwatch was started.
346 :returns: The time when the stopwatch was started, otherwise None.
347 """
348 return self._beginTime
350 @readonly
351 def StopTime(self) -> Nullable[datetime]:
352 """
353 Read-only property returning the absolute time when the stopwatch was stopped.
355 :returns: The time when the stopwatch was stopped, otherwise None.
356 """
357 return self._endTime
359 @readonly
360 def HasSplitTimes(self) -> bool:
361 """
362 Read-only property checking if split times have been taken.
364 :returns: True, if split times have been taken.
365 """
366 return len(self._splits) > 1
368 @readonly
369 def SplitCount(self) -> int:
370 """
371 Read-only property returning the number of split times.
373 :returns: Number of split times.
374 """
375 return len(self._splits)
377 @readonly
378 def ActiveCount(self) -> int:
379 """
380 Read-only property returning the number of active split times.
382 :returns: Number of active split times.
384 .. warning::
386 This won't include all activities, unless the stopwatch got stopped.
387 """
388 if self._startTime is None: 388 ↛ 389line 388 didn't jump to line 389 because the condition on line 388 was never true
389 return 0
391 return len(list(t for t, a in self._splits if a is True))
393 @readonly
394 def InactiveCount(self) -> int:
395 """
396 Read-only property returning the number of active split times.
398 :returns: Number of active split times.
400 .. warning::
402 This won't include all inactivities, unless the stopwatch got stopped.
403 """
404 if self._startTime is None: 404 ↛ 405line 404 didn't jump to line 405 because the condition on line 404 was never true
405 return 0
407 return len(list(t for t, a in self._splits if a is False))
409 @readonly
410 def Activity(self) -> float:
411 """
412 Read-only property returning the duration of all active split times.
414 If the stopwatch is currently running, the duration since start or last resume operation will be included.
416 :returns: Duration of all active split times in seconds. If the stopwatch was never started, the return value will
417 be 0.0.
418 """
419 if self._startTime is None: 419 ↛ 420line 419 didn't jump to line 420 because the condition on line 419 was never true
420 return 0.0
422 currentDiff = 0.0 if self._resumeTime is None else ((perf_counter_ns() - self._resumeTime) / 1e9)
423 return sum(t for t, a in self._splits if a is True) + currentDiff
425 @readonly
426 def Inactivity(self) -> float:
427 """
428 Read-only property returning the duration of all inactive split times.
430 If the stopwatch is currently paused, the duration since last pause operation will be included.
432 :returns: Duration of all inactive split times in seconds. If the stopwatch was never started, the return value will
433 be 0.0.
434 """
435 if self._startTime is None: 435 ↛ 436line 435 didn't jump to line 436 because the condition on line 435 was never true
436 return 0.0
438 currentDiff = 0.0 if self._pauseTime is None else ((perf_counter_ns() - self._pauseTime) / 1e9)
439 return sum(t for t, a in self._splits if a is False) + currentDiff
441 @readonly
442 def Duration(self) -> float:
443 """
444 Read-only property returning the duration from start operation to stop operation.
446 If the stopwatch is not yet stopped, the duration from start to now is returned.
448 :returns: Duration since stopwatch was started in seconds. If the stopwatch was never started, the return value will
449 be 0.0.
450 """
451 if self._startTime is None: 451 ↛ 452line 451 didn't jump to line 452 because the condition on line 451 was never true
452 return 0.0
454 return ((perf_counter_ns() - self._startTime) if self._stopTime is None else self._totalTime) / 1e9
456 @readonly
457 def Exclude(self) -> ExcludeContextManager:
458 """
459 Return an *exclude* context manager for the stopwatch instance.
461 :returns: An excluding context manager.
462 """
463 if self._excludeContextManager is None:
464 excludeContextManager = ExcludeContextManager(self)
465 self._excludeContextManager = excludeContextManager
467 return excludeContextManager
469 def __enter__(self) -> Self:
470 """
471 Implementation of the :ref:`context manager protocol's <context-managers>` ``__enter__(...)`` method.
473 An unstarted stopwatch will be started. A paused stopwatch will be resumed.
475 :returns: The stopwatch itself.
476 :raises StopwatchException: If the stopwatch was already started.
477 """
478 if self._startTime is None: # start stopwatch
479 self._beginTime = datetime.now()
480 self._resumeTime = self._startTime = perf_counter_ns()
481 elif self._pauseTime is not None: # resume after pause
482 self._resumeTime = perf_counter_ns()
484 diff = (self._resumeTime - self._pauseTime) / 1e9
485 self._splits.append((diff, False))
486 self._pauseTime = None
487 elif self._resumeTime is not None: # is running? 487 ↛ 488line 487 didn't jump to line 488 because the condition on line 487 was never true
488 raise StopwatchException("Stopwatch is currently running and can not be started/resumed again.")
489 elif self._stopTime is not None: # is stopped? 489 ↛ 492line 489 didn't jump to line 492 because the condition on line 489 was always true
490 raise StopwatchException(f"Stopwatch was already stopped.")
491 else:
492 raise StopwatchException(f"Internal error.")
494 return self
496 def __exit__(
497 self,
498 exc_type: Nullable[type[BaseException]] = None,
499 exc_val: Nullable[BaseException] = None,
500 exc_tb: Nullable[TracebackType] = None
501 ) -> Nullable[bool]:
502 """
503 Implementation of the :ref:`context manager protocol's <context-managers>` ``__exit__(...)`` method.
505 A running stopwatch will be paused or stopped depending on the configured ``preferPause`` behavior.
507 :param exc_type: Exception type, otherwise None.
508 :param exc_val: Exception object, otherwise None.
509 :param exc_tb: Exception's traceback, otherwise None.
510 :returns: True, if exceptions should be suppressed.
511 :raises StopwatchException: If the stopwatch was already stopped.
512 """
513 if self._startTime is None: # never started? 513 ↛ 514line 513 didn't jump to line 514 because the condition on line 513 was never true
514 raise StopwatchException("Stopwatch was never started.")
515 elif self._stopTime is not None: 515 ↛ 516line 515 didn't jump to line 516 because the condition on line 515 was never true
516 raise StopwatchException("Stopwatch was already stopped.")
517 elif self._resumeTime is not None: # pause or stop 517 ↛ 534line 517 didn't jump to line 534 because the condition on line 517 was always true
518 if self._preferPause:
519 self._pauseTime = perf_counter_ns()
520 diff = (self._pauseTime - self._resumeTime) / 1e9
521 self._splits.append((diff, True))
522 self._resumeTime = None
523 else:
524 self._stopTime = perf_counter_ns()
525 self._endTime = datetime.now()
527 diff = (self._stopTime - self._resumeTime) / 1e9
528 self._splits.append((diff, True))
530 self._pauseTime = None
531 self._resumeTime = None
532 self._totalTime = self._stopTime - self._startTime
533 else:
534 raise StopwatchException("Stopwatch was not resumed.")
536 def __len__(self) -> int:
537 """
538 Implementation of ``len(...)`` to return the number of split times.
540 :returns: Number of split times.
541 """
542 return len(self._splits)
544 def __getitem__(self, index: int) -> tuple[float, bool]:
545 """
546 Implementation of ``split = object[i]`` to return the i-th split time.
548 :param index: Index to access the i-th split time.
549 :returns: i-th split time as a tuple of: |br|
550 (1) delta time to the previous stopwatch operation and |br|
551 (2) a boolean indicating if the split was an activity (true) or inactivity (false).
552 :raises KeyError: If index *i* doesn't exist.
553 """
554 return self._splits[index]
556 def __iter__(self) -> Iterator[tuple[float, bool]]:
557 """
558 Return an iterator of tuples to iterate all split times.
560 If the stopwatch is not stopped yet, the last split won't be included.
562 :returns: Iterator of split time tuples of: |br|
563 (1) delta time to the previous stopwatch operation and |br|
564 (2) a boolean indicating if the split was an activity (true) or inactivity (false).
565 """
566 return self._splits.__iter__()
568 def __str__(self) -> str:
569 """
570 Returns the stopwatch's state and its measured time span.
572 :returns: The string equivalent of the stopwatch.
573 """
574 name = f" {self._name}" if self._name is not None else ""
575 if self.IsStopped:
576 return f"Stopwatch{name} (stopped): {self._beginTime} -> {self._endTime}: {self._totalTime}"
577 elif self.IsRunning:
578 return f"Stopwatch{name} (running): {self._beginTime} -> now: {self.Duration}"
579 elif self.IsPaused:
580 return f"Stopwatch{name} (paused): {self._beginTime} -> now: {self.Duration}"
581 else:
582 return f"Stopwatch{name}: not started"