Coverage for pyTooling / Stopwatch / __init__.py: 85%

206 statements  

« prev     ^ index     » next       coverage.py v7.13.3, created at 2026-02-07 17:18 +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 

39from datetime import datetime 

40from time import perf_counter_ns 

41from types import TracebackType 

42from typing import List, Optional as Nullable, Iterator, Tuple, Type, Self 

43 

44from pyTooling.Decorators import export, readonly 

45from pyTooling.MetaClasses import SlottedObject 

46from pyTooling.Exceptions import ToolingException 

47 

48 

49@export 

50class StopwatchException(ToolingException): 

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

52 

53 

54@export 

55class ExcludeContextManager: 

56 """ 

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

58 

59 While a normal stopwatch's embedded context manager (re)starts the stopwatch on every *enter* event and pauses the 

60 stopwatch on every *exit* event, this context manager pauses on *enter* events and restarts on every *exit* event. 

61 """ 

62 _stopwatch: "Stopwatch" #: Reference to the stopwatch. 

63 

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

65 """ 

66 Initializes an excluding context manager. 

67 

68 :param stopwatch: Reference to the stopwatch. 

69 """ 

70 self._stopwatch = stopwatch 

71 

72 def __enter__(self) -> Self: 

73 """ 

74 Enter the context and pause the stopwatch. 

75 

76 :returns: Excluding stopwatch context manager instance. 

77 """ 

78 self._stopwatch.Pause() 

79 

80 return self 

81 

82 def __exit__( 

83 self, 

84 exc_type: Nullable[Type[BaseException]] = None, 

85 exc_val: Nullable[BaseException] = None, 

86 exc_tb: Nullable[TracebackType] = None 

87 ) -> Nullable[bool]: 

88 """ 

89 Exit the context and restart stopwatch. 

90 

91 :param exc_type: Exception type 

92 :param exc_val: Exception instance 

93 :param exc_tb: Exception's traceback. 

94 :returns: ``None`` 

95 """ 

96 self._stopwatch.Resume() 

97 

98 

99@export 

100class Stopwatch(SlottedObject): 

101 """ 

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

103 

104 The time measurement can be started, paused, resumed and stopped. More over, split times can be taken too. The 

105 measurement is based on :func:`time.perf_counter_ns`. Additionally, starting and stopping is preserved as absolute 

106 time via :meth:`datetime.datetime.now`. 

107 

108 Every split time taken is a time delta to the previous operation. These are preserved in an internal sequence of 

109 splits. This sequence includes time deltas of activity and inactivity. Thus, a running stopwatch can be split as well 

110 as a paused stopwatch. 

111 

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

113 """ 

114 

115 _name: Nullable[str] 

116 _preferPause: bool 

117 

118 _beginTime: Nullable[datetime] 

119 _endTime: Nullable[datetime] 

120 _startTime: Nullable[int] 

121 _resumeTime: Nullable[int] 

122 _pauseTime: Nullable[int] 

123 _stopTime: Nullable[int] 

124 _totalTime: Nullable[int] 

125 _splits: List[Tuple[float, bool]] 

126 

127 _excludeContextManager: ExcludeContextManager 

128 

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

130 """ 

131 Initializes the fields of the stopwatch. 

132 

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

134 

135 :param name: Optional name of the stopwatch. 

136 :param preferPause: Optional setting, if __exit__(...) in a contex should prefer pause or stop behavior. 

137 :param started: Optional flag, if the stopwatch should be started immediately. 

138 """ 

139 self._name = name 

140 self._preferPause = preferPause 

141 

142 self._endTime = None 

143 self._pauseTime = None 

144 self._stopTime = None 

145 self._totalTime = None 

146 self._splits = [] 

147 

148 self._excludeContextManager = None 

149 

150 if started is False: 150 ↛ 155line 150 didn't jump to line 155 because the condition on line 150 was always true

151 self._beginTime = None 

152 self._startTime = None 

153 self._resumeTime = None 

154 else: 

155 self._beginTime = datetime.now() 

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

157 

158 def Start(self) -> None: 

159 """ 

160 Start the stopwatch. 

161 

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

163 

164 :raises StopwatchException: If stopwatch was already started. 

165 :raises StopwatchException: If stopwatch was already started and stopped. 

166 """ 

167 if self._startTime is not None: 

168 raise StopwatchException("Stopwatch was already started.") 

169 if self._stopTime is not None: 169 ↛ 170line 169 didn't jump to line 170 because the condition on line 169 was never true

170 raise StopwatchException("Stopwatch was already used (started and stopped).") 

171 

172 self._beginTime = datetime.now() 

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

174 

175 def Split(self) -> float: 

176 """ 

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

178 

179 The stopwatch needs to be running to take a split time. See property :data:`IsRunning` to check if the stopwatch 

180 is running and the split operation is possible. |br| 

181 Depending on the previous operation, the time delta will be: 

182 

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

184 * the duration from last resume to this split. 

185 

186 :returns: Duration in seconds since last stopwatch operation 

187 :raises StopwatchException: If stopwatch was not started or resumed. 

188 """ 

189 pauseTime = perf_counter_ns() 

190 

191 if self._resumeTime is None: 

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

193 

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

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

196 self._resumeTime = pauseTime 

197 

198 return diff 

199 

200 def Pause(self) -> float: 

201 """ 

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

203 

204 The stopwatch needs to be running to pause it. See property :data:`IsRunning` to check if the stopwatch is running 

205 and the pause operation is possible. |br| 

206 Depending on the previous operation, the time delta will be: 

207 

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

209 * the duration from last resume to this pause. 

210 

211 :returns: Duration in seconds since last stopwatch operation 

212 :raises StopwatchException: If stopwatch was not started or resumed. 

213 """ 

214 self._pauseTime = perf_counter_ns() 

215 

216 if self._resumeTime is None: 

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

218 

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

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

221 self._resumeTime = None 

222 

223 return diff 

224 

225 def Resume(self) -> float: 

226 """ 

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

228 

229 The stopwatch needs to be paused to resume it. See property :data:`IsPaused` to check if the stopwatch is paused 

230 and the resume operation is possible. |br| 

231 The time delta will be the duration from last pause to this resume. 

232 

233 :returns: Duration in seconds since last pause operation 

234 :raises StopwatchException: If stopwatch was not paused. 

235 """ 

236 self._resumeTime = perf_counter_ns() 

237 

238 if self._pauseTime is None: 

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

240 

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

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

243 self._pauseTime = None 

244 

245 return diff 

246 

247 def Stop(self) -> float: 

248 """ 

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

250 

251 The stopwatch needs to be started to stop it. See property :data:`IsStarted` to check if the stopwatch was started 

252 and the stop operation is possible. |br| 

253 Depending on the previous operation, the time delta will be: 

254 

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

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

257 

258 :returns: Duration in seconds since last stopwatch operation 

259 :raises StopwatchException: If stopwatch was not started. 

260 :raises StopwatchException: If stopwatch was already stopped. 

261 """ 

262 self._stopTime = perf_counter_ns() 

263 self._endTime = datetime.now() 

264 

265 if self._startTime is None: 

266 raise StopwatchException("Stopwatch was never started.") 

267 if self._totalTime is not None: 267 ↛ 268line 267 didn't jump to line 268 because the condition on line 267 was never true

268 raise StopwatchException("Stopwatch was already stopped.") 

269 

270 if len(self._splits) == 0: # was never paused 

271 diff = (self._stopTime - self._startTime) / 1e9 

272 elif self._resumeTime is None: # is paused 

273 diff = (self._stopTime - self._pauseTime) / 1e9 

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

275 else: # is running 

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

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

278 

279 self._pauseTime = None 

280 self._resumeTime = None 

281 self._totalTime = self._stopTime - self._startTime 

282 

283 # FIXME: why is this unused? 

284 beginEndDiff = self._endTime - self._beginTime 

285 

286 return diff 

287 

288 @readonly 

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

290 """ 

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

292 

293 :return: Name of the stopwatch. 

294 """ 

295 return self._name 

296 

297 @readonly 

298 def IsStarted(self) -> bool: 

299 """ 

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

301 

302 :return: True, if stopwatch was started. 

303 """ 

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

305 

306 @readonly 

307 def IsRunning(self) -> bool: 

308 """ 

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

310 

311 :return: True, if stopwatch was started and is currently not paused. 

312 """ 

313 return self._startTime is not None and self._resumeTime is not None 

314 

315 @readonly 

316 def IsPaused(self) -> bool: 

317 """ 

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

319 

320 :return: True, if stopwatch was started and is currently paused. 

321 """ 

322 return self._startTime is not None and self._pauseTime is not None 

323 

324 @readonly 

325 def IsStopped(self) -> bool: 

326 """ 

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

328 

329 :return: True, if stopwatch was stopped. 

330 """ 

331 return self._stopTime is not None 

332 

333 @readonly 

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

335 """ 

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

337 

338 :return: The time when the stopwatch was started, otherwise None. 

339 """ 

340 return self._beginTime 

341 

342 @readonly 

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

344 """ 

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

346 

347 :return: The time when the stopwatch was stopped, otherwise None. 

348 """ 

349 return self._endTime 

350 

351 @readonly 

352 def HasSplitTimes(self) -> bool: 

353 """ 

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

355 

356 :return: True, if split times have been taken. 

357 """ 

358 return len(self._splits) > 1 

359 

360 @readonly 

361 def SplitCount(self) -> int: 

362 """ 

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

364 

365 :return: Number of split times. 

366 """ 

367 return len(self._splits) 

368 

369 @readonly 

370 def ActiveCount(self) -> int: 

371 """ 

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

373 

374 :return: Number of active split times. 

375 

376 .. warning:: 

377 

378 This won't include all activities, unless the stopwatch got stopped. 

379 """ 

380 if self._startTime is None: 380 ↛ 381line 380 didn't jump to line 381 because the condition on line 380 was never true

381 return 0 

382 

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

384 

385 @readonly 

386 def InactiveCount(self) -> int: 

387 """ 

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

389 

390 :return: Number of active split times. 

391 

392 .. warning:: 

393 

394 This won't include all inactivities, unless the stopwatch got stopped. 

395 """ 

396 if self._startTime is None: 396 ↛ 397line 396 didn't jump to line 397 because the condition on line 396 was never true

397 return 0 

398 

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

400 

401 @readonly 

402 def Activity(self) -> float: 

403 """ 

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

405 

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

407 

408 :return: Duration of all active split times in seconds. If the stopwatch was never started, the return value will 

409 be 0.0. 

410 """ 

411 if self._startTime is None: 411 ↛ 412line 411 didn't jump to line 412 because the condition on line 411 was never true

412 return 0.0 

413 

414 currentDiff = 0.0 if self._resumeTime is None else ((perf_counter_ns() - self._resumeTime) / 1e9) 

415 return sum(t for t, a in self._splits if a is True) + currentDiff 

416 

417 @readonly 

418 def Inactivity(self) -> float: 

419 """ 

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

421 

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

423 

424 :return: Duration of all inactive split times in seconds. If the stopwatch was never started, the return value will 

425 be 0.0. 

426 """ 

427 if self._startTime is None: 427 ↛ 428line 427 didn't jump to line 428 because the condition on line 427 was never true

428 return 0.0 

429 

430 currentDiff = 0.0 if self._pauseTime is None else ((perf_counter_ns() - self._pauseTime) / 1e9) 

431 return sum(t for t, a in self._splits if a is False) + currentDiff 

432 

433 @readonly 

434 def Duration(self) -> float: 

435 """ 

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

437 

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

439 

440 :return: Duration since stopwatch was started in seconds. If the stopwatch was never started, the return value will 

441 be 0.0. 

442 """ 

443 if self._startTime is None: 443 ↛ 444line 443 didn't jump to line 444 because the condition on line 443 was never true

444 return 0.0 

445 

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

447 

448 @readonly 

449 def Exclude(self) -> ExcludeContextManager: 

450 """ 

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

452 

453 :returns: An excluding context manager. 

454 """ 

455 if self._excludeContextManager is None: 

456 excludeContextManager = ExcludeContextManager(self) 

457 self._excludeContextManager = excludeContextManager 

458 

459 return excludeContextManager 

460 

461 def __enter__(self) -> Self: 

462 """ 

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

464 

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

466 

467 :return: The stopwatch itself. 

468 """ 

469 if self._startTime is None: # start stopwatch 

470 self._beginTime = datetime.now() 

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

472 elif self._pauseTime is not None: # resume after pause 

473 self._resumeTime = perf_counter_ns() 

474 

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

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

477 self._pauseTime = None 

478 elif self._resumeTime is not None: # is running? 478 ↛ 479line 478 didn't jump to line 479 because the condition on line 478 was never true

479 raise StopwatchException("Stopwatch is currently running and can not be started/resumed again.") 

480 elif self._stopTime is not None: # is stopped? 480 ↛ 483line 480 didn't jump to line 483 because the condition on line 480 was always true

481 raise StopwatchException(f"Stopwatch was already stopped.") 

482 else: 

483 raise StopwatchException(f"Internal error.") 

484 

485 return self 

486 

487 def __exit__( 

488 self, 

489 exc_type: Nullable[Type[BaseException]] = None, 

490 exc_val: Nullable[BaseException] = None, 

491 exc_tb: Nullable[TracebackType] = None 

492 ) -> Nullable[bool]: 

493 """ 

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

495 

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

497 

498 :param exc_type: Exception type, otherwise None. 

499 :param exc_val: Exception object, otherwise None. 

500 :param exc_tb: Exception's traceback, otherwise None. 

501 :returns: True, if exceptions should be suppressed. 

502 """ 

503 if self._startTime is None: # never started? 503 ↛ 504line 503 didn't jump to line 504 because the condition on line 503 was never true

504 raise StopwatchException("Stopwatch was never started.") 

505 elif self._stopTime is not None: 505 ↛ 506line 505 didn't jump to line 506 because the condition on line 505 was never true

506 raise StopwatchException("Stopwatch was already stopped.") 

507 elif self._resumeTime is not None: # pause or stop 507 ↛ 524line 507 didn't jump to line 524 because the condition on line 507 was always true

508 if self._preferPause: 

509 self._pauseTime = perf_counter_ns() 

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

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

512 self._resumeTime = None 

513 else: 

514 self._stopTime = perf_counter_ns() 

515 self._endTime = datetime.now() 

516 

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

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

519 

520 self._pauseTime = None 

521 self._resumeTime = None 

522 self._totalTime = self._stopTime - self._startTime 

523 else: 

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

525 

526 def __len__(self) -> int: 

527 """ 

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

529 

530 :return: Number of split times. 

531 """ 

532 return len(self._splits) 

533 

534 def __getitem__(self, index: int) -> Tuple[float, bool]: 

535 """ 

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

537 

538 :param index: Index to access the i-th split time. 

539 :return: i-th split time as a tuple of: |br| 

540 (1) delta time to the previous stopwatch operation and |br| 

541 (2) a boolean indicating if the split was an activity (true) or inactivity (false). 

542 :raises KeyError: If index *i* doesn't exist. 

543 """ 

544 return self._splits[index] 

545 

546 def __iter__(self) -> Iterator[Tuple[float, bool]]: 

547 """ 

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

549 

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

551 

552 :return: Iterator of split time tuples of: |br| 

553 (1) delta time to the previous stopwatch operation and |br| 

554 (2) a boolean indicating if the split was an activity (true) or inactivity (false). 

555 """ 

556 return self._splits.__iter__() 

557 

558 def __str__(self) -> str: 

559 """ 

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

561 

562 :returns: The string equivalent of the stopwatch. 

563 """ 

564 name = f" {self._name}" if self._name is not None else "" 

565 if self.IsStopped: 

566 return f"Stopwatch{name} (stopped): {self._beginTime} -> {self._endTime}: {self._totalTime}" 

567 elif self.IsRunning: 

568 return f"Stopwatch{name} (running): {self._beginTime} -> now: {self.Duration}" 

569 elif self.IsPaused: 

570 return f"Stopwatch{name} (paused): {self._beginTime} -> now: {self.Duration}" 

571 else: 

572 return f"Stopwatch{name}: not started"