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
« 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
39from pyTooling.Decorators import export, readonly
40from pyTooling.MetaClasses import ExtendedType
41from pyTooling.Exceptions import ToolingException
42from pyTooling.Common import getFullyQualifiedName
45__all__ = ["_threadLocalData"]
47_threadLocalData = local()
48"""A reference to the thread local data needed by the pyTooling.Tracing classes."""
51@export
52class TracingException(ToolingException):
53 """Base-exception of all exceptions raised by :mod:`pyTooling.Tracing`."""
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.
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.
68 def __init__(self, name: str, time: Nullable[datetime] = None, parent: Nullable["Span"] = None) -> None:
69 """
70 Initializes a named event.
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.")
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
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
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
105 self._dict = {}
107 @readonly
108 def Name(self) -> str:
109 """
110 Read-only property to access the event's name.
112 :returns: Name of the event.
113 """
114 return self._name
116 @readonly
117 def Time(self) -> datetime:
118 """
119 Read-only property to access the event's timestamp.
121 :returns: Timestamp of the event.
122 """
123 return self._time
125 @readonly
126 def Parent(self) -> Nullable["Span"]:
127 """
128 Read-only property to access the event's parent span.
130 :returns: Parent span.
131 """
132 return self._parent
134 def __getitem__(self, key: str) -> Any:
135 """
136 Read an event's attached attributes (key-value-pairs) by key.
138 :param key: The key to look for.
139 :returns: The value associated to the given key.
140 """
141 return self._dict[key]
143 def __setitem__(self, key: str, value: Any) -> None:
144 """
145 Create or update an event's attached attributes (key-value-pairs) by key.
147 If a key doesn't exist yet, a new key-value-pair is created.
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
154 def __delitem__(self, key: str) -> None:
155 """
156 Remove an entry from event's attached attributes (key-value-pairs) by key.
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]
163 def __contains__(self, key: str) -> bool:
164 """
165 Checks if the key is an attached attribute (key-value-pairs) on this event.
167 :param key: The key to check.
168 :returns: ``True``, if the key is an attached attribute.
169 """
170 return key in self._dict
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.
176 :returns: Iterator to iterate all attributes.
177 """
178 return iter(self._dict.items())
180 def __len__(self) -> int:
181 """
182 Returns the number of attached attributes (key-value-pairs) on this event.
184 :returns: Number of attached attributes.
185 """
186 return len(self._dict)
188 def __str__(self) -> str:
189 """
190 Return a string representation of the event.
192 :returns: The event's name.
193 """
194 return self._name
197@export
198class Span(metaclass=ExtendedType, slots=True):
199 """
200 Represents a timespan (span) within another timespan or trace.
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).
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.
213 _spans: List["Span"] #: Sub-timespans
214 _events: List[Event] #: Events happened within this timespan
215 _dict: Dict[str, Any] #: Dictionary of associated attributes.
217 def __init__(self, name: str, parent: Nullable["Span"] = None) -> None:
218 """
219 Initializes a timespan as part of a software execution trace.
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.")
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
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
244 self._beginTime = None
245 self._startTime = None
246 self._endTime = None
247 self._stopTime = None
248 self._totalTime = None
250 self._spans = []
251 self._events = []
252 self._dict = {}
254 @readonly
255 def Name(self) -> str:
256 """
257 Read-only property to access the timespan's name.
259 :returns: Name of the timespan.
260 """
261 return self._name
263 @readonly
264 def Parent(self) -> Nullable["Span"]:
265 """
266 Read-only property to access the span's parent span or trace.
268 :returns: Parent span.
269 """
270 return self._parent
272 def _AddSpan(self, span: "Span") -> Self:
273 self._spans.append(span)
274 span._parent = self
276 return span
278 @readonly
279 def HasSubSpans(self) -> bool:
280 """
281 Check if this timespan contains nested sub-spans.
283 :returns: ``True``, if the span has nested spans.
284 """
285 return len(self._spans) > 0
287 @readonly
288 def SubSpanCount(self) -> int:
289 """
290 Return the number of sub-spans within this span.
292 :return: Number of nested spans.
293 """
294 return len(self._spans)
296 # iterate subspans with optional predicate
297 def IterateSubSpans(self) -> Iterator["Span"]:
298 """
299 Returns an iterator to iterate all nested sub-spans.
301 :returns: Iterator to iterate all sub-spans.
302 """
303 return iter(self._spans)
305 @readonly
306 def HasEvents(self) -> bool:
307 """
308 Check if this timespan contains events.
310 :returns: ``True``, if the span has events.
311 """
312 return len(self._events) > 0
314 @readonly
315 def EventCount(self) -> int:
316 """
317 Return the number of events within this span.
319 :return: Number of events.
320 """
321 return len(self._events)
323 # iterate events with optional predicate
324 def IterateEvents(self) -> Iterator[Event]:
325 """
326 Returns an iterator to iterate all embedded events.
328 :returns: Iterator to iterate all events.
329 """
330 return iter(self._events)
332 @readonly
333 def StartTime(self) -> Nullable[datetime]:
334 """
335 Read-only property accessing the absolute time when the span was started.
337 :return: The time when the span was entered, otherwise None.
338 """
339 return self._beginTime
341 @readonly
342 def StopTime(self) -> Nullable[datetime]:
343 """
344 Read-only property accessing the absolute time when the span was stopped.
346 :return: The time when the span was exited, otherwise None.
347 """
348 return self._endTime
350 @readonly
351 def Duration(self) -> float:
352 """
353 Read-only property accessing the duration from start operation to stop operation.
355 If the span is not yet stopped, the duration from start to now is returned.
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.")
363 return ((perf_counter_ns() - self._startTime) if self._stopTime is None else self._totalTime) / 1e9
365 @classmethod
366 def CurrentSpan(cls) -> "Span":
367 """
368 Class-method to return the currently active timespan (span) or ``None``.
370 :returns: Currently active span or ``None``.
371 """
372 global _threadLocalData
374 try:
375 currentSpan = _threadLocalData.currentSpan
376 except AttributeError:
377 currentSpan = None
379 return currentSpan
381 def __enter__(self) -> Self:
382 """
383 Implementation of the :ref:`context manager protocol's <context-managers>` ``__enter__(...)`` method.
385 A span will be started.
387 :return: The span itself.
388 """
389 global _threadLocalData
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
398 _threadLocalData.currentSpan = currentSpan._AddSpan(self)
400 self._beginTime = datetime.now()
401 self._startTime = perf_counter_ns()
403 return self
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.
414 An active span will be stopped.
416 Exit the context and ......
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
425 self._stopTime = perf_counter_ns()
426 self._endTime = datetime.now()
427 self._totalTime = self._stopTime - self._startTime
429 currentSpan = _threadLocalData.currentSpan
430 _threadLocalData.currentSpan = currentSpan._parent
432 def __getitem__(self, key: str) -> Any:
433 """
434 Read an event's attached attributes (key-value-pairs) by key.
436 :param key: The key to look for.
437 :returns: The value associated to the given key.
438 """
439 return self._dict[key]
441 def __setitem__(self, key: str, value: Any) -> None:
442 """
443 Create or update an event's attached attributes (key-value-pairs) by key.
445 If a key doesn't exist yet, a new key-value-pair is created.
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
452 def __delitem__(self, key: str) -> None:
453 """
454 Remove an entry from event's attached attributes (key-value-pairs) by key.
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]
461 def __contains__(self, key: str) -> bool:
462 """
463 Checks if the key is an attached attribute (key-value-pairs) on this event.
465 :param key: The key to check.
466 :returns: ``True``, if the key is an attached attribute.
467 """
468 return key in self._dict
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.
474 :returns: Iterator to iterate all attributes.
475 """
476 return iter(self._dict.items())
478 def __len__(self) -> int:
479 """
480 Returns the number of attached attributes (key-value-pairs) on this event.
482 :returns: Number of attached attributes.
483 """
484 return len(self._dict)
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))
492 return result
494 def __repr__(self) -> str:
495 return f"{self._name} -> {self._parent!r}"
497 def __str__(self) -> str:
498 """
499 Return a string representation of the timespan.
501 :returns: The span's name.
502 """
503 return self._name
506@export
507class Trace(Span):
508 """
509 Represents a software execution trace made up of timespans (:class:`Span`).
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*.
517 A trace may contain sub-spans, events and arbitrary attributes (key-value pairs).
518 """
520 def __init__(self, name: str) -> None:
521 """
522 Initializes a software execution trace.
524 :param name: Name of the trace.
525 """
526 super().__init__(name)
528 def __enter__(self) -> Self:
529 global _threadLocalData
531 # TODO: check if a trace is already setup
532 # try:
533 # currentTrace = _threadLocalData.currentTrace
534 # except AttributeError:
535 # pass
537 _threadLocalData.currentTrace = self
538 _threadLocalData.currentSpan = self
540 self._beginTime = datetime.now()
541 self._startTime = perf_counter_ns()
543 return self
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 ......
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
561 self._stopTime = perf_counter_ns()
562 self._endTime = datetime.now()
563 self._totalTime = self._stopTime - self._startTime
565 del _threadLocalData.currentTrace
566 del _threadLocalData.currentSpan
568 return None
570 @classmethod
571 def CurrentTrace(cls) -> "Trace":
572 """
573 Class-method to return the currently active trace or ``None``.
575 :returns: Currently active trace or ``None``.
576 """
577 try:
578 currentTrace = _threadLocalData.currentTrace
579 except AttributeError:
580 currentTrace = None
582 return currentTrace
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))
591 return result