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

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. 

33 

34.. hint:: 

35 

36 See :ref:`high-level help <COMMON/Stopwatch>` for explanations and usage examples. 

37 

38.. seealso:: 

39 

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 

46 

47from datetime import datetime 

48from time import perf_counter_ns 

49from types import TracebackType 

50from typing import Optional as Nullable, Iterator, Self 

51 

52from pyTooling.Decorators import export, readonly 

53from pyTooling.MetaClasses import SlottedObject 

54from pyTooling.Exceptions import ToolingException 

55 

56 

57@export 

58class StopwatchException(ToolingException): 

59 """This exception is caused by wrong usage of the stopwatch.""" 

60 

61 

62@export 

63class ExcludeContextManager: 

64 """ 

65 A stopwatch context manager for excluding certain time spans from measurement. 

66 

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. 

71 

72 def __init__(self, stopwatch: Stopwatch) -> None: 

73 """ 

74 Initializes an excluding context manager. 

75 

76 :param stopwatch: Reference to the stopwatch. 

77 """ 

78 self._stopwatch = stopwatch 

79 

80 def __enter__(self) -> Self: 

81 """ 

82 Enter the context and pause the stopwatch. 

83 

84 :returns: Excluding stopwatch context manager instance. 

85 """ 

86 self._stopwatch.Pause() 

87 

88 return self 

89 

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. 

98 

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() 

105 

106 

107@export 

108class Stopwatch(SlottedObject): 

109 """ 

110 The stopwatch implements a solution to measure and collect timings. 

111 

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`. 

115 

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. 

119 

120 The stopwatch can also be used in a :ref:`with-statement <with>`, because it implements the :ref:`context manager protocol <context-managers>`. 

121 """ 

122 

123 _name: Nullable[str] #: Optional name of the stopwatch. 

124 _preferPause: bool #: If ``True``, the context manager pauses instead of stopping on exit. 

125 

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. 

134 

135 _excludeContextManager: ExcludeContextManager #: The nested context manager excluding time spans from measurement. 

136 

137 def __init__(self, name: str = None, started: bool = False, preferPause: bool = False) -> None: 

138 """ 

139 Initializes the fields of the stopwatch. 

140 

141 If parameter ``started`` is set to true, the stopwatch will immediately start. 

142 

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 

149 

150 self._endTime = None 

151 self._pauseTime = None 

152 self._stopTime = None 

153 self._totalTime = None 

154 self._splits = [] 

155 

156 self._excludeContextManager = None 

157 

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() 

165 

166 def Start(self) -> None: 

167 """ 

168 Start the stopwatch. 

169 

170 A stopwatch can only be started once. There is no restart or reset operation provided. 

171 

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).") 

179 

180 self._beginTime = datetime.now() 

181 self._resumeTime = self._startTime = perf_counter_ns() 

182 

183 def Split(self) -> float: 

184 """ 

185 Take a split time and return the time delta to the previous stopwatch operation. 

186 

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: 

190 

191 * the duration from start operation to the first split. 

192 * the duration from last resume to this split. 

193 

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() 

198 

199 if self._resumeTime is None: 

200 raise StopwatchException("Stopwatch was not started or resumed.") 

201 

202 diff = (pauseTime - self._resumeTime) / 1e9 

203 self._splits.append((diff, True)) 

204 self._resumeTime = pauseTime 

205 

206 return diff 

207 

208 def Pause(self) -> float: 

209 """ 

210 Pause the stopwatch and return the time delta to the previous stopwatch operation. 

211 

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: 

215 

216 * the duration from start operation to the first pause. 

217 * the duration from last resume to this pause. 

218 

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() 

223 

224 if self._resumeTime is None: 

225 raise StopwatchException("Stopwatch was not started or resumed.") 

226 

227 diff = (self._pauseTime - self._resumeTime) / 1e9 

228 self._splits.append((diff, True)) 

229 self._resumeTime = None 

230 

231 return diff 

232 

233 def Resume(self) -> float: 

234 """ 

235 Resume the stopwatch and return the time delta to the previous pause operation. 

236 

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. 

240 

241 :returns: Duration in seconds since last pause operation 

242 :raises StopwatchException: If stopwatch was not paused. 

243 """ 

244 self._resumeTime = perf_counter_ns() 

245 

246 if self._pauseTime is None: 

247 raise StopwatchException("Stopwatch was not paused.") 

248 

249 diff = (self._resumeTime - self._pauseTime) / 1e9 

250 self._splits.append((diff, False)) 

251 self._pauseTime = None 

252 

253 return diff 

254 

255 def Stop(self) -> float: 

256 """ 

257 Stop the stopwatch and return the time delta to the previous stopwatch operation. 

258 

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: 

262 

263 * the duration from start operation to the stop operation. 

264 * the duration from last resume to the stop operation. 

265 

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() 

272 

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.") 

277 

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)) 

286 

287 self._pauseTime = None 

288 self._resumeTime = None 

289 self._totalTime = self._stopTime - self._startTime 

290 

291 # FIXME: why is this unused? 

292 beginEndDiff = self._endTime - self._beginTime 

293 

294 return diff 

295 

296 @readonly 

297 def Name(self) -> Nullable[str]: 

298 """ 

299 Read-only property returning the name of the stopwatch. 

300 

301 :returns: Name of the stopwatch. 

302 """ 

303 return self._name 

304 

305 @readonly 

306 def IsStarted(self) -> bool: 

307 """ 

308 Read-only property returning the IsStarted state of the stopwatch. 

309 

310 :returns: True, if stopwatch was started. 

311 """ 

312 return self._startTime is not None and self._stopTime is None 

313 

314 @readonly 

315 def IsRunning(self) -> bool: 

316 """ 

317 Read-only property returning the IsRunning state of the stopwatch. 

318 

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 

322 

323 @readonly 

324 def IsPaused(self) -> bool: 

325 """ 

326 Read-only property returning the IsPaused state of the stopwatch. 

327 

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 

331 

332 @readonly 

333 def IsStopped(self) -> bool: 

334 """ 

335 Read-only property returning the IsStopped state of the stopwatch. 

336 

337 :returns: True, if stopwatch was stopped. 

338 """ 

339 return self._stopTime is not None 

340 

341 @readonly 

342 def StartTime(self) -> Nullable[datetime]: 

343 """ 

344 Read-only property returning the absolute time when the stopwatch was started. 

345 

346 :returns: The time when the stopwatch was started, otherwise None. 

347 """ 

348 return self._beginTime 

349 

350 @readonly 

351 def StopTime(self) -> Nullable[datetime]: 

352 """ 

353 Read-only property returning the absolute time when the stopwatch was stopped. 

354 

355 :returns: The time when the stopwatch was stopped, otherwise None. 

356 """ 

357 return self._endTime 

358 

359 @readonly 

360 def HasSplitTimes(self) -> bool: 

361 """ 

362 Read-only property checking if split times have been taken. 

363 

364 :returns: True, if split times have been taken. 

365 """ 

366 return len(self._splits) > 1 

367 

368 @readonly 

369 def SplitCount(self) -> int: 

370 """ 

371 Read-only property returning the number of split times. 

372 

373 :returns: Number of split times. 

374 """ 

375 return len(self._splits) 

376 

377 @readonly 

378 def ActiveCount(self) -> int: 

379 """ 

380 Read-only property returning the number of active split times. 

381 

382 :returns: Number of active split times. 

383 

384 .. warning:: 

385 

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 

390 

391 return len(list(t for t, a in self._splits if a is True)) 

392 

393 @readonly 

394 def InactiveCount(self) -> int: 

395 """ 

396 Read-only property returning the number of active split times. 

397 

398 :returns: Number of active split times. 

399 

400 .. warning:: 

401 

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 

406 

407 return len(list(t for t, a in self._splits if a is False)) 

408 

409 @readonly 

410 def Activity(self) -> float: 

411 """ 

412 Read-only property returning the duration of all active split times. 

413 

414 If the stopwatch is currently running, the duration since start or last resume operation will be included. 

415 

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 

421 

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 

424 

425 @readonly 

426 def Inactivity(self) -> float: 

427 """ 

428 Read-only property returning the duration of all inactive split times. 

429 

430 If the stopwatch is currently paused, the duration since last pause operation will be included. 

431 

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 

437 

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 

440 

441 @readonly 

442 def Duration(self) -> float: 

443 """ 

444 Read-only property returning the duration from start operation to stop operation. 

445 

446 If the stopwatch is not yet stopped, the duration from start to now is returned. 

447 

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 

453 

454 return ((perf_counter_ns() - self._startTime) if self._stopTime is None else self._totalTime) / 1e9 

455 

456 @readonly 

457 def Exclude(self) -> ExcludeContextManager: 

458 """ 

459 Return an *exclude* context manager for the stopwatch instance. 

460 

461 :returns: An excluding context manager. 

462 """ 

463 if self._excludeContextManager is None: 

464 excludeContextManager = ExcludeContextManager(self) 

465 self._excludeContextManager = excludeContextManager 

466 

467 return excludeContextManager 

468 

469 def __enter__(self) -> Self: 

470 """ 

471 Implementation of the :ref:`context manager protocol's <context-managers>` ``__enter__(...)`` method. 

472 

473 An unstarted stopwatch will be started. A paused stopwatch will be resumed. 

474 

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() 

483 

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.") 

493 

494 return self 

495 

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. 

504 

505 A running stopwatch will be paused or stopped depending on the configured ``preferPause`` behavior. 

506 

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() 

526 

527 diff = (self._stopTime - self._resumeTime) / 1e9 

528 self._splits.append((diff, True)) 

529 

530 self._pauseTime = None 

531 self._resumeTime = None 

532 self._totalTime = self._stopTime - self._startTime 

533 else: 

534 raise StopwatchException("Stopwatch was not resumed.") 

535 

536 def __len__(self) -> int: 

537 """ 

538 Implementation of ``len(...)`` to return the number of split times. 

539 

540 :returns: Number of split times. 

541 """ 

542 return len(self._splits) 

543 

544 def __getitem__(self, index: int) -> tuple[float, bool]: 

545 """ 

546 Implementation of ``split = object[i]`` to return the i-th split time. 

547 

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] 

555 

556 def __iter__(self) -> Iterator[tuple[float, bool]]: 

557 """ 

558 Return an iterator of tuples to iterate all split times. 

559 

560 If the stopwatch is not stopped yet, the last split won't be included. 

561 

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__() 

567 

568 def __str__(self) -> str: 

569 """ 

570 Returns the stopwatch's state and its measured time span. 

571 

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"