Source code for functrace.utilities

import collections
import decimal
import inspect
import math
import pathlib
import types
import typing
import warnings

from functrace.type_hints import *

__all__ = ('FunctionCall', 'ElapsedTime')


[docs] class FunctionCall: """ Represents detailed information about a function call for tracing and debugging purposes. This class provides a comprehensive view of a function invocation, including its name, arguments, and contextual information related to the call. It is primarily used internally to capture and analyze the details of function execution, which can be useful for monitoring, performance analysis, and debugging. Notes ----- - This class is intended for internal use within the tracing mechanism, and users should not instantiate it directly. Instead, instances of `FunctionCall` are created and managed by the tracing system to provide structured information about function calls and execution contexts. """ def __init__( self, func: CallableType, args: ArgsType, kwargs: KwargsType, apply_defaults: bool, undefined_value: typing.Any, include: str | tuple[str, ...] | None, exclude: str | tuple[str, ...] | None, frame: types.FrameType, stack_level: int, ) -> None: self._func = func self._args = args self._kwargs = kwargs self._apply_defaults = apply_defaults self._undefined_value = undefined_value self._include = include self._exclude = exclude self._frame = frame self._stack_level = stack_level def __repr__(self) -> str: func = repr(self._func.__name__) args = len(self._args) kwargs = len(self._kwargs) signature = f'func={func}, args={args}, kwargs={kwargs}' return f'{self.__class__.__name__}({signature})' @property def module(self) -> types.ModuleType: """ Retrieve the module in which the function is defined. This property provides access to the module object that contains the function, allowing you to understand the context in which the function resides. Returns ------- types.ModuleType The module object where the function is defined. This can be useful for determining the origin of the function or for module-level debugging. """ return inspect.getmodule(object=self._func) @property def func(self) -> CallableType | types.FunctionType | types.MethodType: """ Retrieve the function object being called. This property provides the actual function or method object that was invoked, allowing you to access the function's attributes or examine its behavior. Returns ------- collections.abc.Callable The function or method object that was called. This can be used to inspect or manipulate the function's attributes or execution characteristics. """ return self._func @property def args(self) -> ArgsType: """ Retrieve the positional arguments passed to the function. This property provides a tuple of the positional arguments that were supplied when the function was called. It reflects the order and values of the arguments. Returns ------- tuple A tuple containing the positional arguments passed to the function. This can be used for inspecting or processing the arguments of the function call. """ return self._args @property def kwargs(self) -> KwargsType: """ Retrieve the keyword arguments passed to the function. This property provides a dictionary of the keyword arguments that were provided during the function call. It includes both the names and values of the keyword arguments. Returns ------- dict A dictionary containing the keyword arguments passed to the function. This allows for detailed inspection and manipulation of the function's named parameters. """ return self._kwargs @property def signature(self) -> inspect.Signature: """ Retrieve the signature of the function. This property provides the `inspect.Signature` object of the function, which includes information about the function's parameters, return type, and other signature-related details. Returns ------- inspect.Signature The `inspect.Signature` object representing the function's signature. This can be used to analyze the function's parameter structure and default values. """ return inspect.signature(obj=self._func) @property def bound_arguments(self) -> inspect.BoundArguments: """ Retrieve the bound arguments for the function call, including any default values if applicable. This property provides an `inspect.BoundArguments` object that binds the positional and keyword arguments to the function's parameters, applying default values if `apply_defaults` is set to True. Returns ------- inspect.BoundArguments The `inspect.BoundArguments` object that shows how the arguments from the call are bound to the function's parameters. It includes default values if `apply_defaults` is True. """ bound_arguments = self.signature.bind(*self._args, **self._kwargs) if self._apply_defaults: bound_arguments.apply_defaults() return bound_arguments @property def frame(self) -> types.FrameType: """ Retrieve the frame object where the function is being called. This property provides the frame object that represents the context of the function call, including information about the call stack and execution environment. Returns ------- types.FrameType The frame object associated with the function call. This can be useful for debugging and understanding the execution context of the function. """ return self._frame @property def stack_level(self) -> int: """ Retrieve the stack level for the function call context. This property provides the depth of the call stack where the function was invoked, which can be particularly useful for logging purposes. It helps in understanding the position of the function call within the broader call stack. Returns ------- int The stack level relative to the function's invocation. This value is useful for logging and debugging to track where the function call occurs in the call stack. Notes ----- - This value can be used to log the stack level, aiding in tracing and diagnosing function call sequences and their context within the application. - Higher stack levels indicate deeper positions in the call stack, which can assist in understanding complex call chains. """ return self._stack_level + 2
[docs] def format( self, pattern: str = '{name}({params})', *, association: str = '=', separator: str = ', ', ) -> str: """ Generates a string representation of the function call based on the specified pattern. This method formats the details of the function call according to the provided pattern and customization options, allowing for flexible and informative string representations of function invocations. Parameters ---------- pattern : str, default '{name}({params})' The format string used to represent the function call. It supports the following placeholders: - `{path}` : The file path where the function is defined. - `{file}` : The file name where the function is defined, including the extension - `{module}` : The module name where the function is located. - `{name}` : The function name. - `{qualname}` : The fully qualified name of the function (including class name if applicable). - `{line}` : The line number where the function call occurs. - `{params}` : A string representing the function's parameters and their values. association : str, default '=' The string used to separate argument names from their values in the formatted representation. separator : str, default ', ' The string used to separate multiple arguments in the formatted representation. Returns ------- str A formatted string representing the function call, customized according to the specified pattern, association, and separator. Examples -------- Given the following setup: >>> # /home/user/python/project/module/file.py ... >>> from functrace import trace ... >>> class MyClass: ... @trace(callback=...) ... def my_func(self, a, b): ... ... ... >>> MyClass().my_func(123, 'abc') Formatting the function name and its parameters: >>> self.format('{name}({params})') 'my_func(a=123, b='abc')' Displaying the file path and line number: >>> self.format('{path}, line: {line}') '/home/user/python/project/module/file.py, line: 10' Showing the file name where the function is defined: >>> self.format('filename: {file}') 'filename: file.py' Including the module name and fully qualified function name: >>> self.format('{module} - {qualname}') 'module.file - MyClass.my_func' Customizing the parameter representation with a different separator and association: >>> self.format('{params}', association=': ', separator=' | ') 'a: 123 | b: 'abc'' """ module = self.module signature = self.signature bound_arguments = self.bound_arguments parameters = signature.parameters arguments = bound_arguments.arguments if self._include is None: include = set(parameters) elif isinstance(self._include, str): include = {self._include} else: include = set(self._include) if self._exclude is None: exclude = set() elif isinstance(self._exclude, str): exclude = {self._exclude} else: exclude = set(self._exclude) selected = include - exclude arguments = { parameter: arguments.get(parameter, self._undefined_value) for parameter in filter(selected.__contains__, parameters) } if '{absolute_path}' in pattern: old, new = '{absolute_path}', '{path}' pattern = pattern.replace(old, new) warnings.warn( message=f'use {new} instead of {old}', category=DeprecationWarning, stacklevel=2, ) if '{module_name}' in pattern: old, new = '{module_name}', '{module}' pattern = pattern.replace(old, new) warnings.warn( message=f'use {new} instead of {old}', category=DeprecationWarning, stacklevel=2, ) if '{function_name}' in pattern: old, new = '{function_name}', '{name}' pattern = pattern.replace(old, new) warnings.warn( message=f'use {new} instead of {old}', category=DeprecationWarning, stacklevel=2, ) if '{function_qualified_name}' in pattern: old, new = '{function_qualified_name}', '{qualname}' pattern = pattern.replace(old, new) warnings.warn( message=f'use {new} instead of {old}', category=DeprecationWarning, stacklevel=2, ) if '{line_number}' in pattern: old, new = '{line_number}', '{line}' pattern = pattern.replace(old, new) warnings.warn( message=f'use {new} instead of {old}', category=DeprecationWarning, stacklevel=2, ) if '{arguments}' in pattern: old, new = '{arguments}', '{params}' pattern = pattern.replace(old, new) warnings.warn( message=f'use {new} instead of {old}', category=DeprecationWarning, stacklevel=2, ) return pattern.format( path=module.__file__, file=pathlib.Path(module.__file__).name, module=module.__name__, name=self._func.__name__, qualname=self._func.__qualname__, line=inspect.getlineno(frame=self._frame), params=separator.join( f'{key}{association}{value!r}' for key, value in arguments.items() ) )
[docs] class ElapsedTime: """ Represents elapsed time in seconds, with support for multiple time units and human-readable formatting. This class provides a structured way to represent and manage elapsed time. It is primarily used internally to track the duration of function executions within the tracing mechanism. This includes formatting elapsed time for monitoring and debugging purposes. Notes ----- - This class is intended for internal use within the tracing system. Users should not instantiate it directly. Instead, instances of `ElapsedTime` are created and managed by the tracing mechanism to handle and format time-related information. """ def __init__(self, seconds: float) -> None: self._seconds = seconds def __repr__(self) -> str: return f'{self.__class__.__name__}(seconds={self._seconds})' @property def elapsed_time(self) -> float: warnings.warn( message='use .seconds instead of .elapsed_time', category=DeprecationWarning, stacklevel=2, ) return self.seconds @property def nanoseconds(self) -> float: """ Calculate the elapsed time in nanoseconds. Returns ------- float Elapsed time expressed in nanoseconds. """ return self._seconds * 1e9 @property def microseconds(self) -> float: """ Calculate the elapsed time in microseconds. Returns ------- float Elapsed time expressed in microseconds. """ return self._seconds * 1e6 @property def milliseconds(self) -> float: """ Calculate the elapsed time in milliseconds. Returns ------- float Elapsed time expressed in milliseconds. """ return self._seconds * 1e3 @property def seconds(self) -> float: """ Calculate the elapsed time in seconds. Returns ------- float Total elapsed time in seconds. """ return self._seconds @property def minutes(self) -> float: """ Calculate the elapsed time in minutes. Returns ------- float Elapsed time expressed in minutes. """ return self._seconds / 60 @property def hours(self) -> float: """ Calculate the elapsed time in hours. Returns ------- float Elapsed time expressed in hours. """ return self._seconds / 3600 @property def days(self) -> float: """ Calculate the elapsed time in days. Returns ------- float Elapsed time expressed in days. """ return self._seconds / 86400 @property def weeks(self) -> float: """ Calculate the elapsed time in weeks. Returns ------- float Elapsed time expressed in weeks. """ return self._seconds / 604800
[docs] def format( self, *, decimals=2, trailing_zeros=False, time_parts: bool = False, separator: str = ', ', ignore_zeros: bool = True, ) -> str | None: """ Convert the elapsed time to a human-readable string representation. Parameters ---------- decimals : int, default 2 Number of decimal places to display for the elapsed time. This parameter is applicable when `time_parts` is `False` or when the elapsed time is less than 1 nanosecond. trailing_zeros : bool, default False Determines whether to include trailing zeros after the decimal point. If `True`, trailing zeros will be included in the output; if `False`, they will be removed. time_parts : bool, default False If `True`, the elapsed time is broken down into its component units (e.g., hours, minutes, seconds). If `False`, the elapsed time is displayed as a single unit with the specified decimal precision. separator : str, default ', ' The string used to separate different units in the formatted output when `time_parts` is `True` ignore_zeros : bool, default True **Deprecated**. When set to `True`, zero-value units are excluded from the formatted output. This option is no longer recommended, as it may be removed in future versions. Returns ------- str or None A formatted string representing the elapsed time. If the elapsed time is `float('nan')`, the method returns `None`. Examples -------- Assume the elapsed time was 75 seconds: >>> self.seconds 75.0 >>> self.minutes 1.25 >>> self.format(decimals=2) '1.25 minutes' >>> self.format(decimals=1) '1.3 minutes' >>> self.format(decimals=0) '1 minute' Assume the elapsed time was 60 seconds: >>> self.seconds 60.0 >>> self.minutes 1.0 >>> self.format(trailing_zeros=False) '1 minute' >>> self.format(trailing_zeros=True) '1.00 minute' Assume the elapsed time was 3723 seconds: >>> self.seconds 3723.0 >>> self.minutes 62.05 >>> self.format(time_parts=True) '1 hour, 2 minutes, 3 seconds' >>> self.format(time_parts=True, separator=' | ') '1 hour | 2 minutes | 3 seconds' """ if math.isnan(self._seconds): return value, unit = self._single_unit() if time_parts: parts = self._multi_units(ignore_zeros=ignore_zeros) result = separator.join(f'{value} {unit}' for value, unit in parts) if result: return result value = self._round( value=value, decimals=decimals, trailing_zeros=trailing_zeros, ) return f'{value} {unit}'
@staticmethod def _round(value: float, decimals: int, trailing_zeros: bool) -> str: result = decimal.Decimal( value=value, ).quantize( exp=decimal.Decimal(value=f'1e-{decimals}'), rounding=decimal.ROUND_HALF_UP, ) if not trailing_zeros: result = str(result).rstrip('0').rstrip('.') return result def _single_unit(self) -> tuple[float, str]: if (value := self.weeks) >= 1: unit = f'week{'' if value == 1 else 's'}' elif (value := self.days) >= 1: unit = f'day{'' if value == 1 else 's'}' elif (value := self.hours) >= 1: unit = f'hour{'' if value == 1 else 's'}' elif (value := self.minutes) >= 1: unit = f'minute{'' if value == 1 else 's'}' elif (value := self.seconds) >= 1: unit = f'second{'' if value == 1 else 's'}' elif (value := self.milliseconds) >= 1: unit = f'millisecond{'' if value == 1 else 's'}' elif (value := self.microseconds) >= 1: unit = f'microsecond{'' if value == 1 else 's'}' else: value = self.nanoseconds unit = f'nanosecond{'' if value == 1 else 's'}' return value, unit def _multi_units(self, ignore_zeros: bool) -> tuple[tuple[int, str], ...]: weeks = int(self._seconds // 604800) days = int(self._seconds % 604800 // 86400) hours = int(self._seconds % 86400 // 3600) minutes = int(self._seconds % 3600 // 60) seconds = int(self._seconds % 60) milliseconds = int(self._seconds * 1e3 % 1e3) microseconds = int(self._seconds * 1e6 % 1e3) nanoseconds = int(self._seconds * 1e9 % 1e3) parts = ( (weeks, f'week{'' if weeks == 1 else 's'}'), (days, f'day{'' if days == 1 else 's'}'), (hours, f'hour{'' if hours == 1 else 's'}'), (minutes, f'minute{'' if minutes == 1 else 's'}'), (seconds, f'second{'' if seconds == 1 else 's'}'), (milliseconds, f'millisecond{'' if milliseconds == 1 else 's'}'), (microseconds, f'microsecond{'' if microseconds == 1 else 's'}'), (nanoseconds, f'nanosecond{'' if nanoseconds == 1 else 's'}'), ) if ignore_zeros: parts = tuple((value, unit) for value, unit in parts if value) else: warnings.warn( message='ignore_zeros is deprecated', category=DeprecationWarning, stacklevel=2, ) return parts