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

218 statements  

« prev     ^ index     » next       coverage.py v7.13.1, created at 2026-01-18 14:57 +0000

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

2# _____ _ _ _____ _ # 

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

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

5# | |_) | |_| || | (_) | (_) | | | | | | (_| |_| || | | (_| | (__| | | | | (_| | # 

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

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

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

9# Authors: # 

10# Patrick Lehmann # 

11# # 

12# License: # 

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

14# Copyright 2025-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"""Tools for software execution tracing.""" 

32from datetime import datetime 

33from time import perf_counter_ns 

34from threading import local 

35from types import TracebackType 

36from typing import Optional as Nullable, List, Iterator, Type, Self, Iterable, Dict, Any, Tuple 

37 

38 

39try: 

40 from pyTooling.Decorators import export, readonly 

41 from pyTooling.MetaClasses import ExtendedType 

42 from pyTooling.Exceptions import ToolingException 

43 from pyTooling.Common import getFullyQualifiedName 

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

45 print("[pyTooling.Tracing] Could not import from 'pyTooling.*'!") 

46 

47 try: 

48 from Decorators import export, readonly 

49 from MetaClasses import ExtendedType 

50 from Exceptions import ToolingException 

51 from Common import getFullyQualifiedName 

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

53 print("[pyTooling.Tracing] Could not import directly!") 

54 raise ex 

55 

56 

57__all__ = ["_threadLocalData"] 

58 

59_threadLocalData = local() 

60"""A reference to the thread local data needed by the pyTooling.Tracing classes.""" 

61 

62 

63@export 

64class TracingException(ToolingException): 

65 """Base-exception of all exceptions raised by :mod:`pyTooling.Tracing`.""" 

66 

67 

68@export 

69class Event(metaclass=ExtendedType, slots=True): 

70 """ 

71 Represents a named event within a timespan (:class:`Span`) used in a software execution trace. 

72 

73 It may contain arbitrary attributes (key-value pairs). 

74 """ 

75 _name: str #: Name of the event. 

76 _parent: Nullable["Span"] #: Reference to the parent span. 

77 _time: Nullable[datetime] #: Timestamp of the event. 

78 _dict: Dict[str, Any] #: Dictionary of associated attributes. 

79 

80 def __init__(self, name: str, time: Nullable[datetime] = None, parent: Nullable["Span"] = None) -> None: 

81 """ 

82 Initializes a named event. 

83 

84 :param name: The name of the event. 

85 :param time: The optional time when the event happened. 

86 :param parent: Reference to the parent span. 

87 """ 

88 if isinstance(name, str): 88 ↛ 94line 88 didn't jump to line 94 because the condition on line 88 was always true

89 if name == "": 89 ↛ 90line 89 didn't jump to line 90 because the condition on line 89 was never true

90 raise ValueError(f"Parameter 'name' is empty.") 

91 

92 self._name = name 

93 else: 

94 ex = TypeError("Parameter 'name' is not of type 'str'.") 

95 ex.add_note(f"Got type '{getFullyQualifiedName(name)}'.") 

96 raise ex 

97 

98 if time is None: 98 ↛ 100line 98 didn't jump to line 100 because the condition on line 98 was always true

99 self._time = None 

100 elif isinstance(time, datetime): 

101 self._time = time 

102 else: 

103 ex = TypeError("Parameter 'time' is not of type 'datetime'.") 

104 ex.add_note(f"Got type '{getFullyQualifiedName(time)}'.") 

105 raise ex 

106 

107 if parent is None: 

108 self._parent = None 

109 elif isinstance(parent, Span): 109 ↛ 113line 109 didn't jump to line 113 because the condition on line 109 was always true

110 self._parent = parent 

111 parent._events.append(self) 

112 else: 

113 ex = TypeError("Parameter 'parent' is not of type 'Span'.") 

114 ex.add_note(f"Got type '{getFullyQualifiedName(parent)}'.") 

115 raise ex 

116 

117 self._dict = {} 

118 

119 @readonly 

120 def Name(self) -> str: 

121 """ 

122 Read-only property to access the event's name. 

123 

124 :returns: Name of the event. 

125 """ 

126 return self._name 

127 

128 @readonly 

129 def Time(self) -> datetime: 

130 """ 

131 Read-only property to access the event's timestamp. 

132 

133 :returns: Timestamp of the event. 

134 """ 

135 return self._time 

136 

137 @readonly 

138 def Parent(self) -> Nullable["Span"]: 

139 """ 

140 Read-only property to access the event's parent span. 

141 

142 :returns: Parent span. 

143 """ 

144 return self._parent 

145 

146 def __getitem__(self, key: str) -> Any: 

147 """ 

148 Read an event's attached attributes (key-value-pairs) by key. 

149 

150 :param key: The key to look for. 

151 :returns: The value associated to the given key. 

152 """ 

153 return self._dict[key] 

154 

155 def __setitem__(self, key: str, value: Any) -> None: 

156 """ 

157 Create or update an event's attached attributes (key-value-pairs) by key. 

158 

159 If a key doesn't exist yet, a new key-value-pair is created. 

160 

161 :param key: The key to create or update. 

162 :param value: The value to associate to the given key. 

163 """ 

164 self._dict[key] = value 

165 

166 def __delitem__(self, key: str) -> None: 

167 """ 

168 Remove an entry from event's attached attributes (key-value-pairs) by key. 

169 

170 :param key: The key to remove. 

171 :raises KeyError: If key doesn't exist in the event's attributes. 

172 """ 

173 del self._dict[key] 

174 

175 def __contains__(self, key: str) -> bool: 

176 """ 

177 Checks if the key is an attached attribute (key-value-pairs) on this event. 

178 

179 :param key: The key to check. 

180 :returns: ``True``, if the key is an attached attribute. 

181 """ 

182 return key in self._dict 

183 

184 def __iter__(self) -> Iterator[Tuple[str, Any]]: 

185 """ 

186 Returns an iterator to iterate all associated attributes of this event as :pycode:`(key, value)` tuples. 

187 

188 :returns: Iterator to iterate all attributes. 

189 """ 

190 return iter(self._dict.items()) 

191 

192 def __len__(self) -> int: 

193 """ 

194 Returns the number of attached attributes (key-value-pairs) on this event. 

195 

196 :returns: Number of attached attributes. 

197 """ 

198 return len(self._dict) 

199 

200 def __str__(self) -> str: 

201 """ 

202 Return a string representation of the event. 

203 

204 :returns: The event's name. 

205 """ 

206 return self._name 

207 

208 

209@export 

210class Span(metaclass=ExtendedType, slots=True): 

211 """ 

212 Represents a timespan (span) within another timespan or trace. 

213 

214 It may contain sub-spans, events and arbitrary attributes (key-value pairs). 

215 """ 

216 _name: str #: Name of the timespan 

217 _parent: Nullable["Span"] #: Reference to the parent span (or trace). 

218 

219 _beginTime: Nullable[datetime] #: Timestamp when the timespan begins. 

220 _endTime: Nullable[datetime] #: Timestamp when the timespan ends. 

221 _startTime: Nullable[int] 

222 _stopTime: Nullable[int] 

223 _totalTime: Nullable[int] #: Duration of this timespan in ns. 

224 

225 _spans: List["Span"] #: Sub-timespans 

226 _events: List[Event] #: Events happened within this timespan 

227 _dict: Dict[str, Any] #: Dictionary of associated attributes. 

228 

229 def __init__(self, name: str, parent: Nullable["Span"] = None) -> None: 

230 """ 

231 Initializes a timespan as part of a software execution trace. 

232 

233 :param name: Name of the timespan. 

234 :param parent: Reference to a parent span or trace. 

235 """ 

236 if isinstance(name, str): 236 ↛ 242line 236 didn't jump to line 242 because the condition on line 236 was always true

237 if name == "": 237 ↛ 238line 237 didn't jump to line 238 because the condition on line 237 was never true

238 raise ValueError(f"Parameter 'name' is empty.") 

239 

240 self._name = name 

241 else: 

242 ex = TypeError("Parameter 'name' is not of type 'str'.") 

243 ex.add_note(f"Got type '{getFullyQualifiedName(parent)}'.") 

244 raise ex 

245 

246 if parent is None: 

247 self._parent = None 

248 elif isinstance(parent, Span): 248 ↛ 252line 248 didn't jump to line 252 because the condition on line 248 was always true

249 self._parent = parent 

250 parent._spans.append(self) 

251 else: 

252 ex = TypeError("Parameter 'parent' is not of type 'Span'.") 

253 ex.add_note(f"Got type '{getFullyQualifiedName(parent)}'.") 

254 raise ex 

255 

256 self._beginTime = None 

257 self._startTime = None 

258 self._endTime = None 

259 self._stopTime = None 

260 self._totalTime = None 

261 

262 self._spans = [] 

263 self._events = [] 

264 self._dict = {} 

265 

266 @readonly 

267 def Name(self) -> str: 

268 """ 

269 Read-only property to access the timespan's name. 

270 

271 :returns: Name of the timespan. 

272 """ 

273 return self._name 

274 

275 @readonly 

276 def Parent(self) -> Nullable["Span"]: 

277 """ 

278 Read-only property to access the span's parent span or trace. 

279 

280 :returns: Parent span. 

281 """ 

282 return self._parent 

283 

284 def _AddSpan(self, span: "Span") -> Self: 

285 self._spans.append(span) 

286 span._parent = self 

287 

288 return span 

289 

290 @readonly 

291 def HasSubSpans(self) -> bool: 

292 """ 

293 Check if this timespan contains nested sub-spans. 

294 

295 :returns: ``True``, if the span has nested spans. 

296 """ 

297 return len(self._spans) > 0 

298 

299 @readonly 

300 def SubSpanCount(self) -> int: 

301 """ 

302 Return the number of sub-spans within this span. 

303 

304 :return: Number of nested spans. 

305 """ 

306 return len(self._spans) 

307 

308 # iterate subspans with optional predicate 

309 def IterateSubSpans(self) -> Iterator["Span"]: 

310 """ 

311 Returns an iterator to iterate all nested sub-spans. 

312 

313 :returns: Iterator to iterate all sub-spans. 

314 """ 

315 return iter(self._spans) 

316 

317 @readonly 

318 def HasEvents(self) -> bool: 

319 """ 

320 Check if this timespan contains events. 

321 

322 :returns: ``True``, if the span has events. 

323 """ 

324 return len(self._events) > 0 

325 

326 @readonly 

327 def EventCount(self) -> int: 

328 """ 

329 Return the number of events within this span. 

330 

331 :return: Number of events. 

332 """ 

333 return len(self._events) 

334 

335 # iterate events with optional predicate 

336 def IterateEvents(self) -> Iterator[Event]: 

337 """ 

338 Returns an iterator to iterate all embedded events. 

339 

340 :returns: Iterator to iterate all events. 

341 """ 

342 return iter(self._events) 

343 

344 @readonly 

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

346 """ 

347 Read-only property accessing the absolute time when the span was started. 

348 

349 :return: The time when the span was entered, otherwise None. 

350 """ 

351 return self._beginTime 

352 

353 @readonly 

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

355 """ 

356 Read-only property accessing the absolute time when the span was stopped. 

357 

358 :return: The time when the span was exited, otherwise None. 

359 """ 

360 return self._endTime 

361 

362 @readonly 

363 def Duration(self) -> float: 

364 """ 

365 Read-only property accessing the duration from start operation to stop operation. 

366 

367 If the span is not yet stopped, the duration from start to now is returned. 

368 

369 :return: Duration since span was started in seconds. 

370 :raises TracingException: When span was never started. 

371 """ 

372 if self._startTime is None: 

373 raise TracingException(f"{self.__class__.__name__} was never started.") 

374 

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

376 

377 @classmethod 

378 def CurrentSpan(cls) -> "Span": 

379 """ 

380 Class-method to return the currently active timespan (span) or ``None``. 

381 

382 :returns: Currently active span or ``None``. 

383 """ 

384 global _threadLocalData 

385 

386 try: 

387 currentSpan = _threadLocalData.currentSpan 

388 except AttributeError: 

389 currentSpan = None 

390 

391 return currentSpan 

392 

393 def __enter__(self) -> Self: 

394 """ 

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

396 

397 A span will be started. 

398 

399 :return: The span itself. 

400 """ 

401 global _threadLocalData 

402 

403 try: 

404 currentSpan = _threadLocalData.currentSpan 

405 except AttributeError: 

406 ex = TracingException("Can't setup span. No active trace.") 

407 ex.add_note("Use with-statement using 'Trace()' to setup software execution tracing.") 

408 raise ex 

409 

410 _threadLocalData.currentSpan = currentSpan._AddSpan(self) 

411 

412 self._beginTime = datetime.now() 

413 self._startTime = perf_counter_ns() 

414 

415 return self 

416 

417 def __exit__( 

418 self, 

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

420 exc_val: Nullable[BaseException] = None, 

421 exc_tb: Nullable[TracebackType] = None 

422 ) -> Nullable[bool]: 

423 """ 

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

425 

426 An active span will be stopped. 

427 

428 Exit the context and ...... 

429 

430 :param exc_type: Exception type 

431 :param exc_val: Exception instance 

432 :param exc_tb: Exception's traceback. 

433 :returns: ``None`` 

434 """ 

435 global _threadLocalData 

436 

437 self._stopTime = perf_counter_ns() 

438 self._endTime = datetime.now() 

439 self._totalTime = self._stopTime - self._startTime 

440 

441 currentSpan = _threadLocalData.currentSpan 

442 _threadLocalData.currentSpan = currentSpan._parent 

443 

444 def __getitem__(self, key: str) -> Any: 

445 """ 

446 Read an event's attached attributes (key-value-pairs) by key. 

447 

448 :param key: The key to look for. 

449 :returns: The value associated to the given key. 

450 """ 

451 return self._dict[key] 

452 

453 def __setitem__(self, key: str, value: Any) -> None: 

454 """ 

455 Create or update an event's attached attributes (key-value-pairs) by key. 

456 

457 If a key doesn't exist yet, a new key-value-pair is created. 

458 

459 :param key: The key to create or update. 

460 :param value: The value to associate to the given key. 

461 """ 

462 self._dict[key] = value 

463 

464 def __delitem__(self, key: str) -> None: 

465 """ 

466 Remove an entry from event's attached attributes (key-value-pairs) by key. 

467 

468 :param key: The key to remove. 

469 :raises KeyError: If key doesn't exist in the event's attributes. 

470 """ 

471 del self._dict[key] 

472 

473 def __contains__(self, key: str) -> bool: 

474 """ 

475 Checks if the key is an attached attribute (key-value-pairs) on this event. 

476 

477 :param key: The key to check. 

478 :returns: ``True``, if the key is an attached attribute. 

479 """ 

480 return key in self._dict 

481 

482 def __iter__(self) -> Iterator[Tuple[str, Any]]: 

483 """ 

484 Returns an iterator to iterate all associated attributes of this timespan as :pycode:`(key, value)` tuples. 

485 

486 :returns: Iterator to iterate all attributes. 

487 """ 

488 return iter(self._dict.items()) 

489 

490 def __len__(self) -> int: 

491 """ 

492 Returns the number of attached attributes (key-value-pairs) on this event. 

493 

494 :returns: Number of attached attributes. 

495 """ 

496 return len(self._dict) 

497 

498 def Format(self, indent: int = 1, columnSize: int = 25) -> Iterable[str]: 

499 result = [] 

500 result.append(f"{' ' * indent}🕑{self._name:<{columnSize - 2 * indent}} {self._totalTime/1e6:8.3f} ms") 

501 for span in self._spans: 

502 result.extend(span.Format(indent + 1, columnSize)) 

503 

504 return result 

505 

506 def __repr__(self) -> str: 

507 return f"{self._name} -> {self._parent!r}" 

508 

509 def __str__(self) -> str: 

510 """ 

511 Return a string representation of the timespan. 

512 

513 :returns: The span's name. 

514 """ 

515 return self._name 

516 

517 

518@export 

519class Trace(Span): 

520 """ 

521 Represents a software execution trace made up of timespans (:class:`Span`). 

522 

523 The trace is the top-most element in a tree of timespans. All timespans share the same *TraceID*, thus even in a 

524 distributed software execution, timespans can be aggregated with delay in a centralized database and the flow of 

525 execution can be reassembled by grouping all timespans with same *TraceID*. Execution order can be derived from 

526 timestamps and parallel execution is represented by overlapping timespans sharing the same parent *SpanID*. Thus, the 

527 tree structure can be reassembled by inspecting the parent *SpanID* relations within the same *TraceID*. 

528 

529 A trace may contain sub-spans, events and arbitrary attributes (key-value pairs). 

530 """ 

531 

532 def __init__(self, name: str) -> None: 

533 """ 

534 Initializes a software execution trace. 

535 

536 :param name: Name of the trace. 

537 """ 

538 super().__init__(name) 

539 

540 def __enter__(self) -> Self: 

541 global _threadLocalData 

542 

543 # TODO: check if a trace is already setup 

544 # try: 

545 # currentTrace = _threadLocalData.currentTrace 

546 # except AttributeError: 

547 # pass 

548 

549 _threadLocalData.currentTrace = self 

550 _threadLocalData.currentSpan = self 

551 

552 self._beginTime = datetime.now() 

553 self._startTime = perf_counter_ns() 

554 

555 return self 

556 

557 def __exit__( 

558 self, 

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

560 exc_val: Nullable[BaseException] = None, 

561 exc_tb: Nullable[TracebackType] = None 

562 ) -> Nullable[bool]: 

563 """ 

564 Exit the context and ...... 

565 

566 :param exc_type: Exception type 

567 :param exc_val: Exception instance 

568 :param exc_tb: Exception's traceback. 

569 :returns: ``None`` 

570 """ 

571 global _threadLocalData 

572 

573 self._stopTime = perf_counter_ns() 

574 self._endTime = datetime.now() 

575 self._totalTime = self._stopTime - self._startTime 

576 

577 del _threadLocalData.currentTrace 

578 del _threadLocalData.currentSpan 

579 

580 return None 

581 

582 @classmethod 

583 def CurrentTrace(cls) -> "Trace": 

584 """ 

585 Class-method to return the currently active trace or ``None``. 

586 

587 :returns: Currently active trace or ``None``. 

588 """ 

589 try: 

590 currentTrace = _threadLocalData.currentTrace 

591 except AttributeError: 

592 currentTrace = None 

593 

594 return currentTrace 

595 

596 def Format(self, indent: int = 0, columnSize: int = 25) -> Iterable[str]: 

597 result = [] 

598 result.append(f"{' ' * indent}Software Execution Trace: {self._totalTime/1e6:8.3f} ms") 

599 result.append(f"{' ' * indent}📉{self._name:<{columnSize - 2}} {self._totalTime/1e6:8.3f} ms") 

600 for span in self._spans: 

601 result.extend(span.Format(indent + 1, columnSize - 2)) 

602 

603 return result