Coverage for pyTooling/Stopwatch/__init__.py: 94%
252 statements
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-08 20:44 +0000
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-08 20:44 +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.")
198 if self._stopTime is not None: 198 ↛ 199line 198 didn't jump to line 199 because the condition on line 198 was never true
199 raise StopwatchError("Stopwatch was already used (started and stopped).")
201 self._beginTime = datetime.now()
202 self._resumeTime = self._startTime = perf_counter_ns()
204 def Split(self) -> float:
205 """
206 Take a split time and return the time delta to the previous stopwatch operation.
208 The stopwatch needs to be running to take a split time. See property :data:`IsRunning` to check if the stopwatch
209 is running and the split operation is possible. |br|
210 Depending on the previous operation, the time delta will be:
212 * the duration from start operation to the first split.
213 * the duration from last resume to this split.
215 :returns: Duration in seconds since last stopwatch operation
216 :raises StopwatchError: If stopwatch was not started or resumed.
217 """
218 pauseTime = perf_counter_ns()
220 if self._resumeTime is None:
221 raise StopwatchError("Stopwatch was not started or resumed.")
223 diff = (pauseTime - self._resumeTime) / 1e9
224 self._splits.append((diff, True))
225 self._resumeTime = pauseTime
227 return diff
229 def Pause(self) -> float:
230 """
231 Pause the stopwatch and return the time delta to the previous stopwatch operation.
233 The stopwatch needs to be running to pause it. See property :data:`IsRunning` to check if the stopwatch is running
234 and the pause operation is possible. |br|
235 Depending on the previous operation, the time delta will be:
237 * the duration from start operation to the first pause.
238 * the duration from last resume to this pause.
240 :returns: Duration in seconds since last stopwatch operation
241 :raises StopwatchError: If stopwatch was not started or resumed.
242 """
243 self._pauseTime = perf_counter_ns()
245 if self._resumeTime is None:
246 raise StopwatchError("Stopwatch was not started or resumed.")
248 diff = (self._pauseTime - self._resumeTime) / 1e9
249 self._splits.append((diff, True))
250 self._resumeTime = None
252 return diff
254 def Resume(self) -> float:
255 """
256 Resume the stopwatch and return the time delta to the previous pause operation.
258 The stopwatch needs to be paused to resume it. See property :data:`IsPaused` to check if the stopwatch is paused
259 and the resume operation is possible. |br|
260 The time delta will be the duration from last pause to this resume.
262 :returns: Duration in seconds since last pause operation
263 :raises StopwatchError: If stopwatch was not paused.
264 """
265 self._resumeTime = perf_counter_ns()
267 if self._pauseTime is None:
268 raise StopwatchError("Stopwatch was not paused.")
270 diff = (self._resumeTime - self._pauseTime) / 1e9
271 self._splits.append((diff, False))
272 self._pauseTime = None
274 return diff
276 def Stop(self) -> float:
277 """
278 Stop the stopwatch and return the time delta to the previous stopwatch operation.
280 The stopwatch needs to be started to stop it. See property :data:`IsStarted` to check if the stopwatch was started
281 and the stop operation is possible. |br|
282 Depending on the previous operation, the time delta will be:
284 * the duration from start operation to the stop operation.
285 * the duration from last resume to the stop operation.
287 :returns: Duration in seconds since last stopwatch operation
288 :raises StopwatchError: If stopwatch was not started.
289 :raises StopwatchError: If stopwatch was already stopped.
290 """
291 self._stopTime = perf_counter_ns()
292 self._endTime = datetime.now()
294 if self._startTime is None:
295 raise StopwatchError("Stopwatch was never started.")
296 if self._totalTime is not None: 296 ↛ 297line 296 didn't jump to line 297 because the condition on line 296 was never true
297 raise StopwatchError("Stopwatch was already stopped.")
299 if len(self._splits) == 0: # was never paused
300 diff = (self._stopTime - self._startTime) / 1e9
301 elif self._resumeTime is None: # is paused
302 diff = (self._stopTime - self._pauseTime) / 1e9
303 self._splits.append((diff, False))
304 else: # is running
305 diff = (self._stopTime - self._resumeTime) / 1e9
306 self._splits.append((diff, True))
308 self._pauseTime = None
309 self._resumeTime = None
310 self._totalTime = self._stopTime - self._startTime
312 # FIXME: why is this unused?
313 beginEndDiff = self._endTime - self._beginTime
315 return diff
317 @property
318 def Digits(self) -> int:
319 """
320 Property to get and set the number of fractional digits (:attr:`_digits`) used by :meth:`__str__`.
322 The measurement itself is unaffected - this only decides how many digits of the duration in seconds are
323 rendered. It defaults to ``3``, which is milliseconds.
325 :returns: Number of fractional digits.
326 :raises TypeError: If the assigned value is not of type :class:`int`.
327 :raises ValueError: If the assigned value is negative or greater than 9.
328 """
329 return self._digits
331 @Digits.setter
332 def Digits(self, digits: int) -> None:
333 if not isinstance(digits, int):
334 ex = TypeError("Parameter 'digits' is not of type 'int'.")
335 ex.add_note(f"Got type '{getFullyQualifiedName(digits)}'.")
336 raise ex
337 elif not 0 <= digits <= 9:
338 ex = ValueError(f"Parameter 'digits' is out of range 0..9. Got {digits}.")
339 ex.add_note("A duration in seconds has at most 9 digits (nanoseconds).")
340 raise ex
342 self._digits = digits
344 @readonly
345 def Name(self) -> Nullable[str]:
346 """
347 Read-only property returning the name of the stopwatch.
349 :returns: Name of the stopwatch.
350 """
351 return self._name
353 @readonly
354 def IsStarted(self) -> bool:
355 """
356 Read-only property returning the IsStarted state of the stopwatch.
358 :returns: True, if stopwatch was started.
359 """
360 return self._startTime is not None and self._stopTime is None
362 @readonly
363 def IsRunning(self) -> bool:
364 """
365 Read-only property returning the IsRunning state of the stopwatch.
367 :returns: True, if stopwatch was started and is currently not paused.
368 """
369 return self._startTime is not None and self._resumeTime is not None
371 @readonly
372 def IsPaused(self) -> bool:
373 """
374 Read-only property returning the IsPaused state of the stopwatch.
376 :returns: True, if stopwatch was started and is currently paused.
377 """
378 return self._startTime is not None and self._pauseTime is not None
380 @readonly
381 def IsStopped(self) -> bool:
382 """
383 Read-only property returning the IsStopped state of the stopwatch.
385 :returns: True, if stopwatch was stopped.
386 """
387 return self._stopTime is not None
389 @readonly
390 def StartTime(self) -> Nullable[datetime]:
391 """
392 Read-only property returning the absolute time when the stopwatch was started.
394 :returns: The time when the stopwatch was started, otherwise None.
395 """
396 return self._beginTime
398 @readonly
399 def StopTime(self) -> Nullable[datetime]:
400 """
401 Read-only property returning the absolute time when the stopwatch was stopped.
403 :returns: The time when the stopwatch was stopped, otherwise None.
404 """
405 return self._endTime
407 @readonly
408 def HasSplitTimes(self) -> bool:
409 """
410 Read-only property checking if split times have been taken.
412 :returns: True, if at least one split time has been taken.
413 """
414 return len(self._splits) > 0
416 @readonly
417 def SplitCount(self) -> int:
418 """
419 Read-only property returning the number of split times.
421 :returns: Number of split times.
422 """
423 return len(self._splits)
425 @readonly
426 def ActiveCount(self) -> int:
427 """
428 Read-only property returning the number of active split times.
430 A running stopwatch is inside an active span that hasn't been recorded yet, and that span is counted here -
431 the result is what the stopwatch would report if it were stopped right now. This matches
432 :attr:`Activity`, which includes the running span's duration.
434 :returns: Number of active split times, including the one in progress.
435 """
436 if self._startTime is None:
437 return 0
439 return len([t for t, a in self._splits if a is True]) + (1 if self._resumeTime is not None else 0)
441 @readonly
442 def InactiveCount(self) -> int:
443 """
444 Read-only property returning the number of inactive split times.
446 A paused stopwatch is inside an inactive span that hasn't been recorded yet, and that span is counted here -
447 the result is what the stopwatch would report if it were stopped right now. This matches
448 :attr:`Inactivity`, which includes the paused span's duration.
450 :returns: Number of inactive split times, including the one in progress.
451 """
452 if self._startTime is None:
453 return 0
455 return len([t for t, a in self._splits if a is False]) + (1 if self._pauseTime is not None else 0)
457 @readonly
458 def Activity(self) -> float:
459 """
460 Read-only property returning the duration of all active split times.
462 If the stopwatch is currently running, the duration since start or last resume operation will be included.
464 :returns: Duration of all active split times in seconds. If the stopwatch was never started, the return value will
465 be 0.0.
466 """
467 if self._startTime is None: 467 ↛ 468line 467 didn't jump to line 468 because the condition on line 467 was never true
468 return 0.0
470 currentDiff = 0.0 if self._resumeTime is None else ((perf_counter_ns() - self._resumeTime) / 1e9)
471 return sum(t for t, a in self._splits if a is True) + currentDiff
473 @readonly
474 def Inactivity(self) -> float:
475 """
476 Read-only property returning the duration of all inactive split times.
478 If the stopwatch is currently paused, the duration since last pause operation will be included.
480 :returns: Duration of all inactive split times in seconds. If the stopwatch was never started, the return value will
481 be 0.0.
482 """
483 if self._startTime is None: 483 ↛ 484line 483 didn't jump to line 484 because the condition on line 483 was never true
484 return 0.0
486 currentDiff = 0.0 if self._pauseTime is None else ((perf_counter_ns() - self._pauseTime) / 1e9)
487 return sum(t for t, a in self._splits if a is False) + currentDiff
489 @readonly
490 def Duration(self) -> float:
491 """
492 Read-only property returning the duration from start operation to stop operation.
494 If the stopwatch is not yet stopped, the duration from start to now is returned.
496 :returns: Duration since stopwatch was started in seconds. If the stopwatch was never started, the return value will
497 be 0.0.
498 """
499 return self.DurationInNanoseconds / 1e9
501 @readonly
502 def DurationInNanoseconds(self) -> int:
503 """
504 Read-only property returning the same duration as :attr:`Duration`, but in whole nanoseconds.
506 This is the measurement as the underlying :func:`time.perf_counter_ns` took it, so anything that divides a
507 duration into parts - :meth:`__format__` does - works from an integer instead of converting a float back.
509 Precision is not the reason to prefer it. A float holds a duration in seconds exactly, to the nanosecond, up
510 to :math:`2^{53}` ns - a little over 104 days - which no stopwatch will reach.
512 :returns: Duration since the stopwatch was started in nanoseconds. If the stopwatch was never started, the
513 return value will be 0.
514 """
515 if self._startTime is None:
516 return 0
517 elif self._totalTime is not None: # was stopped, so the total is final
518 return self._totalTime
520 return perf_counter_ns() - self._startTime
522 @readonly
523 def Exclude(self) -> ExcludeContextManager:
524 """
525 Return an *exclude* context manager for the stopwatch instance.
527 :returns: An excluding context manager.
528 """
529 if self._excludeContextManager is None:
530 self._excludeContextManager = ExcludeContextManager(self)
532 return self._excludeContextManager
534 def __enter__(self) -> Self:
535 """
536 Implementation of the :ref:`context manager protocol's <context-managers>` ``__enter__(...)`` method.
538 An unstarted stopwatch will be started. A paused stopwatch will be resumed.
540 :returns: The stopwatch itself.
541 :raises StopwatchError: If the stopwatch was already started.
542 """
543 if self._startTime is None: # start stopwatch
544 self._beginTime = datetime.now()
545 self._resumeTime = self._startTime = perf_counter_ns()
546 elif self._pauseTime is not None: # resume after pause
547 self._resumeTime = perf_counter_ns()
549 diff = (self._resumeTime - self._pauseTime) / 1e9
550 self._splits.append((diff, False))
551 self._pauseTime = None
552 elif self._resumeTime is not None: # is running? 552 ↛ 553line 552 didn't jump to line 553 because the condition on line 552 was never true
553 raise StopwatchError("Stopwatch is currently running and can not be started/resumed again.")
554 elif self._stopTime is not None: # is stopped? 554 ↛ 557line 554 didn't jump to line 557 because the condition on line 554 was always true
555 raise StopwatchError("Stopwatch was already stopped.")
556 else:
557 raise StopwatchError("Internal error.")
559 return self
561 def __exit__(
562 self,
563 exc_type: Nullable[type[BaseException]] = None,
564 exc_val: Nullable[BaseException] = None,
565 exc_tb: Nullable[TracebackType] = None
566 ) -> Nullable[bool]:
567 """
568 Implementation of the :ref:`context manager protocol's <context-managers>` ``__exit__(...)`` method.
570 A running stopwatch will be paused or stopped depending on the configured ``preferPause`` behavior.
572 :param exc_type: Exception type, otherwise None.
573 :param exc_val: Exception object, otherwise None.
574 :param exc_tb: Exception's traceback, otherwise None.
575 :returns: True, if exceptions should be suppressed.
576 :raises StopwatchError: If the stopwatch was already stopped.
577 """
578 if self._startTime is None: # never started? 578 ↛ 579line 578 didn't jump to line 579 because the condition on line 578 was never true
579 raise StopwatchError("Stopwatch was never started.")
580 elif self._stopTime is not None: 580 ↛ 581line 580 didn't jump to line 581 because the condition on line 580 was never true
581 raise StopwatchError("Stopwatch was already stopped.")
582 elif self._resumeTime is not None: # pause or stop 582 ↛ 599line 582 didn't jump to line 599 because the condition on line 582 was always true
583 if self._preferPause:
584 self._pauseTime = perf_counter_ns()
585 diff = (self._pauseTime - self._resumeTime) / 1e9
586 self._splits.append((diff, True))
587 self._resumeTime = None
588 else:
589 self._stopTime = perf_counter_ns()
590 self._endTime = datetime.now()
592 diff = (self._stopTime - self._resumeTime) / 1e9
593 self._splits.append((diff, True))
595 self._pauseTime = None
596 self._resumeTime = None
597 self._totalTime = self._stopTime - self._startTime
598 else:
599 raise StopwatchError("Stopwatch was not resumed.")
601 def __len__(self) -> int:
602 """
603 Implementation of ``len(...)`` to return the number of split times.
605 :returns: Number of split times.
606 """
607 return len(self._splits)
609 def __getitem__(self, index: int) -> tuple[float, bool]:
610 """
611 Implementation of ``split = object[i]`` to return the i-th split time.
613 :param index: Index to access the i-th split time.
614 :returns: i-th split time as a tuple of: |br|
615 (1) delta time to the previous stopwatch operation and |br|
616 (2) a boolean indicating if the split was an activity (true) or inactivity (false).
617 :raises KeyError: If index *i* doesn't exist.
618 """
619 return self._splits[index]
621 def __iter__(self) -> Iterator[tuple[float, bool]]:
622 """
623 Return an iterator of tuples to iterate all split times.
625 If the stopwatch is not stopped yet, the last split won't be included.
627 :returns: Iterator of split time tuples of: |br|
628 (1) delta time to the previous stopwatch operation and |br|
629 (2) a boolean indicating if the split was an activity (true) or inactivity (false).
630 """
631 return self._splits.__iter__()
633 def __format__(self, formatSpec: str) -> str:
634 """
635 Return the measured duration according to the format specification.
637 .. topic:: Format Specifiers
639 An **uppercase** specifier is a field of the duration as it would be displayed. A **lowercase** specifier is
640 the whole duration expressed in one unit, which is what a report or a comparison wants.
642 +-----------+--------------------------------------------------------+
643 | Specifier | Meaning |
644 +===========+========================================================+
645 | ``%H`` | hours, not capped - a 26 hour measurement shows ``26`` |
646 +-----------+--------------------------------------------------------+
647 | ``%M`` | minutes, ``00`` to ``59`` |
648 +-----------+--------------------------------------------------------+
649 | ``%S`` | seconds, ``00`` to ``59`` |
650 +-----------+--------------------------------------------------------+
651 | ``%L`` | fractional seconds, 3 digits (milliseconds) |
652 +-----------+--------------------------------------------------------+
653 | ``%U`` | fractional seconds, 6 digits (microseconds) |
654 +-----------+--------------------------------------------------------+
655 | ``%N`` | fractional seconds, 9 digits (nanoseconds) |
656 +-----------+--------------------------------------------------------+
657 | ``%s`` | the whole duration in seconds |
658 +-----------+--------------------------------------------------------+
659 | ``%m`` | the whole duration in milliseconds |
660 +-----------+--------------------------------------------------------+
661 | ``%u`` | the whole duration in microseconds |
662 +-----------+--------------------------------------------------------+
663 | ``%n`` | the whole duration in nanoseconds |
664 +-----------+--------------------------------------------------------+
666 The fractional specifiers are truncations of the same fraction, so ``%S.%U`` renders ``04.123456`` without
667 having to be combined with anything. ``%H`` is not capped, so ``%H:%M:%S`` never silently drops a day.
669 ``%%`` renders a literal percent sign. An empty format specification returns :meth:`__str__`.
671 :param formatSpec: The format specification, using ``%``-placeholders for the duration's parts.
672 :returns: The formatted duration.
673 :raises ValueError: If the format specification contains an unknown placeholder.
674 """
675 if formatSpec == "":
676 return self.__str__()
678 nanoseconds = self.DurationInNanoseconds
679 seconds, fraction = divmod(nanoseconds, 1_000_000_000)
680 minutes, secondField = divmod(seconds, 60)
681 hours, minuteField = divmod(minutes, 60)
683 result = formatSpec
684 for placeholder, value in (
685 ("%H", f"{hours:02}"),
686 ("%M", f"{minuteField:02}"),
687 ("%S", f"{secondField:02}"),
688 ("%L", f"{fraction // 1_000_000:03}"),
689 ("%U", f"{fraction // 1_000:06}"),
690 ("%N", f"{fraction:09}"),
691 ("%s", f"{nanoseconds // 1_000_000_000}"),
692 ("%m", f"{nanoseconds // 1_000_000}"),
693 ("%u", f"{nanoseconds // 1_000}"),
694 ("%n", f"{nanoseconds}"),
695 ):
696 result = result.replace(placeholder, value)
698 if (position := result.find("%")) != -1:
699 following = result[position + 1] if position + 1 < len(result) else ""
700 if following != "%":
701 raise ValueError(f"Unknown format specifier '%{following}' in '{formatSpec}'.")
703 return result.replace("%%", "%")
705 def __str__(self) -> str:
706 """
707 Returns the stopwatch's state and its measured time span.
709 The duration is rendered in seconds with :attr:`Digits` fractional digits, in every state - a running and a
710 stopped stopwatch report the same unit at the same resolution.
712 :returns: The string equivalent of the stopwatch.
713 """
714 name = f" {self._name}" if self._name is not None else ""
715 duration = f"{self.Duration:.{self._digits}f}"
717 if self.IsStopped:
718 return f"Stopwatch{name} (stopped): {self._beginTime} -> {self._endTime}: {duration}"
719 elif self.IsRunning:
720 return f"Stopwatch{name} (running): {self._beginTime} -> now: {duration}"
721 elif self.IsPaused:
722 return f"Stopwatch{name} (paused): {self._beginTime} -> now: {duration}"
723 else:
724 return f"Stopwatch{name}: not started"