Coverage for pyTooling/Stopwatch/__init__.py: 94%
252 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-03 23:02 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-03 23:02 +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.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: Exception type
101 :param exc_val: Exception instance
102 :param exc_tb: 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 _splits: list[tuple[float, bool]] #: Split times as (duration, is-active) pairs, in the order they were taken.
137 _excludeContextManager: ExcludeContextManager #: The nested context manager excluding time spans from measurement.
139 def __init__(
140 self,
141 name: Nullable[str] = None,
142 started: bool = False,
143 preferPause: bool = False,
144 digits: int = 3
145 ) -> None:
146 """
147 Initializes the fields of the stopwatch.
149 If parameter ``started`` is set to true, the stopwatch will immediately start.
151 :param name: Optional, name of the stopwatch.
152 :param started: Optional, if ``True``, start the stopwatch immediately.
153 :param preferPause: Optional, if ``True``, ``__exit__(...)`` prefers pause over stop behavior.
154 :param digits: Optional, number of fractional digits :meth:`__str__` renders the duration with.
155 :raises TypeError: If parameter 'digits' is not of type :class:`int`.
156 :raises ValueError: If parameter 'digits' is negative or greater than 9.
157 """
158 if not isinstance(digits, int):
159 ex = TypeError("Parameter 'digits' is not of type 'int'.")
160 ex.add_note(f"Got type '{getFullyQualifiedName(digits)}'.")
161 raise ex
162 elif not 0 <= digits <= 9:
163 ex = ValueError(f"Parameter 'digits' is out of range 0..9. Got {digits}.")
164 ex.add_note("A duration in seconds has at most 9 digits (nanoseconds).")
165 raise ex
167 self._name = name
168 self._preferPause = preferPause
169 self._digits = digits
171 self._endTime = None
172 self._pauseTime = None
173 self._stopTime = None
174 self._totalTime = None
175 self._splits = []
177 self._excludeContextManager = None
179 if started is False:
180 self._beginTime = None
181 self._startTime = None
182 self._resumeTime = None
183 else:
184 self._beginTime = datetime.now()
185 self._resumeTime = self._startTime = perf_counter_ns()
187 def Start(self) -> None:
188 """
189 Start the stopwatch.
191 A stopwatch can only be started once. There is no restart or reset operation provided.
193 :raises StopwatchError: If stopwatch was already started.
194 :raises StopwatchError: If stopwatch was already started and stopped.
195 """
196 if self._startTime is not None:
197 raise StopwatchError("Stopwatch was already started.")
199 if self._stopTime is not None: 199 ↛ 200line 199 didn't jump to line 200 because the condition on line 199 was never true
200 raise StopwatchError("Stopwatch was already used (started and stopped).")
202 self._beginTime = datetime.now()
203 self._resumeTime = self._startTime = perf_counter_ns()
205 def Split(self) -> float:
206 """
207 Take a split time and return the time delta to the previous stopwatch operation.
209 The stopwatch needs to be running to take a split time. See property :data:`IsRunning` to check if the stopwatch
210 is running and the split operation is possible. |br|
211 Depending on the previous operation, the time delta will be:
213 * the duration from start operation to the first split.
214 * the duration from last resume to this split.
216 :returns: Duration in seconds since last stopwatch operation
217 :raises StopwatchError: If stopwatch was not started or resumed.
218 """
219 pauseTime = perf_counter_ns()
221 if self._resumeTime is None:
222 raise StopwatchError("Stopwatch was not started or resumed.")
224 diff = (pauseTime - self._resumeTime) / 1e9
225 self._splits.append((diff, True))
226 self._resumeTime = pauseTime
228 return diff
230 def Pause(self) -> float:
231 """
232 Pause the stopwatch and return the time delta to the previous stopwatch operation.
234 The stopwatch needs to be running to pause it. See property :data:`IsRunning` to check if the stopwatch is running
235 and the pause operation is possible. |br|
236 Depending on the previous operation, the time delta will be:
238 * the duration from start operation to the first pause.
239 * the duration from last resume to this pause.
241 :returns: Duration in seconds since last stopwatch operation
242 :raises StopwatchError: If stopwatch was not started or resumed.
243 """
244 self._pauseTime = perf_counter_ns()
246 if self._resumeTime is None:
247 raise StopwatchError("Stopwatch was not started or resumed.")
249 diff = (self._pauseTime - self._resumeTime) / 1e9
250 self._splits.append((diff, True))
251 self._resumeTime = None
253 return diff
255 def Resume(self) -> float:
256 """
257 Resume the stopwatch and return the time delta to the previous pause operation.
259 The stopwatch needs to be paused to resume it. See property :data:`IsPaused` to check if the stopwatch is paused
260 and the resume operation is possible. |br|
261 The time delta will be the duration from last pause to this resume.
263 :returns: Duration in seconds since last pause operation
264 :raises StopwatchError: If stopwatch was not paused.
265 """
266 self._resumeTime = perf_counter_ns()
268 if self._pauseTime is None:
269 raise StopwatchError("Stopwatch was not paused.")
271 diff = (self._resumeTime - self._pauseTime) / 1e9
272 self._splits.append((diff, False))
273 self._pauseTime = None
275 return diff
277 def Stop(self) -> float:
278 """
279 Stop the stopwatch and return the time delta to the previous stopwatch operation.
281 The stopwatch needs to be started to stop it. See property :data:`IsStarted` to check if the stopwatch was started
282 and the stop operation is possible. |br|
283 Depending on the previous operation, the time delta will be:
285 * the duration from start operation to the stop operation.
286 * the duration from last resume to the stop operation.
288 :returns: Duration in seconds since last stopwatch operation
289 :raises StopwatchError: If stopwatch was not started.
290 :raises StopwatchError: If stopwatch was already stopped.
291 """
292 self._stopTime = perf_counter_ns()
293 self._endTime = datetime.now()
295 if self._startTime is None:
296 raise StopwatchError("Stopwatch was never started.")
298 if self._totalTime is not None: 298 ↛ 299line 298 didn't jump to line 299 because the condition on line 298 was never true
299 raise StopwatchError("Stopwatch was already stopped.")
301 if len(self._splits) == 0: # was never paused
302 diff = (self._stopTime - self._startTime) / 1e9
303 elif self._resumeTime is None: # is paused
304 diff = (self._stopTime - self._pauseTime) / 1e9
305 self._splits.append((diff, False))
306 else: # is running
307 diff = (self._stopTime - self._resumeTime) / 1e9
308 self._splits.append((diff, True))
310 self._pauseTime = None
311 self._resumeTime = None
312 self._totalTime = self._stopTime - self._startTime
314 # FIXME: why is this unused?
315 beginEndDiff = self._endTime - self._beginTime
317 return diff
319 @property
320 def Digits(self) -> int:
321 """
322 Property to get and set the number of fractional digits (:attr:`_digits`) used by :meth:`__str__`.
324 The measurement itself is unaffected - this only decides how many digits of the duration in seconds are
325 rendered. It defaults to ``3``, which is milliseconds.
327 :returns: Number of fractional digits.
328 :raises TypeError: If the assigned value is not of type :class:`int`.
329 :raises ValueError: If the assigned value is negative or greater than 9.
330 """
331 return self._digits
333 @Digits.setter
334 def Digits(self, digits: int) -> None:
335 if not isinstance(digits, int):
336 ex = TypeError("Parameter 'digits' is not of type 'int'.")
337 ex.add_note(f"Got type '{getFullyQualifiedName(digits)}'.")
338 raise ex
339 elif not 0 <= digits <= 9:
340 ex = ValueError(f"Parameter 'digits' is out of range 0..9. Got {digits}.")
341 ex.add_note("A duration in seconds has at most 9 digits (nanoseconds).")
342 raise ex
344 self._digits = digits
346 @readonly
347 def Name(self) -> Nullable[str]:
348 """
349 Read-only property returning the name of the stopwatch.
351 :returns: Name of the stopwatch.
352 """
353 return self._name
355 @readonly
356 def IsStarted(self) -> bool:
357 """
358 Read-only property returning the IsStarted state of the stopwatch.
360 :returns: True, if stopwatch was started.
361 """
362 return self._startTime is not None and self._stopTime is None
364 @readonly
365 def IsRunning(self) -> bool:
366 """
367 Read-only property returning the IsRunning state of the stopwatch.
369 :returns: True, if stopwatch was started and is currently not paused.
370 """
371 return self._startTime is not None and self._resumeTime is not None
373 @readonly
374 def IsPaused(self) -> bool:
375 """
376 Read-only property returning the IsPaused state of the stopwatch.
378 :returns: True, if stopwatch was started and is currently paused.
379 """
380 return self._startTime is not None and self._pauseTime is not None
382 @readonly
383 def IsStopped(self) -> bool:
384 """
385 Read-only property returning the IsStopped state of the stopwatch.
387 :returns: True, if stopwatch was stopped.
388 """
389 return self._stopTime is not None
391 @readonly
392 def StartTime(self) -> Nullable[datetime]:
393 """
394 Read-only property returning the absolute time when the stopwatch was started.
396 :returns: The time when the stopwatch was started, otherwise None.
397 """
398 return self._beginTime
400 @readonly
401 def StopTime(self) -> Nullable[datetime]:
402 """
403 Read-only property returning the absolute time when the stopwatch was stopped.
405 :returns: The time when the stopwatch was stopped, otherwise None.
406 """
407 return self._endTime
409 @readonly
410 def HasSplitTimes(self) -> bool:
411 """
412 Read-only property checking if split times have been taken.
414 :returns: True, if at least one split time has been taken.
415 """
416 return len(self._splits) > 0
418 @readonly
419 def SplitCount(self) -> int:
420 """
421 Read-only property returning the number of split times.
423 :returns: Number of split times.
424 """
425 return len(self._splits)
427 @readonly
428 def ActiveCount(self) -> int:
429 """
430 Read-only property returning the number of active split times.
432 A running stopwatch is inside an active span that hasn't been recorded yet, and that span is counted here -
433 the result is what the stopwatch would report if it were stopped right now. This matches
434 :attr:`Activity`, which includes the running span's duration.
436 :returns: Number of active split times, including the one in progress.
437 """
438 if self._startTime is None:
439 return 0
441 return len([t for t, a in self._splits if a is True]) + (1 if self._resumeTime is not None else 0)
443 @readonly
444 def InactiveCount(self) -> int:
445 """
446 Read-only property returning the number of inactive split times.
448 A paused stopwatch is inside an inactive span that hasn't been recorded yet, and that span is counted here -
449 the result is what the stopwatch would report if it were stopped right now. This matches
450 :attr:`Inactivity`, which includes the paused span's duration.
452 :returns: Number of inactive split times, including the one in progress.
453 """
454 if self._startTime is None:
455 return 0
457 return len([t for t, a in self._splits if a is False]) + (1 if self._pauseTime is not None else 0)
459 @readonly
460 def Activity(self) -> float:
461 """
462 Read-only property returning the duration of all active split times.
464 If the stopwatch is currently running, the duration since start or last resume operation will be included.
466 :returns: Duration of all active split times in seconds. If the stopwatch was never started, the return value will
467 be 0.0.
468 """
469 if self._startTime is None: 469 ↛ 470line 469 didn't jump to line 470 because the condition on line 469 was never true
470 return 0.0
472 currentDiff = 0.0 if self._resumeTime is None else ((perf_counter_ns() - self._resumeTime) / 1e9)
473 return sum(t for t, a in self._splits if a is True) + currentDiff
475 @readonly
476 def Inactivity(self) -> float:
477 """
478 Read-only property returning the duration of all inactive split times.
480 If the stopwatch is currently paused, the duration since last pause operation will be included.
482 :returns: Duration of all inactive split times in seconds. If the stopwatch was never started, the return value will
483 be 0.0.
484 """
485 if self._startTime is None: 485 ↛ 486line 485 didn't jump to line 486 because the condition on line 485 was never true
486 return 0.0
488 currentDiff = 0.0 if self._pauseTime is None else ((perf_counter_ns() - self._pauseTime) / 1e9)
489 return sum(t for t, a in self._splits if a is False) + currentDiff
491 @readonly
492 def Duration(self) -> float:
493 """
494 Read-only property returning the duration from start operation to stop operation.
496 If the stopwatch is not yet stopped, the duration from start to now is returned.
498 :returns: Duration since stopwatch was started in seconds. If the stopwatch was never started, the return value will
499 be 0.0.
500 """
501 return self.DurationInNanoseconds / 1e9
503 @readonly
504 def DurationInNanoseconds(self) -> int:
505 """
506 Read-only property returning the same duration as :attr:`Duration`, but in whole nanoseconds.
508 This is the measurement as the underlying :func:`time.perf_counter_ns` took it, so anything that divides a
509 duration into parts - :meth:`__format__` does - works from an integer instead of converting a float back.
511 Precision is not the reason to prefer it. A float holds a duration in seconds exactly, to the nanosecond, up
512 to :math:`2^{53}` ns - a little over 104 days - which no stopwatch will reach.
514 :returns: Duration since the stopwatch was started in nanoseconds. If the stopwatch was never started, the
515 return value will be 0.
516 """
517 if self._startTime is None:
518 return 0
519 elif self._totalTime is not None: # was stopped, so the total is final
520 return self._totalTime
522 return perf_counter_ns() - self._startTime
524 @readonly
525 def Exclude(self) -> ExcludeContextManager:
526 """
527 Return an *exclude* context manager for the stopwatch instance.
529 :returns: An excluding context manager.
530 """
531 if self._excludeContextManager is None:
532 self._excludeContextManager = ExcludeContextManager(self)
534 return self._excludeContextManager
536 def __enter__(self) -> Self:
537 """
538 Implementation of the :ref:`context manager protocol's <context-managers>` ``__enter__(...)`` method.
540 An unstarted stopwatch will be started. A paused stopwatch will be resumed.
542 :returns: The stopwatch itself.
543 :raises StopwatchError: If the stopwatch was already started.
544 """
545 if self._startTime is None: # start stopwatch
546 self._beginTime = datetime.now()
547 self._resumeTime = self._startTime = perf_counter_ns()
548 elif self._pauseTime is not None: # resume after pause
549 self._resumeTime = perf_counter_ns()
551 diff = (self._resumeTime - self._pauseTime) / 1e9
552 self._splits.append((diff, False))
553 self._pauseTime = None
554 elif self._resumeTime is not None: # is running? 554 ↛ 555line 554 didn't jump to line 555 because the condition on line 554 was never true
555 raise StopwatchError("Stopwatch is currently running and can not be started/resumed again.")
556 elif self._stopTime is not None: # is stopped? 556 ↛ 559line 556 didn't jump to line 559 because the condition on line 556 was always true
557 raise StopwatchError("Stopwatch was already stopped.")
558 else:
559 raise StopwatchError("Internal error.")
561 return self
563 def __exit__(
564 self,
565 exc_type: Nullable[type[BaseException]] = None,
566 exc_val: Nullable[BaseException] = None,
567 exc_tb: Nullable[TracebackType] = None
568 ) -> Nullable[bool]:
569 """
570 Implementation of the :ref:`context manager protocol's <context-managers>` ``__exit__(...)`` method.
572 A running stopwatch will be paused or stopped depending on the configured ``preferPause`` behavior.
574 :param exc_type: Exception type, otherwise None.
575 :param exc_val: Exception object, otherwise None.
576 :param exc_tb: Exception's traceback, otherwise None.
577 :returns: True, if exceptions should be suppressed.
578 :raises StopwatchError: If the stopwatch was already stopped.
579 """
580 if self._startTime is None: # never started? 580 ↛ 581line 580 didn't jump to line 581 because the condition on line 580 was never true
581 raise StopwatchError("Stopwatch was never started.")
582 elif self._stopTime is not None: 582 ↛ 583line 582 didn't jump to line 583 because the condition on line 582 was never true
583 raise StopwatchError("Stopwatch was already stopped.")
584 elif self._resumeTime is not None: # pause or stop 584 ↛ 601line 584 didn't jump to line 601 because the condition on line 584 was always true
585 if self._preferPause:
586 self._pauseTime = perf_counter_ns()
587 diff = (self._pauseTime - self._resumeTime) / 1e9
588 self._splits.append((diff, True))
589 self._resumeTime = None
590 else:
591 self._stopTime = perf_counter_ns()
592 self._endTime = datetime.now()
594 diff = (self._stopTime - self._resumeTime) / 1e9
595 self._splits.append((diff, True))
597 self._pauseTime = None
598 self._resumeTime = None
599 self._totalTime = self._stopTime - self._startTime
600 else:
601 raise StopwatchError("Stopwatch was not resumed.")
603 def __len__(self) -> int:
604 """
605 Implementation of ``len(...)`` to return the number of split times.
607 :returns: Number of split times.
608 """
609 return len(self._splits)
611 def __getitem__(self, index: int) -> tuple[float, bool]:
612 """
613 Implementation of ``split = object[i]`` to return the i-th split time.
615 :param index: Index to access the i-th split time.
616 :returns: i-th split time as a tuple of: |br|
617 (1) delta time to the previous stopwatch operation and |br|
618 (2) a boolean indicating if the split was an activity (true) or inactivity (false).
619 :raises KeyError: If index *i* doesn't exist.
620 """
621 return self._splits[index]
623 def __iter__(self) -> Iterator[tuple[float, bool]]:
624 """
625 Return an iterator of tuples to iterate all split times.
627 If the stopwatch is not stopped yet, the last split won't be included.
629 :returns: Iterator of split time tuples of: |br|
630 (1) delta time to the previous stopwatch operation and |br|
631 (2) a boolean indicating if the split was an activity (true) or inactivity (false).
632 """
633 return self._splits.__iter__()
635 def __format__(self, formatSpec: str) -> str:
636 """
637 Return the measured duration according to the format specification.
639 .. topic:: Format Specifiers
641 An **uppercase** specifier is a field of the duration as it would be displayed. A **lowercase** specifier is
642 the whole duration expressed in one unit, which is what a report or a comparison wants.
644 +-----------+--------------------------------------------------------+
645 | Specifier | Meaning |
646 +===========+========================================================+
647 | ``%H`` | hours, not capped - a 26 hour measurement shows ``26`` |
648 +-----------+--------------------------------------------------------+
649 | ``%M`` | minutes, ``00`` to ``59`` |
650 +-----------+--------------------------------------------------------+
651 | ``%S`` | seconds, ``00`` to ``59`` |
652 +-----------+--------------------------------------------------------+
653 | ``%L`` | fractional seconds, 3 digits (milliseconds) |
654 +-----------+--------------------------------------------------------+
655 | ``%U`` | fractional seconds, 6 digits (microseconds) |
656 +-----------+--------------------------------------------------------+
657 | ``%N`` | fractional seconds, 9 digits (nanoseconds) |
658 +-----------+--------------------------------------------------------+
659 | ``%s`` | the whole duration in seconds |
660 +-----------+--------------------------------------------------------+
661 | ``%m`` | the whole duration in milliseconds |
662 +-----------+--------------------------------------------------------+
663 | ``%u`` | the whole duration in microseconds |
664 +-----------+--------------------------------------------------------+
665 | ``%n`` | the whole duration in nanoseconds |
666 +-----------+--------------------------------------------------------+
668 The fractional specifiers are truncations of the same fraction, so ``%S.%U`` renders ``04.123456`` without
669 having to be combined with anything. ``%H`` is not capped, so ``%H:%M:%S`` never silently drops a day.
671 ``%%`` renders a literal percent sign. An empty format specification returns :meth:`__str__`.
673 :param formatSpec: The format specification, using ``%``-placeholders for the duration's parts.
674 :returns: The formatted duration.
675 :raises ValueError: If the format specification contains an unknown placeholder.
676 """
677 if formatSpec == "":
678 return self.__str__()
680 nanoseconds = self.DurationInNanoseconds
681 seconds, fraction = divmod(nanoseconds, 1_000_000_000)
682 minutes, secondField = divmod(seconds, 60)
683 hours, minuteField = divmod(minutes, 60)
685 result = formatSpec
686 for placeholder, value in (
687 ("%H", f"{hours:02}"),
688 ("%M", f"{minuteField:02}"),
689 ("%S", f"{secondField:02}"),
690 ("%L", f"{fraction // 1_000_000:03}"),
691 ("%U", f"{fraction // 1_000:06}"),
692 ("%N", f"{fraction:09}"),
693 ("%s", f"{nanoseconds // 1_000_000_000}"),
694 ("%m", f"{nanoseconds // 1_000_000}"),
695 ("%u", f"{nanoseconds // 1_000}"),
696 ("%n", f"{nanoseconds}"),
697 ):
698 result = result.replace(placeholder, value)
700 if (position := result.find("%")) != -1:
701 following = result[position + 1] if position + 1 < len(result) else ""
702 if following != "%":
703 raise ValueError(f"Unknown format specifier '%{following}' in '{formatSpec}'.")
705 return result.replace("%%", "%")
707 def __str__(self) -> str:
708 """
709 Returns the stopwatch's state and its measured time span.
711 The duration is rendered in seconds with :attr:`Digits` fractional digits, in every state - a running and a
712 stopped stopwatch report the same unit at the same resolution.
714 :returns: The string equivalent of the stopwatch.
715 """
716 name = f" {self._name}" if self._name is not None else ""
717 duration = f"{self.Duration:.{self._digits}f}"
719 if self.IsStopped:
720 return f"Stopwatch{name} (stopped): {self._beginTime} -> {self._endTime}: {duration}"
721 elif self.IsRunning:
722 return f"Stopwatch{name} (running): {self._beginTime} -> now: {duration}"
723 elif self.IsPaused:
724 return f"Stopwatch{name} (paused): {self._beginTime} -> now: {duration}"
725 else:
726 return f"Stopwatch{name}: not started"