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

217 statements  

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

39from pyTooling.Decorators import export, readonly 

40from pyTooling.MetaClasses import ExtendedType 

41from pyTooling.Exceptions import ToolingException 

42from pyTooling.Common import getFullyQualifiedName 

43 

44 

45__all__ = ["_threadLocalData"] 

46 

47_threadLocalData = local() 

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

49 

50 

51@export 

52class TracingException(ToolingException): 

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

54 

55 

56@export 

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

58 """ 

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

60 

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

62 """ 

63 _name: str #: Name of the event. 

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

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

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

67 

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

69 """ 

70 Initializes a named event. 

71 

72 :param name: The name of the event. 

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

74 :param parent: Reference to the parent span. 

75 """ 

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

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

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

79 

80 self._name = name 

81 else: 

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

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

84 raise ex 

85 

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

87 self._time = None 

88 elif isinstance(time, datetime): 

89 self._time = time 

90 else: 

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

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

93 raise ex 

94 

95 if parent is None: 

96 self._parent = None 

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

98 self._parent = parent 

99 parent._events.append(self) 

100 else: 

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

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

103 raise ex 

104 

105 self._dict = {} 

106 

107 @readonly 

108 def Name(self) -> str: 

109 """ 

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

111 

112 :returns: Name of the event. 

113 """ 

114 return self._name 

115 

116 @readonly 

117 def Time(self) -> datetime: 

118 """ 

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

120 

121 :returns: Timestamp of the event. 

122 """ 

123 return self._time 

124 

125 @readonly 

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

127 """ 

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

129 

130 :returns: Parent span. 

131 """ 

132 return self._parent 

133 

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

135 """ 

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

137 

138 :param key: The key to look for. 

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

140 """ 

141 return self._dict[key] 

142 

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

144 """ 

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

146 

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

148 

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

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

151 """ 

152 self._dict[key] = value 

153 

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

155 """ 

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

157 

158 :param key: The key to remove. 

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

160 """ 

161 del self._dict[key] 

162 

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

164 """ 

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

166 

167 :param key: The key to check. 

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

169 """ 

170 return key in self._dict 

171 

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

173 """ 

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

175 

176 :returns: Iterator to iterate all attributes. 

177 """ 

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

179 

180 def __len__(self) -> int: 

181 """ 

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

183 

184 :returns: Number of attached attributes. 

185 """ 

186 return len(self._dict) 

187 

188 def __str__(self) -> str: 

189 """ 

190 Return a string representation of the event. 

191 

192 :returns: The event's name. 

193 """ 

194 return self._name 

195 

196 

197@export 

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

199 """ 

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

201 

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

203 """ 

204 _name: str #: Name of the timespan 

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

206 

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

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

209 _startTime: Nullable[int] 

210 _stopTime: Nullable[int] 

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

212 

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

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

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

216 

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

218 """ 

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

220 

221 :param name: Name of the timespan. 

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

223 """ 

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

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

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

227 

228 self._name = name 

229 else: 

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

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

232 raise ex 

233 

234 if parent is None: 

235 self._parent = None 

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

237 self._parent = parent 

238 parent._spans.append(self) 

239 else: 

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

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

242 raise ex 

243 

244 self._beginTime = None 

245 self._startTime = None 

246 self._endTime = None 

247 self._stopTime = None 

248 self._totalTime = None 

249 

250 self._spans = [] 

251 self._events = [] 

252 self._dict = {} 

253 

254 @readonly 

255 def Name(self) -> str: 

256 """ 

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

258 

259 :returns: Name of the timespan. 

260 """ 

261 return self._name 

262 

263 @readonly 

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

265 """ 

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

267 

268 :returns: Parent span. 

269 """ 

270 return self._parent 

271 

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

273 self._spans.append(span) 

274 span._parent = self 

275 

276 return span 

277 

278 @readonly 

279 def HasSubSpans(self) -> bool: 

280 """ 

281 Check if this timespan contains nested sub-spans. 

282 

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

284 """ 

285 return len(self._spans) > 0 

286 

287 @readonly 

288 def SubSpanCount(self) -> int: 

289 """ 

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

291 

292 :return: Number of nested spans. 

293 """ 

294 return len(self._spans) 

295 

296 # iterate subspans with optional predicate 

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

298 """ 

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

300 

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

302 """ 

303 return iter(self._spans) 

304 

305 @readonly 

306 def HasEvents(self) -> bool: 

307 """ 

308 Check if this timespan contains events. 

309 

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

311 """ 

312 return len(self._events) > 0 

313 

314 @readonly 

315 def EventCount(self) -> int: 

316 """ 

317 Return the number of events within this span. 

318 

319 :return: Number of events. 

320 """ 

321 return len(self._events) 

322 

323 # iterate events with optional predicate 

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

325 """ 

326 Returns an iterator to iterate all embedded events. 

327 

328 :returns: Iterator to iterate all events. 

329 """ 

330 return iter(self._events) 

331 

332 @readonly 

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

334 """ 

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

336 

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

338 """ 

339 return self._beginTime 

340 

341 @readonly 

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

343 """ 

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

345 

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

347 """ 

348 return self._endTime 

349 

350 @readonly 

351 def Duration(self) -> float: 

352 """ 

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

354 

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

356 

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

358 :raises TracingException: When span was never started. 

359 """ 

360 if self._startTime is None: 

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

362 

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

364 

365 @classmethod 

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

367 """ 

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

369 

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

371 """ 

372 global _threadLocalData 

373 

374 try: 

375 currentSpan = _threadLocalData.currentSpan 

376 except AttributeError: 

377 currentSpan = None 

378 

379 return currentSpan 

380 

381 def __enter__(self) -> Self: 

382 """ 

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

384 

385 A span will be started. 

386 

387 :return: The span itself. 

388 """ 

389 global _threadLocalData 

390 

391 try: 

392 currentSpan = _threadLocalData.currentSpan 

393 except AttributeError: 

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

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

396 raise ex 

397 

398 _threadLocalData.currentSpan = currentSpan._AddSpan(self) 

399 

400 self._beginTime = datetime.now() 

401 self._startTime = perf_counter_ns() 

402 

403 return self 

404 

405 def __exit__( 

406 self, 

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

408 exc_val: Nullable[BaseException] = None, 

409 exc_tb: Nullable[TracebackType] = None 

410 ) -> Nullable[bool]: 

411 """ 

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

413 

414 An active span will be stopped. 

415 

416 Exit the context and ...... 

417 

418 :param exc_type: Exception type 

419 :param exc_val: Exception instance 

420 :param exc_tb: Exception's traceback. 

421 :returns: ``None`` 

422 """ 

423 global _threadLocalData 

424 

425 self._stopTime = perf_counter_ns() 

426 self._endTime = datetime.now() 

427 self._totalTime = self._stopTime - self._startTime 

428 

429 currentSpan = _threadLocalData.currentSpan 

430 _threadLocalData.currentSpan = currentSpan._parent 

431 

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

433 """ 

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

435 

436 :param key: The key to look for. 

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

438 """ 

439 return self._dict[key] 

440 

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

442 """ 

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

444 

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

446 

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

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

449 """ 

450 self._dict[key] = value 

451 

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

453 """ 

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

455 

456 :param key: The key to remove. 

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

458 """ 

459 del self._dict[key] 

460 

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

462 """ 

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

464 

465 :param key: The key to check. 

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

467 """ 

468 return key in self._dict 

469 

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

471 """ 

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

473 

474 :returns: Iterator to iterate all attributes. 

475 """ 

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

477 

478 def __len__(self) -> int: 

479 """ 

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

481 

482 :returns: Number of attached attributes. 

483 """ 

484 return len(self._dict) 

485 

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

487 result = [] 

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

489 for span in self._spans: 

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

491 

492 return result 

493 

494 def __repr__(self) -> str: 

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

496 

497 def __str__(self) -> str: 

498 """ 

499 Return a string representation of the timespan. 

500 

501 :returns: The span's name. 

502 """ 

503 return self._name 

504 

505 

506@export 

507class Trace(Span): 

508 """ 

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

510 

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

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

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

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

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

516 

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

518 """ 

519 

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

521 """ 

522 Initializes a software execution trace. 

523 

524 :param name: Name of the trace. 

525 """ 

526 super().__init__(name) 

527 

528 def __enter__(self) -> Self: 

529 global _threadLocalData 

530 

531 # TODO: check if a trace is already setup 

532 # try: 

533 # currentTrace = _threadLocalData.currentTrace 

534 # except AttributeError: 

535 # pass 

536 

537 _threadLocalData.currentTrace = self 

538 _threadLocalData.currentSpan = self 

539 

540 self._beginTime = datetime.now() 

541 self._startTime = perf_counter_ns() 

542 

543 return self 

544 

545 def __exit__( 

546 self, 

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

548 exc_val: Nullable[BaseException] = None, 

549 exc_tb: Nullable[TracebackType] = None 

550 ) -> Nullable[bool]: 

551 """ 

552 Exit the context and ...... 

553 

554 :param exc_type: Exception type 

555 :param exc_val: Exception instance 

556 :param exc_tb: Exception's traceback. 

557 :returns: ``None`` 

558 """ 

559 global _threadLocalData 

560 

561 self._stopTime = perf_counter_ns() 

562 self._endTime = datetime.now() 

563 self._totalTime = self._stopTime - self._startTime 

564 

565 del _threadLocalData.currentTrace 

566 del _threadLocalData.currentSpan 

567 

568 return None 

569 

570 @classmethod 

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

572 """ 

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

574 

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

576 """ 

577 try: 

578 currentTrace = _threadLocalData.currentTrace 

579 except AttributeError: 

580 currentTrace = None 

581 

582 return currentTrace 

583 

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

585 result = [] 

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

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

588 for span in self._spans: 

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

590 

591 return result