Coverage for pyTooling/Stopwatch/__init__.py: 98%
268 statements
« prev ^ index » next coverage.py v7.17.0, created at 2026-10-25 12:33 +0000
« prev ^ index » next coverage.py v7.17.0, created at 2026-10-25 12:33 +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, timedelta
48from time import perf_counter_ns
49from types import TracebackType
50from typing import Optional as Nullable, Iterator, Self
52from pyTooling.Common import getFullyQualifiedName
53from pyTooling.Decorators import export, readonly
54from pyTooling.MetaClasses import SlottedObject
55from pyTooling.Exceptions import ToolingException
58@export
59class StopwatchError(ToolingException):
60 """This exception is caused by wrong usage of the stopwatch."""
63@export
64class ExcludeContextManager:
65 """
66 A stopwatch context manager for excluding certain time spans from measurement.
68 While a normal stopwatch's embedded context manager (re)starts the stopwatch on every *enter* event and pauses the
69 stopwatch on every *exit* event, this context manager pauses on *enter* events and restarts on every *exit* event.
70 """
71 _stopwatch: Stopwatch #: Reference to the stopwatch.
73 def __init__(self, stopwatch: Stopwatch) -> None:
74 """
75 Initializes an excluding context manager.
77 :param stopwatch: Reference to the stopwatch.
78 """
79 self._stopwatch = stopwatch
81 def __enter__(self) -> Self:
82 """
83 Enter the context and pause the stopwatch.
85 :returns: Excluding stopwatch context manager instance.
86 """
87 self._stopwatch.Pause()
89 return self
91 def __exit__(
92 self,
93 exc_type: Nullable[type[BaseException]] = None,
94 exc_val: Nullable[BaseException] = None,
95 exc_tb: Nullable[TracebackType] = None
96 ) -> Nullable[bool]:
97 """
98 Exit the context and restart stopwatch.
100 :param exc_type: Optional, exception type.
101 :param exc_val: Optional, exception instance.
102 :param exc_tb: Optional, exception's traceback.
103 :returns: ``None``
104 """
105 self._stopwatch.Resume()
108@export
109class Stopwatch(SlottedObject):
110 """
111 The stopwatch implements a solution to measure and collect timings.
113 The time measurement can be started, paused, resumed and stopped. More over, split times can be taken too. The
114 measurement is based on :func:`time.perf_counter_ns`. Additionally, starting and stopping is preserved as absolute
115 time via :meth:`datetime.datetime.now`.
117 Every split time taken is a time delta to the previous operation. These are preserved in an internal sequence of
118 splits. This sequence includes time deltas of activity and inactivity. Thus, a running stopwatch can be split as well
119 as a paused stopwatch.
121 The stopwatch can also be used in a :ref:`with-statement <with>`, because it implements the :ref:`context manager protocol <context-managers>`.
122 """
124 _name: Nullable[str] #: Optional name of the stopwatch.
125 _preferPause: bool #: If ``True``, the context manager pauses instead of stopping on exit.
126 _digits: int #: Number of fractional digits ``__str__`` renders the duration with.
128 _beginTime: Nullable[datetime] #: Absolute time when the stopwatch was started.
129 _endTime: Nullable[datetime] #: Absolute time when the stopwatch was stopped.
130 _startTime: Nullable[int] #: Performance counter in ns when the stopwatch was started.
131 _resumeTime: Nullable[int] #: Performance counter in ns of the latest resume operation.
132 _pauseTime: Nullable[int] #: Performance counter in ns of the latest pause operation.
133 _stopTime: Nullable[int] #: Performance counter in ns when the stopwatch was stopped.
134 _totalTime: Nullable[int] #: Duration in ns from starting to stopping, activity and inactivity.
135 _duration: Nullable[timedelta] #: :attr:`Duration` of a stopped stopwatch, computed on first access.
136 _splits: list[tuple[float, bool]] #: Split times as (duration, is-active) pairs, in the order they were taken.
138 _excludeContextManager: ExcludeContextManager #: The nested context manager excluding time spans from measurement.
140 def __init__(
141 self,
142 name: Nullable[str] = None,
143 started: bool = False,
144 preferPause: bool = False,
145 digits: int = 3
146 ) -> None:
147 """
148 Initializes the fields of the stopwatch.
150 If parameter ``started`` is set to true, the stopwatch will immediately start.
152 :param name: Optional, name of the stopwatch.
153 :param started: Optional, if ``True``, start the stopwatch immediately.
154 :param preferPause: Optional, if ``True``, ``__exit__(...)`` prefers pause over stop behavior.
155 :param digits: Optional, number of fractional digits :meth:`__str__` renders the duration with.
156 :raises TypeError: If parameter 'digits' is not of type :class:`int`.
157 :raises ValueError: If parameter 'digits' is negative or greater than 9.
158 """
159 if not isinstance(digits, int):
160 ex = TypeError("Parameter 'digits' is not of type 'int'.")
161 ex.add_note(f"Got type '{getFullyQualifiedName(digits)}'.")
162 raise ex
163 elif not 0 <= digits <= 9:
164 ex = ValueError(f"Parameter 'digits' is out of range 0..9. Got {digits}.")
165 ex.add_note("A duration in seconds has at most 9 digits (nanoseconds).")
166 raise ex
168 self._name = name
169 self._preferPause = preferPause
170 self._digits = digits
172 self._endTime = None
173 self._pauseTime = None
174 self._stopTime = None
175 self._totalTime = None
176 self._duration = None
177 self._splits = []
179 self._excludeContextManager = None
181 if started is False:
182 self._beginTime = None
183 self._startTime = None
184 self._resumeTime = None
185 else:
186 self._beginTime = datetime.now()
187 self._resumeTime = self._startTime = perf_counter_ns()
189 def Start(self) -> None:
190 """
191 Start the stopwatch.
193 A stopwatch can only be started once. There is no restart or reset operation provided.
195 :raises StopwatchError: If stopwatch was already started.
196 :raises StopwatchError: If stopwatch was already started and stopped.
197 """
198 if self._startTime is not None:
199 raise StopwatchError("Stopwatch was already started.")
201 if self._stopTime is not None: 201 ↛ 202line 201 didn't jump to line 202 because the condition on line 201 was never true
202 raise StopwatchError("Stopwatch was already used (started and stopped).")
204 self._beginTime = datetime.now()
205 self._resumeTime = self._startTime = perf_counter_ns()
207 def Split(self) -> float:
208 """
209 Take a split time and return the time delta to the previous stopwatch operation.
211 The stopwatch needs to be running to take a split time. See property :data:`IsRunning` to check if the stopwatch
212 is running and the split operation is possible. |br|
213 Depending on the previous operation, the time delta will be:
215 * the duration from start operation to the first split.
216 * the duration from last resume to this split.
218 :returns: Duration in seconds since last stopwatch operation
219 :raises StopwatchError: If stopwatch was not started or resumed.
220 """
221 pauseTime = perf_counter_ns()
223 if self._resumeTime is None:
224 raise StopwatchError("Stopwatch was not started or resumed.")
226 diff = (pauseTime - self._resumeTime) / 1e9
227 self._splits.append((diff, True))
228 self._resumeTime = pauseTime
230 return diff
232 def Pause(self) -> float:
233 """
234 Pause the stopwatch and return the time delta to the previous stopwatch operation.
236 The stopwatch needs to be running to pause it. See property :data:`IsRunning` to check if the stopwatch is running
237 and the pause operation is possible. |br|
238 Depending on the previous operation, the time delta will be:
240 * the duration from start operation to the first pause.
241 * the duration from last resume to this pause.
243 :returns: Duration in seconds since last stopwatch operation
244 :raises StopwatchError: If stopwatch was not started or resumed.
245 """
246 self._pauseTime = perf_counter_ns()
248 if self._resumeTime is None:
249 raise StopwatchError("Stopwatch was not started or resumed.")
251 diff = (self._pauseTime - self._resumeTime) / 1e9
252 self._splits.append((diff, True))
253 self._resumeTime = None
255 return diff
257 def Resume(self) -> float:
258 """
259 Resume the stopwatch and return the time delta to the previous pause operation.
261 The stopwatch needs to be paused to resume it. See property :data:`IsPaused` to check if the stopwatch is paused
262 and the resume operation is possible. |br|
263 The time delta will be the duration from last pause to this resume.
265 :returns: Duration in seconds since last pause operation
266 :raises StopwatchError: If stopwatch was not paused.
267 """
268 self._resumeTime = perf_counter_ns()
270 if self._pauseTime is None:
271 raise StopwatchError("Stopwatch was not paused.")
273 diff = (self._resumeTime - self._pauseTime) / 1e9
274 self._splits.append((diff, False))
275 self._pauseTime = None
277 return diff
279 def Stop(self) -> float:
280 """
281 Stop the stopwatch and return the time delta to the previous stopwatch operation.
283 The stopwatch needs to be started to stop it. See property :data:`IsStarted` to check if the stopwatch was started
284 and the stop operation is possible. |br|
285 Depending on the previous operation, the time delta will be:
287 * the duration from start operation to the stop operation.
288 * the duration from last resume to the stop operation.
290 :returns: Duration in seconds since last stopwatch operation
291 :raises StopwatchError: If stopwatch was not started.
292 :raises StopwatchError: If stopwatch was already stopped.
293 """
294 self._stopTime = perf_counter_ns()
295 self._endTime = datetime.now()
297 if self._startTime is None:
298 raise StopwatchError("Stopwatch was never started.")
300 if self._totalTime is not None: 300 ↛ 301line 300 didn't jump to line 301 because the condition on line 300 was never true
301 raise StopwatchError("Stopwatch was already stopped.")
303 if len(self._splits) == 0: # was never paused
304 diff = (self._stopTime - self._startTime) / 1e9
305 elif self._resumeTime is None: # is paused
306 diff = (self._stopTime - self._pauseTime) / 1e9
307 self._splits.append((diff, False))
308 else: # is running
309 diff = (self._stopTime - self._resumeTime) / 1e9
310 self._splits.append((diff, True))
312 self._pauseTime = None
313 self._resumeTime = None
314 self._totalTime = self._stopTime - self._startTime
316 # FIXME: why is this unused?
317 beginEndDiff = self._endTime - self._beginTime
319 return diff
321 @property
322 def Digits(self) -> int:
323 """
324 Property to get and set the number of fractional digits (:attr:`_digits`) used by :meth:`__str__`.
326 The measurement itself is unaffected - this only decides how many digits of the duration in seconds are
327 rendered. It defaults to ``3``, which is milliseconds.
329 :returns: Number of fractional digits.
330 :raises TypeError: If the assigned value is not of type :class:`int`.
331 :raises ValueError: If the assigned value is negative or greater than 9.
332 """
333 return self._digits
335 @Digits.setter
336 def Digits(self, digits: int) -> None:
337 if not isinstance(digits, int):
338 ex = TypeError("Parameter 'digits' is not of type 'int'.")
339 ex.add_note(f"Got type '{getFullyQualifiedName(digits)}'.")
340 raise ex
341 elif not 0 <= digits <= 9:
342 ex = ValueError(f"Parameter 'digits' is out of range 0..9. Got {digits}.")
343 ex.add_note("A duration in seconds has at most 9 digits (nanoseconds).")
344 raise ex
346 self._digits = digits
348 @readonly
349 def Name(self) -> Nullable[str]:
350 """
351 Read-only property returning the name of the stopwatch.
353 :returns: Name of the stopwatch.
354 """
355 return self._name
357 @readonly
358 def IsStarted(self) -> bool:
359 """
360 Read-only property returning the IsStarted state of the stopwatch.
362 :returns: True, if stopwatch was started.
363 """
364 return self._startTime is not None and self._stopTime is None
366 @readonly
367 def IsRunning(self) -> bool:
368 """
369 Read-only property returning the IsRunning state of the stopwatch.
371 :returns: True, if stopwatch was started and is currently not paused.
372 """
373 return self._startTime is not None and self._resumeTime is not None
375 @readonly
376 def IsPaused(self) -> bool:
377 """
378 Read-only property returning the IsPaused state of the stopwatch.
380 :returns: True, if stopwatch was started and is currently paused.
381 """
382 return self._startTime is not None and self._pauseTime is not None
384 @readonly
385 def IsStopped(self) -> bool:
386 """
387 Read-only property returning the IsStopped state of the stopwatch.
389 :returns: True, if stopwatch was stopped.
390 """
391 return self._stopTime is not None
393 @readonly
394 def StartTime(self) -> Nullable[datetime]:
395 """
396 Read-only property returning the absolute time when the stopwatch was started.
398 :returns: The time when the stopwatch was started, otherwise None.
399 """
400 return self._beginTime
402 @readonly
403 def StopTime(self) -> Nullable[datetime]:
404 """
405 Read-only property returning the absolute time when the stopwatch was stopped.
407 :returns: The time when the stopwatch was stopped, otherwise None.
408 """
409 return self._endTime
411 @readonly
412 def HasSplitTimes(self) -> bool:
413 """
414 Read-only property checking if split times have been taken.
416 :returns: True, if at least one split time has been taken.
417 """
418 return len(self._splits) > 0
420 @readonly
421 def SplitCount(self) -> int:
422 """
423 Read-only property returning the number of split times.
425 :returns: Number of split times.
426 """
427 return len(self._splits)
429 @readonly
430 def ActiveCount(self) -> int:
431 """
432 Read-only property returning the number of active split times.
434 A running stopwatch is inside an active span that hasn't been recorded yet, and that span is counted here -
435 the result is what the stopwatch would report if it were stopped right now. This matches
436 :attr:`Activity`, which includes the running span's duration.
438 :returns: Number of active split times, including the one in progress.
439 """
440 if self._startTime is None:
441 return 0
443 return len([t for t, a in self._splits if a is True]) + (1 if self._resumeTime is not None else 0)
445 @readonly
446 def InactiveCount(self) -> int:
447 """
448 Read-only property returning the number of inactive split times.
450 A paused stopwatch is inside an inactive span that hasn't been recorded yet, and that span is counted here -
451 the result is what the stopwatch would report if it were stopped right now. This matches
452 :attr:`Inactivity`, which includes the paused span's duration.
454 :returns: Number of inactive split times, including the one in progress.
455 """
456 if self._startTime is None:
457 return 0
459 return len([t for t, a in self._splits if a is False]) + (1 if self._pauseTime is not None else 0)
461 @readonly
462 def Activity(self) -> timedelta:
463 """
464 Read-only property returning the duration of all active split times.
466 If the stopwatch is currently running, the duration since start or last resume operation will be included. A
467 :class:`~datetime.timedelta` resolves microseconds; :attr:`ActivityInSeconds` is the same value as a float.
469 :returns: Duration of all active split times. If the stopwatch was never started, the return value is a zero
470 duration.
471 """
472 return timedelta(seconds=self.ActivityInSeconds)
474 @readonly
475 def ActivityInSeconds(self) -> float:
476 """
477 Read-only property returning the same duration as :attr:`Activity`, but in seconds.
479 :returns: Duration of all active split times in seconds. If the stopwatch was never started, the return value will
480 be 0.0.
481 """
482 if self._startTime is None:
483 return 0.0
485 currentDiff = 0.0 if self._resumeTime is None else ((perf_counter_ns() - self._resumeTime) / 1e9)
486 return sum(t for t, a in self._splits if a is True) + currentDiff
488 @readonly
489 def Inactivity(self) -> timedelta:
490 """
491 Read-only property returning the duration of all inactive split times.
493 If the stopwatch is currently paused, the duration since last pause operation will be included. A
494 :class:`~datetime.timedelta` resolves microseconds; :attr:`InactivityInSeconds` is the same value as a float.
496 :returns: Duration of all inactive split times. If the stopwatch was never started, the return value is a zero
497 duration.
498 """
499 return timedelta(seconds=self.InactivityInSeconds)
501 @readonly
502 def InactivityInSeconds(self) -> float:
503 """
504 Read-only property returning the same duration as :attr:`Inactivity`, but in seconds.
506 :returns: Duration of all inactive split times in seconds. If the stopwatch was never started, the return value will
507 be 0.0.
508 """
509 if self._startTime is None:
510 return 0.0
512 currentDiff = 0.0 if self._pauseTime is None else ((perf_counter_ns() - self._pauseTime) / 1e9)
513 return sum(t for t, a in self._splits if a is False) + currentDiff
515 @readonly
516 def Duration(self) -> timedelta:
517 """
518 Read-only property returning the duration from start operation to stop operation.
520 If the stopwatch is not yet stopped, the duration from start to now is returned. A :class:`~datetime.timedelta`
521 formats as hours, minutes and seconds and resolves microseconds; :attr:`DurationInSeconds` and
522 :attr:`DurationInNanoseconds` are the same measurement as numbers. The duration of a stopped stopwatch is
523 computed once (:attr:`_duration`).
525 :returns: Duration since the stopwatch was started. If the stopwatch was never started, the return value is a zero
526 duration.
527 """
528 if self._duration is not None:
529 return self._duration
531 duration = timedelta(microseconds=self.DurationInNanoseconds / 1000)
532 if self._totalTime is not None:
533 self._duration = duration
535 return duration
537 @readonly
538 def DurationInSeconds(self) -> float:
539 """
540 Read-only property returning the same duration as :attr:`Duration`, but in seconds.
542 :returns: Duration since stopwatch was started in seconds. If the stopwatch was never started, the return value will
543 be 0.0.
544 """
545 return self.DurationInNanoseconds / 1e9
547 @readonly
548 def DurationInNanoseconds(self) -> int:
549 """
550 Read-only property returning the same duration as :attr:`Duration`, but in whole nanoseconds.
552 This is the measurement as the underlying :func:`time.perf_counter_ns` took it, so anything that divides a
553 duration into parts - :meth:`__format__` does - works from an integer instead of converting a float back.
555 Precision is not the reason to prefer it. A float holds a duration in seconds exactly, to the nanosecond, up
556 to :math:`2^{53}` ns - a little over 104 days - which no stopwatch will reach.
558 :returns: Duration since the stopwatch was started in nanoseconds. If the stopwatch was never started, the
559 return value will be 0.
560 """
561 if self._startTime is None:
562 return 0
563 elif self._totalTime is not None: # was stopped, so the total is final
564 return self._totalTime
566 return perf_counter_ns() - self._startTime
568 @readonly
569 def Exclude(self) -> ExcludeContextManager:
570 """
571 Return an *exclude* context manager for the stopwatch instance.
573 :returns: An excluding context manager.
574 """
575 if self._excludeContextManager is None:
576 self._excludeContextManager = ExcludeContextManager(self)
578 return self._excludeContextManager
580 def __enter__(self) -> Self:
581 """
582 Enter the context and start or resume the stopwatch.
584 An unstarted stopwatch is started, a paused one is resumed.
586 :returns: The stopwatch itself.
587 :raises StopwatchError: If the stopwatch is running.
588 :raises StopwatchError: If the stopwatch was already stopped.
589 """
590 if self._startTime is None: # start stopwatch
591 self._beginTime = datetime.now()
592 self._resumeTime = self._startTime = perf_counter_ns()
593 elif self._pauseTime is not None: # resume after pause
594 self._resumeTime = perf_counter_ns()
596 diff = (self._resumeTime - self._pauseTime) / 1e9
597 self._splits.append((diff, False))
598 self._pauseTime = None
599 elif self._resumeTime is not None: # is running?
600 raise StopwatchError("Stopwatch is currently running and can not be started/resumed again.")
601 elif self._stopTime is not None: # is stopped? 601 ↛ 604line 601 didn't jump to line 604 because the condition on line 601 was always true
602 raise StopwatchError("Stopwatch was already stopped.")
603 else:
604 raise StopwatchError("Internal error.")
606 return self
608 def __exit__(
609 self,
610 exc_type: Nullable[type[BaseException]] = None,
611 exc_val: Nullable[BaseException] = None,
612 exc_tb: Nullable[TracebackType] = None
613 ) -> Nullable[bool]:
614 """
615 Exit the context and pause or stop the stopwatch.
617 A running stopwatch is paused, if ``preferPause`` was set, otherwise it's stopped.
619 :param exc_type: Optional, exception type, otherwise None.
620 :param exc_val: Optional, exception object, otherwise None.
621 :param exc_tb: Optional, exception's traceback, otherwise None.
622 :returns: ``None``
623 :raises StopwatchError: If the stopwatch was never started.
624 :raises StopwatchError: If the stopwatch was already stopped.
625 :raises StopwatchError: If the stopwatch is paused, because it wasn't resumed.
626 """
627 if self._startTime is None: # never started?
628 raise StopwatchError("Stopwatch was never started.")
629 elif self._stopTime is not None:
630 raise StopwatchError("Stopwatch was already stopped.")
631 elif self._resumeTime is not None: # pause or stop
632 if self._preferPause:
633 self._pauseTime = perf_counter_ns()
634 diff = (self._pauseTime - self._resumeTime) / 1e9
635 self._splits.append((diff, True))
636 self._resumeTime = None
637 else:
638 self._stopTime = perf_counter_ns()
639 self._endTime = datetime.now()
641 diff = (self._stopTime - self._resumeTime) / 1e9
642 self._splits.append((diff, True))
644 self._pauseTime = None
645 self._resumeTime = None
646 self._totalTime = self._stopTime - self._startTime
647 else:
648 raise StopwatchError("Stopwatch was not resumed.")
650 def __len__(self) -> int:
651 """
652 Implementation of ``len(...)`` to return the number of split times.
654 :returns: Number of split times.
655 """
656 return len(self._splits)
658 def __getitem__(self, index: int) -> tuple[float, bool]:
659 """
660 Implementation of ``split = object[i]`` to return the i-th split time.
662 :param index: Index to access the i-th split time.
663 :returns: i-th split time as a tuple of: |br|
664 (1) delta time to the previous stopwatch operation and |br|
665 (2) a boolean indicating if the split was an activity (true) or inactivity (false).
666 :raises IndexError: If index *i* doesn't exist.
667 """
668 return self._splits[index]
670 def __iter__(self) -> Iterator[tuple[float, bool]]:
671 """
672 Return an iterator of tuples to iterate all split times.
674 If the stopwatch is not stopped yet, the last split won't be included.
676 :returns: Iterator of split time tuples of: |br|
677 (1) delta time to the previous stopwatch operation and |br|
678 (2) a boolean indicating if the split was an activity (true) or inactivity (false).
679 """
680 return self._splits.__iter__()
682 def __format__(self, formatSpec: str) -> str:
683 """
684 Return the measured duration according to the format specification.
686 .. topic:: Format Specifiers
688 An **uppercase** specifier is a field of the duration as it would be displayed. A **lowercase** specifier is
689 the whole duration expressed in one unit, which is what a report or a comparison wants.
691 +-----------+--------------------------------------------------------+
692 | Specifier | Meaning |
693 +===========+========================================================+
694 | ``%H`` | hours, not capped - a 26 hour measurement shows ``26`` |
695 +-----------+--------------------------------------------------------+
696 | ``%M`` | minutes, ``00`` to ``59`` |
697 +-----------+--------------------------------------------------------+
698 | ``%S`` | seconds, ``00`` to ``59`` |
699 +-----------+--------------------------------------------------------+
700 | ``%L`` | fractional seconds, 3 digits (milliseconds) |
701 +-----------+--------------------------------------------------------+
702 | ``%U`` | fractional seconds, 6 digits (microseconds) |
703 +-----------+--------------------------------------------------------+
704 | ``%N`` | fractional seconds, 9 digits (nanoseconds) |
705 +-----------+--------------------------------------------------------+
706 | ``%s`` | the whole duration in seconds |
707 +-----------+--------------------------------------------------------+
708 | ``%m`` | the whole duration in milliseconds |
709 +-----------+--------------------------------------------------------+
710 | ``%u`` | the whole duration in microseconds |
711 +-----------+--------------------------------------------------------+
712 | ``%n`` | the whole duration in nanoseconds |
713 +-----------+--------------------------------------------------------+
715 The fractional specifiers are truncations of the same fraction, so ``%S.%U`` renders ``04.123456`` without
716 having to be combined with anything. ``%H`` is not capped, so ``%H:%M:%S`` never silently drops a day.
718 ``%%`` renders a literal percent sign. An empty format specification returns :meth:`__str__`.
720 :param formatSpec: The format specification, using ``%``-placeholders for the duration's parts.
721 :returns: The formatted duration.
722 :raises ValueError: If the format specification contains an unknown placeholder.
723 """
724 if formatSpec == "":
725 return self.__str__()
727 nanoseconds = self.DurationInNanoseconds
728 seconds, fraction = divmod(nanoseconds, 1_000_000_000)
729 minutes, secondField = divmod(seconds, 60)
730 hours, minuteField = divmod(minutes, 60)
732 result = formatSpec
733 for placeholder, value in (
734 ("%H", f"{hours:02}"),
735 ("%M", f"{minuteField:02}"),
736 ("%S", f"{secondField:02}"),
737 ("%L", f"{fraction // 1_000_000:03}"),
738 ("%U", f"{fraction // 1_000:06}"),
739 ("%N", f"{fraction:09}"),
740 ("%s", f"{nanoseconds // 1_000_000_000}"),
741 ("%m", f"{nanoseconds // 1_000_000}"),
742 ("%u", f"{nanoseconds // 1_000}"),
743 ("%n", f"{nanoseconds}"),
744 ):
745 result = result.replace(placeholder, value)
747 if (position := result.find("%")) != -1:
748 following = result[position + 1] if position + 1 < len(result) else ""
749 if following != "%":
750 raise ValueError(f"Unknown format specifier '%{following}' in '{formatSpec}'.")
752 return result.replace("%%", "%")
754 def __str__(self) -> str:
755 """
756 Returns the stopwatch's state and its measured time span.
758 The duration is rendered in seconds with :attr:`Digits` fractional digits, in every state - a running and a
759 stopped stopwatch report the same unit at the same resolution.
761 :returns: The string equivalent of the stopwatch.
762 """
763 name = f" {self._name}" if self._name is not None else ""
764 duration = f"{self.DurationInSeconds:.{self._digits}f}"
766 if self.IsStopped:
767 return f"Stopwatch{name} (stopped): {self._beginTime} -> {self._endTime}: {duration}"
768 elif self.IsRunning:
769 return f"Stopwatch{name} (running): {self._beginTime} -> now: {duration}"
770 elif self.IsPaused:
771 return f"Stopwatch{name} (paused): {self._beginTime} -> now: {duration}"
772 else:
773 return f"Stopwatch{name}: not started"