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

208 statements  

« prev     ^ index     » next       coverage.py v7.8.0, created at 2025-04-25 22:22 +0000

1# ==================================================================================================================== # 

2# _____ _ _ ____ _ _ _ # 

3# _ __ _ |_ _|__ ___ | (_)_ __ __ _ / ___|| |_ ___ _ ____ ____ _| |_ ___| |__ # 

4# | '_ \| | | || |/ _ \ / _ \| | | '_ \ / _` | \___ \| __/ _ \| '_ \ \ /\ / / _` | __/ __| '_ \ # 

5# | |_) | |_| || | (_) | (_) | | | | | | (_| |_ ___) | || (_) | |_) \ V V / (_| | || (__| | | | # 

6# | .__/ \__, ||_|\___/ \___/|_|_|_| |_|\__, (_)____/ \__\___/| .__/ \_/\_/ \__,_|\__\___|_| |_| # 

7# |_| |___/ |___/ |_| # 

8# ==================================================================================================================== # 

9# Authors: # 

10# Patrick Lehmann # 

11# # 

12# License: # 

13# ==================================================================================================================== # 

14# Copyright 2017-2025 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:: See :ref:`high-level help <COMMON/Stopwatch>` for explanations and usage examples. 

35""" 

36from datetime import datetime 

37from inspect import Traceback 

38from time import perf_counter_ns 

39from typing import List, Optional as Nullable, Iterator, Tuple, Type, ContextManager 

40 

41# Python 3.11: use Self if returning the own object: , Self 

42 

43try: 

44 from pyTooling.Decorators import export, readonly 

45 from pyTooling.MetaClasses import SlottedObject 

46 from pyTooling.Exceptions import ToolingException 

47 from pyTooling.Platform import CurrentPlatform 

48except (ImportError, ModuleNotFoundError): # pragma: no cover 

49 print("[pyTooling.Stopwatch] Could not import from 'pyTooling.*'!") 

50 

51 try: 

52 from Decorators import export, readonly 

53 from MetaClasses import SlottedObject 

54 from Exceptions import ToolingException 

55 from Platform import CurrentPlatform 

56 except (ImportError, ModuleNotFoundError) as ex: # pragma: no cover 

57 print("[pyTooling.Stopwatch] Could not import directly!") 

58 raise ex 

59 

60 

61@export 

62class StopwatchException(ToolingException): 

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

64 

65 

66@export 

67class ExcludeContextManager: 

68 _stopwatch: "Stopwatch" 

69 

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

71 self._stopwatch = stopwatch 

72 

73 def __enter__(self) -> "ExcludeContextManager": # TODO: Python 3.11: -> Self: 

74 self._stopwatch.Pause() 

75 

76 return self 

77 

78 def __exit__(self, exc_type: Type[Exception], exc_val: Exception, exc_tb: Traceback) -> bool: 

79 self._stopwatch.Resume() 

80 

81 

82@export 

83class Stopwatch(SlottedObject): 

84 """ 

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

86 

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

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

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

90 

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

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

93 as a paused stopwatch. 

94 

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

96 """ 

97 

98 _name: Nullable[str] 

99 _preferPause: bool 

100 

101 _beginTime: Nullable[datetime] 

102 _endTime: Nullable[datetime] 

103 _startTime: Nullable[int] 

104 _resumeTime: Nullable[int] 

105 _pauseTime: Nullable[int] 

106 _stopTime: Nullable[int] 

107 _totalTime: Nullable[int] 

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

109 

110 _excludeContextManager: ExcludeContextManager 

111 

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

113 """ 

114 Initializes the fields of the stopwatch. 

115 

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

117 

118 :param name: Optional name of the stopwatch. 

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

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

121 """ 

122 self._name = name 

123 self._preferPause = preferPause 

124 

125 self._endTime = None 

126 self._pauseTime = None 

127 self._stopTime = None 

128 self._totalTime = None 

129 self._splits = [] 

130 

131 self._excludeContextManager = None 

132 

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

134 self._beginTime = None 

135 self._startTime = None 

136 self._resumeTime = None 

137 else: 

138 self._beginTime = datetime.now() 

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

140 

141 def Start(self) -> None: 

142 """ 

143 Start the stopwatch. 

144 

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

146 

147 :raises StopwatchException: If stopwatch was already started. 

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

149 """ 

150 if self._startTime is not None: 

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

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

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

154 

155 self._beginTime = datetime.now() 

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

157 

158 def Split(self) -> float: 

159 """ 

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

161 

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

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

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

165 

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

167 * the duration from last resume to this split. 

168 

169 :returns: Duration in seconds since last stopwatch operation 

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

171 """ 

172 pauseTime = perf_counter_ns() 

173 

174 if self._resumeTime is None: 

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

176 

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

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

179 self._resumeTime = pauseTime 

180 

181 return diff 

182 

183 def Pause(self) -> float: 

184 """ 

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

186 

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

188 and the pause 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 pause. 

192 * the duration from last resume to this pause. 

193 

194 :returns: Duration in seconds since last stopwatch operation 

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

196 """ 

197 self._pauseTime = perf_counter_ns() 

198 

199 if self._resumeTime is None: 

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

201 

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

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

204 self._resumeTime = None 

205 

206 return diff 

207 

208 def Resume(self) -> float: 

209 """ 

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

211 

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

213 and the resume operation is possible. |br| 

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

215 

216 :returns: Duration in seconds since last pause operation 

217 :raises StopwatchException: If stopwatch was not paused. 

218 """ 

219 self._resumeTime = perf_counter_ns() 

220 

221 if self._pauseTime is None: 

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

223 

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

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

226 self._pauseTime = None 

227 

228 return diff 

229 

230 def Stop(self): 

231 """ 

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

233 

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

235 and the stop operation is possible. |br| 

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

237 

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

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

240 

241 :returns: Duration in seconds since last stopwatch operation 

242 :raises StopwatchException: If stopwatch was not started. 

243 :raises StopwatchException: If stopwatch was already stopped. 

244 """ 

245 self._stopTime = perf_counter_ns() 

246 self._endTime = datetime.now() 

247 

248 if self._startTime is None: 

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

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

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

252 

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

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

255 elif self._resumeTime is None: # is paused 

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

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

258 else: # is running 

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

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

261 

262 self._pauseTime = None 

263 self._resumeTime = None 

264 self._totalTime = self._stopTime - self._startTime 

265 

266 beginEndDiff = self._endTime - self._beginTime 

267 

268 return diff 

269 

270 @readonly 

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

272 """ 

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

274 

275 :return: Name of the stopwatch. 

276 """ 

277 return self._name 

278 

279 @readonly 

280 def IsStarted(self) -> bool: 

281 """ 

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

283 

284 :return: True, if stopwatch was started. 

285 """ 

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

287 

288 @readonly 

289 def IsRunning(self) -> bool: 

290 """ 

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

292 

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

294 """ 

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

296 

297 @readonly 

298 def IsPaused(self) -> bool: 

299 """ 

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

301 

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

303 """ 

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

305 

306 @readonly 

307 def IsStopped(self) -> bool: 

308 """ 

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

310 

311 :return: True, if stopwatch was stopped. 

312 """ 

313 return self._stopTime is not None 

314 

315 @readonly 

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

317 """ 

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

319 

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

321 """ 

322 return self._beginTime 

323 

324 @readonly 

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

326 """ 

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

328 

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

330 """ 

331 return self._endTime 

332 

333 @readonly 

334 def HasSplitTimes(self) -> bool: 

335 """ 

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

337 

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

339 """ 

340 return len(self._splits) > 1 

341 

342 @readonly 

343 def SplitCount(self) -> int: 

344 """ 

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

346 

347 :return: Number of split times. 

348 """ 

349 return len(self._splits) 

350 

351 @readonly 

352 def ActiveCount(self) -> int: 

353 """ 

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

355 

356 :return: Number of active split times. 

357 

358 .. warning:: 

359 

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

361 """ 

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

363 return 0 

364 

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

366 

367 @readonly 

368 def InactiveCount(self) -> int: 

369 """ 

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

371 

372 :return: Number of active split times. 

373 

374 .. warning:: 

375 

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

377 """ 

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

379 return 0 

380 

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

382 

383 @readonly 

384 def Activity(self) -> float: 

385 """ 

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

387 

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

389 

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

391 be 0.0. 

392 """ 

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

394 return 0.0 

395 

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

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

398 

399 @readonly 

400 def Inactivity(self) -> float: 

401 """ 

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

403 

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

405 

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

407 be 0.0. 

408 """ 

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

410 return 0.0 

411 

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

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

414 

415 @readonly 

416 def Duration(self) -> float: 

417 """ 

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

419 

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

421 

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

423 be 0.0. 

424 """ 

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

426 return 0.0 

427 

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

429 

430 @readonly 

431 def Exclude(self) -> ExcludeContextManager: 

432 if self._excludeContextManager is None: 

433 excludeContextManager = ExcludeContextManager(self) 

434 self._excludeContextManager = excludeContextManager 

435 

436 return excludeContextManager 

437 

438 def __enter__(self) -> "Stopwatch": # TODO: Python 3.11: -> Self: 

439 """ 

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

441 

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

443 

444 :return: The stopwatch itself. 

445 """ 

446 if self._startTime is None: # start stopwatch 

447 self._beginTime = datetime.now() 

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

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

450 self._resumeTime = perf_counter_ns() 

451 

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

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

454 self._pauseTime = None 

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

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

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

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

459 else: 

460 raise StopwatchException(f"Internal error.") 

461 

462 return self 

463 

464 def __exit__(self, exc_type: Type[Exception], exc_val: Exception, exc_tb: Traceback) -> bool: 

465 """ 

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

467 

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

469 

470 :param exc_type: Exception type, otherwise None. 

471 :param exc_val: Exception object, otherwise None. 

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

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

474 """ 

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

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

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

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

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

480 if self._preferPause: 

481 self._pauseTime = perf_counter_ns() 

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

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

484 self._resumeTime = None 

485 else: 

486 self._stopTime = perf_counter_ns() 

487 self._endTime = datetime.now() 

488 

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

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

491 

492 self._pauseTime = None 

493 self._resumeTime = None 

494 self._totalTime = self._stopTime - self._startTime 

495 else: 

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

497 

498 

499 def __len__(self): 

500 """ 

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

502 

503 :return: Number of split times. 

504 """ 

505 return len(self._splits) 

506 

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

508 """ 

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

510 

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

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

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

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

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

516 """ 

517 return self._splits[index] 

518 

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

520 """ 

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

522 

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

524 

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

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

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

528 """ 

529 return self._splits.__iter__() 

530 

531 def __str__(self): 

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

533 if self.IsStopped: 

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

535 elif self.IsRunning: 

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

537 elif self.IsPaused: 

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

539 else: 

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