mermaid-trace 0.3.1__py3-none-any.whl → 0.4.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,3 +1,15 @@
1
+ """
2
+ Function Tracing Decorator Module
3
+
4
+ This module provides the core tracing functionality for MermaidTrace. It includes:
5
+ - A decorator (`trace`/`trace_interaction`) to instrument functions for tracing
6
+ - Helper functions for formatting and logging trace events
7
+ - Context management for tracking call chains
8
+
9
+ The decorator can be applied to both synchronous and asynchronous functions,
10
+ and automatically handles context propagation for nested calls.
11
+ """
12
+
1
13
  import functools
2
14
  import logging
3
15
  import inspect
@@ -7,70 +19,127 @@ from typing import Optional, Any, Callable, Tuple, Dict, Union, TypeVar, cast, o
7
19
  from .events import FlowEvent
8
20
  from .context import LogContext
9
21
 
22
+ # Logger name for flow events - used to isolate tracing logs from other application logs
10
23
  FLOW_LOGGER_NAME = "mermaid_trace.flow"
11
24
 
12
25
  # Define generic type variable for the decorated function
13
26
  F = TypeVar("F", bound=Callable[..., Any])
14
27
 
28
+
15
29
  def get_flow_logger() -> logging.Logger:
16
- """Returns the dedicated logger for flow events."""
30
+ """Returns the dedicated logger for flow events.
31
+
32
+ Returns:
33
+ logging.Logger: Logger instance configured for tracing events
34
+ """
17
35
  return logging.getLogger(FLOW_LOGGER_NAME)
18
36
 
19
- def _safe_repr(obj: Any, max_len: int = 50) -> str:
37
+
38
+ def _safe_repr(obj: Any, max_len: int = 50, max_depth: int = 1) -> str:
20
39
  """
21
40
  Safely creates a string representation of an object.
22
-
23
- Prevents massive log files by truncating long strings/objects
41
+
42
+ Prevents massive log files by truncating long strings/objects
24
43
  and handling exceptions during __repr__ calls (e.g. strict objects).
44
+
45
+ Args:
46
+ obj: The object to represent as a string
47
+ max_len: Maximum length of the resulting string
48
+ max_depth: Maximum recursion depth for nested objects
49
+
50
+ Returns:
51
+ str: Safe, truncated representation of the object
25
52
  """
26
53
  try:
27
- r = reprlib.repr(obj)
54
+ # Create a custom repr object to control depth and length
55
+ a_repr = reprlib.Repr()
56
+ a_repr.maxstring = max_len
57
+ a_repr.maxother = max_len
58
+ a_repr.maxlevel = max_depth
59
+
60
+ r = a_repr.repr(obj)
28
61
  if len(r) > max_len:
29
62
  return r[:max_len] + "..."
30
63
  return r
31
64
  except Exception:
65
+ # Fallback if repr() fails for any reason
32
66
  return "<unrepresentable>"
33
67
 
34
- def _format_args(args: Tuple[Any, ...], kwargs: Dict[str, Any]) -> str:
68
+
69
+ def _format_args(
70
+ args: Tuple[Any, ...],
71
+ kwargs: Dict[str, Any],
72
+ capture_args: bool = True,
73
+ max_arg_length: int = 50,
74
+ max_arg_depth: int = 1,
75
+ ) -> str:
35
76
  """
36
77
  Formats function arguments into a single string "arg1, arg2, k=v".
37
78
  Used for the arrow label in the diagram.
79
+
80
+ Args:
81
+ args: Positional arguments to format
82
+ kwargs: Keyword arguments to format
83
+ capture_args: Whether to capture and format arguments at all
84
+ max_arg_length: Maximum length of each argument representation
85
+ max_arg_depth: Maximum recursion depth for nested arguments
86
+
87
+ Returns:
88
+ str: Formatted string of arguments, or empty string if capture_args is False
38
89
  """
39
- parts = []
90
+ if not capture_args:
91
+ return ""
92
+
93
+ parts: list[str] = []
94
+
95
+ # Format positional arguments
40
96
  for arg in args:
41
- parts.append(_safe_repr(arg))
42
-
97
+ parts.append(_safe_repr(arg, max_len=max_arg_length, max_depth=max_arg_depth))
98
+
99
+ # Format keyword arguments
43
100
  for k, v in kwargs.items():
44
- parts.append(f"{k}={_safe_repr(v)}")
45
-
101
+ val_str = _safe_repr(v, max_len=max_arg_length, max_depth=max_arg_depth)
102
+ parts.append(f"{k}={val_str}")
103
+
46
104
  return ", ".join(parts)
47
105
 
48
- def _resolve_target(func: Callable[..., Any], args: Tuple[Any, ...], target_override: Optional[str]) -> str:
106
+
107
+ def _resolve_target(
108
+ func: Callable[..., Any], args: Tuple[Any, ...], target_override: Optional[str]
109
+ ) -> str:
49
110
  """
50
111
  Determines the name of the participant (Target) for the diagram.
51
-
112
+
52
113
  Resolution Priority:
53
114
  1. **Override**: If the user explicitly provided `target="Name"`, use it.
54
- 2. **Instance Method**: If the first arg looks like `self` (has __class__),
115
+ 2. **Instance Method**: If the first arg looks like `self` (has __class__),
55
116
  use the class name.
56
117
  3. **Class Method**: If the first arg is a type (cls), use the class name.
57
118
  4. **Module Function**: Fallback to the name of the module containing the function.
58
119
  5. **Fallback**: "Unknown".
120
+
121
+ Args:
122
+ func: The function being called
123
+ args: Positional arguments passed to the function
124
+ target_override: Explicit target name provided by the user, if any
125
+
126
+ Returns:
127
+ str: Resolved target name for the diagram
59
128
  """
60
129
  if target_override:
61
130
  return target_override
62
-
63
- # Heuristic: If it's a method call, args[0] is usually 'self'.
131
+
132
+ # Heuristic: If it's a method call, args[0] is usually 'self' or 'cls'
64
133
  if args:
65
134
  first_arg = args[0]
66
- # Check if it looks like a class instance
67
- # We check hasattr(__class__) to distinguish objects from primitives/containers broadly,
68
- # ensuring we don't mislabel a plain list passed as first arg to a function as a "List" participant.
69
- if hasattr(first_arg, "__class__") and not isinstance(first_arg, (str, int, float, bool, list, dict, type)):
70
- return str(first_arg.__class__.__name__)
135
+ # Check if it looks like a class instance (not a primitive or container)
136
+ if hasattr(first_arg, "__class__") and not isinstance(
137
+ first_arg, (str, int, float, bool, list, dict, type)
138
+ ):
139
+ return str(first_arg.__class__.__name__)
71
140
  # Check if it looks like a class (cls) - e.g. @classmethod
72
141
  if isinstance(first_arg, type):
73
- return first_arg.__name__
142
+ return first_arg.__name__
74
143
 
75
144
  # Fallback to module name for standalone functions
76
145
  module = inspect.getmodule(func)
@@ -78,175 +147,324 @@ def _resolve_target(func: Callable[..., Any], args: Tuple[Any, ...], target_over
78
147
  return module.__name__.split(".")[-1]
79
148
  return "Unknown"
80
149
 
81
- def _log_interaction(logger: logging.Logger,
82
- source: str,
83
- target: str,
84
- action: str,
85
- params: str,
86
- trace_id: str) -> None:
150
+
151
+ def _log_interaction(
152
+ logger: logging.Logger,
153
+ source: str,
154
+ target: str,
155
+ action: str,
156
+ params: str,
157
+ trace_id: str,
158
+ ) -> None:
87
159
  """
88
160
  Logs the 'Call' event (Start of function).
89
- Arrow: source -> target
161
+ Generates a FlowEvent and logs it with the appropriate context.
162
+
163
+ Args:
164
+ logger: Logger instance to use for logging
165
+ source: Name of the source participant (caller)
166
+ target: Name of the target participant (callee)
167
+ action: Name of the action being performed
168
+ params: Formatted string of function arguments
169
+ trace_id: Unique trace identifier for correlation
90
170
  """
91
171
  req_event = FlowEvent(
92
- source=source, target=target,
93
- action=action, message=action,
94
- params=params, trace_id=trace_id
172
+ source=source,
173
+ target=target,
174
+ action=action,
175
+ message=action,
176
+ params=params,
177
+ trace_id=trace_id,
95
178
  )
96
179
  # The 'extra' dict is critical: the Handler will pick this up to format the Mermaid line
97
180
  logger.info(f"{source}->{target}: {action}", extra={"flow_event": req_event})
98
181
 
99
- def _log_return(logger: logging.Logger,
100
- source: str,
101
- target: str,
102
- action: str,
103
- result: Any,
104
- trace_id: str) -> None:
182
+
183
+ def _log_return(
184
+ logger: logging.Logger,
185
+ source: str,
186
+ target: str,
187
+ action: str,
188
+ result: Any,
189
+ trace_id: str,
190
+ capture_args: bool = True,
191
+ max_arg_length: int = 50,
192
+ max_arg_depth: int = 1,
193
+ ) -> None:
105
194
  """
106
195
  Logs the 'Return' event (End of function).
107
196
  Arrow: target --> source (Dotted line return)
108
-
197
+
109
198
  Note: 'source' here is the original caller, 'target' is the callee.
110
199
  So the return arrow goes from target back to source.
200
+
201
+ Args:
202
+ logger: Logger instance to use for logging
203
+ source: Name of the original caller
204
+ target: Name of the callee that's returning
205
+ action: Name of the action being returned from
206
+ result: Return value of the function
207
+ trace_id: Unique trace identifier for correlation
208
+ capture_args: Whether to include the return value in the log
209
+ max_arg_length: Maximum length of the return value representation
210
+ max_arg_depth: Maximum recursion depth for nested return values
111
211
  """
112
- result_str = _safe_repr(result)
212
+ result_str = ""
213
+ if capture_args:
214
+ result_str = _safe_repr(result, max_len=max_arg_length, max_depth=max_arg_depth)
215
+
113
216
  resp_event = FlowEvent(
114
- source=target, target=source,
115
- action=action, message="Return",
116
- is_return=True, result=result_str, trace_id=trace_id
217
+ source=target,
218
+ target=source,
219
+ action=action,
220
+ message="Return",
221
+ is_return=True,
222
+ result=result_str,
223
+ trace_id=trace_id,
117
224
  )
118
225
  logger.info(f"{target}->{source}: Return", extra={"flow_event": resp_event})
119
226
 
120
- def _log_error(logger: logging.Logger,
121
- source: str,
122
- target: str,
123
- action: str,
124
- error: Exception,
125
- trace_id: str) -> None:
227
+
228
+ def _log_error(
229
+ logger: logging.Logger,
230
+ source: str,
231
+ target: str,
232
+ action: str,
233
+ error: Exception,
234
+ trace_id: str,
235
+ ) -> None:
126
236
  """
127
237
  Logs an 'Error' event if the function raises an exception.
128
238
  Arrow: target -x source (Error return)
239
+
240
+ Args:
241
+ logger: Logger instance to use for logging
242
+ source: Name of the original caller
243
+ target: Name of the callee that encountered an error
244
+ action: Name of the action that failed
245
+ error: Exception that was raised
246
+ trace_id: Unique trace identifier for correlation
129
247
  """
130
248
  err_event = FlowEvent(
131
- source=target, target=source, action=action,
132
- message=str(error), is_return=True, is_error=True, error_message=str(error),
133
- trace_id=trace_id
249
+ source=target,
250
+ target=source,
251
+ action=action,
252
+ message=str(error),
253
+ is_return=True,
254
+ is_error=True,
255
+ error_message=str(error),
256
+ trace_id=trace_id,
134
257
  )
135
258
  logger.error(f"{target}-x{source}: Error", extra={"flow_event": err_event})
136
259
 
260
+
137
261
  @overload
138
- def trace_interaction(func: F) -> F:
139
- ...
262
+ def trace_interaction(func: F) -> F: ...
263
+
140
264
 
141
265
  @overload
142
266
  def trace_interaction(
143
- *,
144
- source: Optional[str] = None,
145
- target: Optional[str] = None,
146
- action: Optional[str] = None
147
- ) -> Callable[[F], F]:
148
- ...
267
+ *,
268
+ source: Optional[str] = None,
269
+ target: Optional[str] = None,
270
+ name: Optional[str] = None,
271
+ action: Optional[str] = None,
272
+ capture_args: bool = True,
273
+ max_arg_length: int = 50,
274
+ max_arg_depth: int = 1,
275
+ ) -> Callable[[F], F]: ...
276
+
149
277
 
150
278
  def trace_interaction(
151
- func: Optional[F] = None,
152
- *,
153
- source: Optional[str] = None,
154
- target: Optional[str] = None,
155
- action: Optional[str] = None
279
+ func: Optional[F] = None,
280
+ *,
281
+ source: Optional[str] = None,
282
+ target: Optional[str] = None,
283
+ name: Optional[str] = None,
284
+ action: Optional[str] = None,
285
+ capture_args: bool = True,
286
+ max_arg_length: int = 50,
287
+ max_arg_depth: int = 1,
156
288
  ) -> Union[F, Callable[[F], F]]:
157
289
  """
158
290
  Main Decorator for tracing function execution in Mermaid diagrams.
159
-
291
+
292
+ This decorator instruments functions to log their execution flow as Mermaid
293
+ sequence diagram events. It supports both synchronous and asynchronous functions,
294
+ and automatically handles context propagation for nested calls.
295
+
160
296
  It supports two modes of operation:
161
- 1. **Simple**: `@trace` (No arguments)
162
- 2. **Configured**: `@trace(action="Login", target="AuthService")`
163
-
297
+ 1. **Simple**: `@trace` (No arguments) - uses default settings
298
+ 2. **Configured**: `@trace(action="Login", target="AuthService")` - customizes behavior
299
+
164
300
  Args:
165
301
  func: The function being decorated (automatically passed in simple mode).
166
302
  source: Explicit name of the caller participant (rarely used, usually inferred from Context).
167
303
  target: Explicit name of the callee participant (overrides automatic resolution).
168
- action: Label for the arrow (defaults to function name).
304
+ name: Alias for 'target' (for clearer API usage).
305
+ action: Label for the arrow (defaults to function name in Title Case).
306
+ capture_args: Whether to include arguments and return values in the log. Default True.
307
+ max_arg_length: Maximum string length for argument/result representation. Default 50.
308
+ max_arg_depth: Maximum recursion depth for argument/result representation. Default 1.
309
+
310
+ Returns:
311
+ Callable: Either the decorated function (simple mode) or a decorator factory (configured mode)
169
312
  """
170
-
171
- # Mode 1: @trace used without parentheses
313
+
314
+ # Handle alias - 'name' is an alternative name for 'target'
315
+ final_target = target or name
316
+
317
+ # Mode 1: @trace used without parentheses - directly decorate the function
172
318
  if func is not None and callable(func):
173
- return _create_decorator(func, source, target, action)
174
-
175
- # Mode 2: @trace(...) used with arguments -> returns a factory
319
+ return _create_decorator(
320
+ func,
321
+ source,
322
+ final_target,
323
+ action,
324
+ capture_args,
325
+ max_arg_length,
326
+ max_arg_depth,
327
+ )
328
+
329
+ # Mode 2: @trace(...) used with arguments -> returns a factory that will decorate the function
176
330
  def factory(f: F) -> F:
177
- return _create_decorator(f, source, target, action)
331
+ return _create_decorator(
332
+ f, source, final_target, action, capture_args, max_arg_length, max_arg_depth
333
+ )
334
+
178
335
  return factory
179
336
 
337
+
180
338
  def _create_decorator(
181
- func: F,
182
- source: Optional[str],
183
- target: Optional[str],
184
- action: Optional[str]
339
+ func: F,
340
+ source: Optional[str],
341
+ target: Optional[str],
342
+ action: Optional[str],
343
+ capture_args: bool,
344
+ max_arg_length: int,
345
+ max_arg_depth: int,
185
346
  ) -> F:
186
347
  """
187
- Constructs the actual wrapper function.
188
- Handles both synchronous and asynchronous functions.
348
+ Constructs the actual wrapper function for the decorated function.
349
+ Handles both synchronous and asynchronous functions by creating the appropriate wrapper.
350
+
351
+ This function separates the wrapper creation logic from the argument parsing logic
352
+ in `trace_interaction`, making the code cleaner and easier to test.
353
+
354
+ Args:
355
+ func: The function to decorate
356
+ source: Explicit source name, if any
357
+ target: Explicit target name, if any
358
+ action: Explicit action name, if any
359
+ capture_args: Whether to capture arguments and return values
360
+ max_arg_length: Maximum length for argument/return value representations
361
+ max_arg_depth: Maximum recursion depth for nested objects
362
+
363
+ Returns:
364
+ Callable: Decorated function with tracing logic
189
365
  """
190
-
366
+
191
367
  # Pre-calculate static metadata to save time at runtime
192
368
  if action is None:
369
+ # Default action name is the function name, converted to Title Case
193
370
  action = func.__name__.replace("_", " ").title()
194
371
 
195
372
  @functools.wraps(func)
196
373
  def wrapper(*args: Any, **kwargs: Any) -> Any:
197
- """Sync function wrapper."""
374
+ """Synchronous function wrapper that adds tracing logic."""
198
375
  # 1. Resolve Context
199
376
  # 'source' is who called us (from Context). 'target' is who we are (resolved from self/cls).
200
377
  current_source = source or LogContext.current_participant()
201
378
  trace_id = LogContext.current_trace_id()
202
379
  current_target = _resolve_target(func, args, target)
203
-
380
+
204
381
  logger = get_flow_logger()
205
- params_str = _format_args(args, kwargs)
206
-
207
- # 2. Log Request (Start of block)
208
- _log_interaction(logger, current_source, current_target, action, params_str, trace_id)
209
-
382
+ # Format arguments for the diagram arrow label
383
+ params_str = _format_args(
384
+ args, kwargs, capture_args, max_arg_length, max_arg_depth
385
+ )
386
+
387
+ # 2. Log Request (Start of function)
388
+ # Logs the initial "Call" arrow (Source -> Target)
389
+ _log_interaction(
390
+ logger, current_source, current_target, action, params_str, trace_id
391
+ )
392
+
210
393
  # 3. Execute with New Context
211
394
  # We push 'current_target' as the NEW 'participant' (source) for any internal calls.
395
+ # This builds the chain: A -> B, then inside B, B becomes the source for C (B -> C).
212
396
  with LogContext.scope({"participant": current_target, "trace_id": trace_id}):
213
397
  try:
214
398
  result = func(*args, **kwargs)
215
399
  # 4. Log Success Return
216
- _log_return(logger, current_source, current_target, action, result, trace_id)
400
+ # Logs the "Return" arrow (Target --> Source)
401
+ _log_return(
402
+ logger,
403
+ current_source,
404
+ current_target,
405
+ action,
406
+ result,
407
+ trace_id,
408
+ capture_args,
409
+ max_arg_length,
410
+ max_arg_depth,
411
+ )
217
412
  return result
218
413
  except Exception as e:
219
414
  # 5. Log Error Return
415
+ # Logs the "Error" arrow (Target --x Source)
220
416
  _log_error(logger, current_source, current_target, action, e, trace_id)
221
417
  raise
222
418
 
223
419
  @functools.wraps(func)
224
420
  async def async_wrapper(*args: Any, **kwargs: Any) -> Any:
225
- """Async function wrapper (coroutine)."""
421
+ """Asynchronous function wrapper that adds tracing logic."""
422
+ # 1. Resolve Context (Same as sync)
226
423
  current_source = source or LogContext.current_participant()
227
424
  trace_id = LogContext.current_trace_id()
228
425
  current_target = _resolve_target(func, args, target)
229
-
426
+
230
427
  logger = get_flow_logger()
231
- params_str = _format_args(args, kwargs)
232
-
233
- # 2. Log Request (Start of block)
234
- _log_interaction(logger, current_source, current_target, action, params_str, trace_id)
235
-
236
- # Use async context manager (ascope) to ensure context propagates correctly across awaits
237
- async with LogContext.ascope({"participant": current_target, "trace_id": trace_id}):
428
+ params_str = _format_args(
429
+ args, kwargs, capture_args, max_arg_length, max_arg_depth
430
+ )
431
+
432
+ # 2. Log Request
433
+ _log_interaction(
434
+ logger, current_source, current_target, action, params_str, trace_id
435
+ )
436
+
437
+ # 3. Execute with New Context using 'ascope'
438
+ # Use async context manager (ascope) to ensure context propagates correctly across awaits.
439
+ # This is critical for asyncio: context must be preserved even if the task yields control.
440
+ async with LogContext.ascope(
441
+ {"participant": current_target, "trace_id": trace_id}
442
+ ):
238
443
  try:
239
444
  result = await func(*args, **kwargs)
240
- _log_return(logger, current_source, current_target, action, result, trace_id)
445
+ # 4. Log Success Return
446
+ _log_return(
447
+ logger,
448
+ current_source,
449
+ current_target,
450
+ action,
451
+ result,
452
+ trace_id,
453
+ capture_args,
454
+ max_arg_length,
455
+ max_arg_depth,
456
+ )
241
457
  return result
242
458
  except Exception as e:
459
+ # 5. Log Error Return
243
460
  _log_error(logger, current_source, current_target, action, e, trace_id)
244
461
  raise
245
462
 
246
463
  # Detect if the wrapped function is a coroutine to choose the right wrapper
247
464
  if inspect.iscoroutinefunction(func):
248
- return cast(F, async_wrapper)
249
- return cast(F, wrapper)
465
+ return cast(F, async_wrapper) # Return async wrapper for coroutines
466
+ return cast(F, wrapper) # Return sync wrapper for regular functions
467
+
250
468
 
251
- # Alias for easy import
469
+ # Alias for easy import - 'trace' is the primary name users should use
252
470
  trace = trace_interaction