mermaid-trace 0.3.1__py3-none-any.whl → 0.4.0__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.
@@ -12,46 +12,68 @@ FLOW_LOGGER_NAME = "mermaid_trace.flow"
12
12
  # Define generic type variable for the decorated function
13
13
  F = TypeVar("F", bound=Callable[..., Any])
14
14
 
15
+
15
16
  def get_flow_logger() -> logging.Logger:
16
17
  """Returns the dedicated logger for flow events."""
17
18
  return logging.getLogger(FLOW_LOGGER_NAME)
18
19
 
19
- def _safe_repr(obj: Any, max_len: int = 50) -> str:
20
+
21
+ def _safe_repr(obj: Any, max_len: int = 50, max_depth: int = 1) -> str:
20
22
  """
21
23
  Safely creates a string representation of an object.
22
-
23
- Prevents massive log files by truncating long strings/objects
24
+
25
+ Prevents massive log files by truncating long strings/objects
24
26
  and handling exceptions during __repr__ calls (e.g. strict objects).
25
27
  """
26
28
  try:
27
- r = reprlib.repr(obj)
29
+ # Create a custom repr object to control depth and length
30
+ a_repr = reprlib.Repr()
31
+ a_repr.maxstring = max_len
32
+ a_repr.maxother = max_len
33
+ a_repr.maxlevel = max_depth
34
+
35
+ r = a_repr.repr(obj)
28
36
  if len(r) > max_len:
29
37
  return r[:max_len] + "..."
30
38
  return r
31
39
  except Exception:
32
40
  return "<unrepresentable>"
33
41
 
34
- def _format_args(args: Tuple[Any, ...], kwargs: Dict[str, Any]) -> str:
42
+
43
+ def _format_args(
44
+ args: Tuple[Any, ...],
45
+ kwargs: Dict[str, Any],
46
+ capture_args: bool = True,
47
+ max_arg_length: int = 50,
48
+ max_arg_depth: int = 1,
49
+ ) -> str:
35
50
  """
36
51
  Formats function arguments into a single string "arg1, arg2, k=v".
37
52
  Used for the arrow label in the diagram.
38
53
  """
54
+ if not capture_args:
55
+ return ""
56
+
39
57
  parts = []
40
58
  for arg in args:
41
- parts.append(_safe_repr(arg))
42
-
59
+ parts.append(_safe_repr(arg, max_len=max_arg_length, max_depth=max_arg_depth))
60
+
43
61
  for k, v in kwargs.items():
44
- parts.append(f"{k}={_safe_repr(v)}")
45
-
62
+ val_str = _safe_repr(v, max_len=max_arg_length, max_depth=max_arg_depth)
63
+ parts.append(f"{k}={val_str}")
64
+
46
65
  return ", ".join(parts)
47
66
 
48
- def _resolve_target(func: Callable[..., Any], args: Tuple[Any, ...], target_override: Optional[str]) -> str:
67
+
68
+ def _resolve_target(
69
+ func: Callable[..., Any], args: Tuple[Any, ...], target_override: Optional[str]
70
+ ) -> str:
49
71
  """
50
72
  Determines the name of the participant (Target) for the diagram.
51
-
73
+
52
74
  Resolution Priority:
53
75
  1. **Override**: If the user explicitly provided `target="Name"`, use it.
54
- 2. **Instance Method**: If the first arg looks like `self` (has __class__),
76
+ 2. **Instance Method**: If the first arg looks like `self` (has __class__),
55
77
  use the class name.
56
78
  3. **Class Method**: If the first arg is a type (cls), use the class name.
57
79
  4. **Module Function**: Fallback to the name of the module containing the function.
@@ -59,18 +81,20 @@ def _resolve_target(func: Callable[..., Any], args: Tuple[Any, ...], target_over
59
81
  """
60
82
  if target_override:
61
83
  return target_override
62
-
84
+
63
85
  # Heuristic: If it's a method call, args[0] is usually 'self'.
64
86
  if args:
65
87
  first_arg = args[0]
66
88
  # Check if it looks like a class instance
67
89
  # We check hasattr(__class__) to distinguish objects from primitives/containers broadly,
68
90
  # 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__)
91
+ if hasattr(first_arg, "__class__") and not isinstance(
92
+ first_arg, (str, int, float, bool, list, dict, type)
93
+ ):
94
+ return str(first_arg.__class__.__name__)
71
95
  # Check if it looks like a class (cls) - e.g. @classmethod
72
96
  if isinstance(first_arg, type):
73
- return first_arg.__name__
97
+ return first_arg.__name__
74
98
 
75
99
  # Fallback to module name for standalone functions
76
100
  module = inspect.getmodule(func)
@@ -78,118 +102,180 @@ def _resolve_target(func: Callable[..., Any], args: Tuple[Any, ...], target_over
78
102
  return module.__name__.split(".")[-1]
79
103
  return "Unknown"
80
104
 
81
- def _log_interaction(logger: logging.Logger,
82
- source: str,
83
- target: str,
84
- action: str,
85
- params: str,
86
- trace_id: str) -> None:
105
+
106
+ def _log_interaction(
107
+ logger: logging.Logger,
108
+ source: str,
109
+ target: str,
110
+ action: str,
111
+ params: str,
112
+ trace_id: str,
113
+ ) -> None:
87
114
  """
88
115
  Logs the 'Call' event (Start of function).
89
116
  Arrow: source -> target
90
117
  """
91
118
  req_event = FlowEvent(
92
- source=source, target=target,
93
- action=action, message=action,
94
- params=params, trace_id=trace_id
119
+ source=source,
120
+ target=target,
121
+ action=action,
122
+ message=action,
123
+ params=params,
124
+ trace_id=trace_id,
95
125
  )
96
126
  # The 'extra' dict is critical: the Handler will pick this up to format the Mermaid line
97
127
  logger.info(f"{source}->{target}: {action}", extra={"flow_event": req_event})
98
128
 
99
- def _log_return(logger: logging.Logger,
100
- source: str,
101
- target: str,
102
- action: str,
103
- result: Any,
104
- trace_id: str) -> None:
129
+
130
+ def _log_return(
131
+ logger: logging.Logger,
132
+ source: str,
133
+ target: str,
134
+ action: str,
135
+ result: Any,
136
+ trace_id: str,
137
+ capture_args: bool = True,
138
+ max_arg_length: int = 50,
139
+ max_arg_depth: int = 1,
140
+ ) -> None:
105
141
  """
106
142
  Logs the 'Return' event (End of function).
107
143
  Arrow: target --> source (Dotted line return)
108
-
144
+
109
145
  Note: 'source' here is the original caller, 'target' is the callee.
110
146
  So the return arrow goes from target back to source.
111
147
  """
112
- result_str = _safe_repr(result)
148
+ result_str = ""
149
+ if capture_args:
150
+ result_str = _safe_repr(result, max_len=max_arg_length, max_depth=max_arg_depth)
151
+
113
152
  resp_event = FlowEvent(
114
- source=target, target=source,
115
- action=action, message="Return",
116
- is_return=True, result=result_str, trace_id=trace_id
153
+ source=target,
154
+ target=source,
155
+ action=action,
156
+ message="Return",
157
+ is_return=True,
158
+ result=result_str,
159
+ trace_id=trace_id,
117
160
  )
118
161
  logger.info(f"{target}->{source}: Return", extra={"flow_event": resp_event})
119
162
 
120
- def _log_error(logger: logging.Logger,
121
- source: str,
122
- target: str,
123
- action: str,
124
- error: Exception,
125
- trace_id: str) -> None:
163
+
164
+ def _log_error(
165
+ logger: logging.Logger,
166
+ source: str,
167
+ target: str,
168
+ action: str,
169
+ error: Exception,
170
+ trace_id: str,
171
+ ) -> None:
126
172
  """
127
173
  Logs an 'Error' event if the function raises an exception.
128
174
  Arrow: target -x source (Error return)
129
175
  """
130
176
  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
177
+ source=target,
178
+ target=source,
179
+ action=action,
180
+ message=str(error),
181
+ is_return=True,
182
+ is_error=True,
183
+ error_message=str(error),
184
+ trace_id=trace_id,
134
185
  )
135
186
  logger.error(f"{target}-x{source}: Error", extra={"flow_event": err_event})
136
187
 
188
+
137
189
  @overload
138
- def trace_interaction(func: F) -> F:
139
- ...
190
+ def trace_interaction(func: F) -> F: ...
191
+
140
192
 
141
193
  @overload
142
194
  def trace_interaction(
143
- *,
144
- source: Optional[str] = None,
145
- target: Optional[str] = None,
146
- action: Optional[str] = None
147
- ) -> Callable[[F], F]:
148
- ...
195
+ *,
196
+ source: Optional[str] = None,
197
+ target: Optional[str] = None,
198
+ name: Optional[str] = None,
199
+ action: Optional[str] = None,
200
+ capture_args: bool = True,
201
+ max_arg_length: int = 50,
202
+ max_arg_depth: int = 1,
203
+ ) -> Callable[[F], F]: ...
204
+
149
205
 
150
206
  def trace_interaction(
151
- func: Optional[F] = None,
152
- *,
153
- source: Optional[str] = None,
154
- target: Optional[str] = None,
155
- action: Optional[str] = None
207
+ func: Optional[F] = None,
208
+ *,
209
+ source: Optional[str] = None,
210
+ target: Optional[str] = None,
211
+ name: Optional[str] = None,
212
+ action: Optional[str] = None,
213
+ capture_args: bool = True,
214
+ max_arg_length: int = 50,
215
+ max_arg_depth: int = 1,
156
216
  ) -> Union[F, Callable[[F], F]]:
157
217
  """
158
218
  Main Decorator for tracing function execution in Mermaid diagrams.
159
-
219
+
160
220
  It supports two modes of operation:
161
221
  1. **Simple**: `@trace` (No arguments)
162
222
  2. **Configured**: `@trace(action="Login", target="AuthService")`
163
-
223
+
164
224
  Args:
165
225
  func: The function being decorated (automatically passed in simple mode).
166
226
  source: Explicit name of the caller participant (rarely used, usually inferred from Context).
167
227
  target: Explicit name of the callee participant (overrides automatic resolution).
228
+ name: Alias for 'target' (for clearer API usage).
168
229
  action: Label for the arrow (defaults to function name).
230
+ capture_args: Whether to include arguments and return values in the log. Default True.
231
+ max_arg_length: Maximum string length for argument/result representation. Default 50.
232
+ max_arg_depth: Maximum recursion depth for argument/result representation. Default 1.
169
233
  """
170
-
234
+
235
+ # Handle alias
236
+ final_target = target or name
237
+
171
238
  # Mode 1: @trace used without parentheses
172
239
  if func is not None and callable(func):
173
- return _create_decorator(func, source, target, action)
174
-
240
+ return _create_decorator(
241
+ func,
242
+ source,
243
+ final_target,
244
+ action,
245
+ capture_args,
246
+ max_arg_length,
247
+ max_arg_depth,
248
+ )
249
+
175
250
  # Mode 2: @trace(...) used with arguments -> returns a factory
176
251
  def factory(f: F) -> F:
177
- return _create_decorator(f, source, target, action)
252
+ return _create_decorator(
253
+ f, source, final_target, action, capture_args, max_arg_length, max_arg_depth
254
+ )
255
+
178
256
  return factory
179
257
 
258
+
180
259
  def _create_decorator(
181
- func: F,
182
- source: Optional[str],
183
- target: Optional[str],
184
- action: Optional[str]
260
+ func: F,
261
+ source: Optional[str],
262
+ target: Optional[str],
263
+ action: Optional[str],
264
+ capture_args: bool,
265
+ max_arg_length: int,
266
+ max_arg_depth: int,
185
267
  ) -> F:
186
268
  """
187
269
  Constructs the actual wrapper function.
188
270
  Handles both synchronous and asynchronous functions.
271
+
272
+ This function separates the wrapper creation logic from the argument parsing logic
273
+ in `trace_interaction`, making the code cleaner and easier to test.
189
274
  """
190
-
275
+
191
276
  # Pre-calculate static metadata to save time at runtime
192
277
  if action is None:
278
+ # Default action name is the function name, converted to Title Case
193
279
  action = func.__name__.replace("_", " ").title()
194
280
 
195
281
  @functools.wraps(func)
@@ -197,49 +283,90 @@ def _create_decorator(
197
283
  """Sync function wrapper."""
198
284
  # 1. Resolve Context
199
285
  # 'source' is who called us (from Context). 'target' is who we are (resolved from self/cls).
286
+ # If 'source' is not explicitly provided, we look up the 'participant' set by the caller.
200
287
  current_source = source or LogContext.current_participant()
201
288
  trace_id = LogContext.current_trace_id()
202
289
  current_target = _resolve_target(func, args, target)
203
-
290
+
204
291
  logger = get_flow_logger()
205
- params_str = _format_args(args, kwargs)
206
-
292
+ # Format arguments for the diagram arrow label
293
+ params_str = _format_args(
294
+ args, kwargs, capture_args, max_arg_length, max_arg_depth
295
+ )
296
+
207
297
  # 2. Log Request (Start of block)
208
- _log_interaction(logger, current_source, current_target, action, params_str, trace_id)
209
-
298
+ # Logs the initial "Call" arrow (Source -> Target)
299
+ _log_interaction(
300
+ logger, current_source, current_target, action, params_str, trace_id
301
+ )
302
+
210
303
  # 3. Execute with New Context
211
304
  # We push 'current_target' as the NEW 'participant' (source) for any internal calls.
305
+ # This builds the chain: A -> B, then inside B, B becomes the source for C (B -> C).
212
306
  with LogContext.scope({"participant": current_target, "trace_id": trace_id}):
213
307
  try:
214
308
  result = func(*args, **kwargs)
215
309
  # 4. Log Success Return
216
- _log_return(logger, current_source, current_target, action, result, trace_id)
310
+ # Logs the "Return" arrow (Target --> Source)
311
+ _log_return(
312
+ logger,
313
+ current_source,
314
+ current_target,
315
+ action,
316
+ result,
317
+ trace_id,
318
+ capture_args,
319
+ max_arg_length,
320
+ max_arg_depth,
321
+ )
217
322
  return result
218
323
  except Exception as e:
219
324
  # 5. Log Error Return
325
+ # Logs the "Error" arrow (Target --x Source)
220
326
  _log_error(logger, current_source, current_target, action, e, trace_id)
221
327
  raise
222
328
 
223
329
  @functools.wraps(func)
224
330
  async def async_wrapper(*args: Any, **kwargs: Any) -> Any:
225
331
  """Async function wrapper (coroutine)."""
332
+ # 1. Resolve Context (Same as sync)
226
333
  current_source = source or LogContext.current_participant()
227
334
  trace_id = LogContext.current_trace_id()
228
335
  current_target = _resolve_target(func, args, target)
229
-
336
+
230
337
  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}):
338
+ params_str = _format_args(
339
+ args, kwargs, capture_args, max_arg_length, max_arg_depth
340
+ )
341
+
342
+ # 2. Log Request
343
+ _log_interaction(
344
+ logger, current_source, current_target, action, params_str, trace_id
345
+ )
346
+
347
+ # 3. Execute with New Context using 'ascope'
348
+ # Use async context manager (ascope) to ensure context propagates correctly across awaits.
349
+ # This is critical for asyncio: context must be preserved even if the task yields control.
350
+ async with LogContext.ascope(
351
+ {"participant": current_target, "trace_id": trace_id}
352
+ ):
238
353
  try:
239
354
  result = await func(*args, **kwargs)
240
- _log_return(logger, current_source, current_target, action, result, trace_id)
355
+ # 4. Log Success Return
356
+ _log_return(
357
+ logger,
358
+ current_source,
359
+ current_target,
360
+ action,
361
+ result,
362
+ trace_id,
363
+ capture_args,
364
+ max_arg_length,
365
+ max_arg_depth,
366
+ )
241
367
  return result
242
368
  except Exception as e:
369
+ # 5. Log Error Return
243
370
  _log_error(logger, current_source, current_target, action, e, trace_id)
244
371
  raise
245
372
 
@@ -248,5 +375,6 @@ def _create_decorator(
248
375
  return cast(F, async_wrapper)
249
376
  return cast(F, wrapper)
250
377
 
378
+
251
379
  # Alias for easy import
252
380
  trace = trace_interaction
@@ -2,66 +2,68 @@ from dataclasses import dataclass, field
2
2
  import time
3
3
  from typing import Optional
4
4
 
5
+
5
6
  @dataclass
6
7
  class FlowEvent:
7
8
  """
8
9
  Represents a single interaction or step in the execution flow.
9
-
10
+
10
11
  This data structure acts as the intermediate representation (IR) between
11
12
  runtime code execution and the final Mermaid diagram output. Each instance
12
13
  corresponds directly to one arrow or note in the sequence diagram.
13
-
14
+
14
15
  The fields map to Mermaid syntax components as follows:
15
16
  `source` -> `target`: `message`
16
-
17
+
17
18
  Attributes:
18
- source (str):
19
+ source (str):
19
20
  The name of the participant initiating the action (the "Caller").
20
21
  In Mermaid: The participant on the LEFT side of the arrow.
21
-
22
- target (str):
22
+
23
+ target (str):
23
24
  The name of the participant receiving the action (the "Callee").
24
25
  In Mermaid: The participant on the RIGHT side of the arrow.
25
-
26
- action (str):
26
+
27
+ action (str):
27
28
  A short, human-readable name for the operation (e.g., function name).
28
29
  Used for grouping or filtering logs, but often redundant with message.
29
-
30
- message (str):
30
+
31
+ message (str):
31
32
  The actual text label displayed on the diagram arrow.
32
33
  Example: "getUser(id=1)" or "Return: User(name='Alice')".
33
-
34
- timestamp (float):
34
+
35
+ timestamp (float):
35
36
  Unix timestamp (seconds) of when the event occurred.
36
- Used for ordering events if logs are processed asynchronously,
37
+ Used for ordering events if logs are processed asynchronously,
37
38
  though Mermaid sequence diagrams primarily rely on line order.
38
-
39
- trace_id (str):
39
+
40
+ trace_id (str):
40
41
  Unique identifier for the trace session.
41
42
  Allows filtering multiple concurrent traces from a single log file
42
43
  to generate separate diagrams for separate requests.
43
-
44
- is_return (bool):
44
+
45
+ is_return (bool):
45
46
  Flag indicating if this is a response arrow.
46
47
  If True, the arrow is drawn as a dotted line (`-->`) in Mermaid.
47
48
  If False, it is a solid line (`->`) representing a call.
48
-
49
- is_error (bool):
49
+
50
+ is_error (bool):
50
51
  Flag indicating if an exception occurred.
51
52
  If True, the arrow might be styled differently (e.g., `-x`) to show failure.
52
-
53
- error_message (Optional[str]):
53
+
54
+ error_message (Optional[str]):
54
55
  Detailed error text if `is_error` is True.
55
56
  Can be added as a note or included in the arrow label.
56
-
57
- params (Optional[str]):
57
+
58
+ params (Optional[str]):
58
59
  Stringified representation of function arguments.
59
60
  Captured only for request events (call start).
60
-
61
- result (Optional[str]):
61
+
62
+ result (Optional[str]):
62
63
  Stringified representation of the return value.
63
64
  Captured only for return events (call end).
64
65
  """
66
+
65
67
  source: str
66
68
  target: str
67
69
  action: str
@@ -3,6 +3,7 @@ import re
3
3
  from typing import Optional
4
4
  from .events import FlowEvent
5
5
 
6
+
6
7
  class MermaidFormatter(logging.Formatter):
7
8
  """
8
9
  Custom formatter to convert FlowEvents into Mermaid sequence diagram syntax.
@@ -10,8 +11,8 @@ class MermaidFormatter(logging.Formatter):
10
11
 
11
12
  def format(self, record: logging.LogRecord) -> str:
12
13
  # 1. Retrieve the FlowEvent
13
- event: Optional[FlowEvent] = getattr(record, 'flow_event', None)
14
-
14
+ event: Optional[FlowEvent] = getattr(record, "flow_event", None)
15
+
15
16
  if not event:
16
17
  # Fallback for standard logs if they accidentally reach this handler
17
18
  return super().format(record)
@@ -23,39 +24,47 @@ class MermaidFormatter(logging.Formatter):
23
24
  """
24
25
  Converts a FlowEvent into a Mermaid syntax string.
25
26
  """
26
- # Sanitize participant names
27
+ # Sanitize participant names to avoid syntax errors in Mermaid
27
28
  src = self._sanitize(event.source)
28
29
  tgt = self._sanitize(event.target)
29
-
30
+
30
31
  # Determine arrow type
31
32
  # ->> : Solid line with arrowhead (synchronous call)
32
33
  # -->> : Dotted line with arrowhead (return)
33
34
  # --x : Dotted line with cross (error)
34
35
  arrow = "-->>" if event.is_return else "->>"
35
-
36
+
37
+ msg = ""
36
38
  if event.is_error:
37
39
  arrow = "--x"
38
40
  msg = f"Error: {event.error_message}"
39
41
  elif event.is_return:
42
+ # For returns, we usually show the return value or just "Return"
40
43
  msg = f"Return: {event.result}" if event.result else "Return"
41
44
  else:
45
+ # For calls, we show Action(Params) or just Action
42
46
  msg = f"{event.message}({event.params})" if event.params else event.message
43
-
47
+
44
48
  # Optional: Add note or group if trace_id changes (not implemented in single line format)
45
49
  # For now, we just output the interaction.
46
-
47
- # Escape message for Mermaid safety
50
+
51
+ # Escape message for Mermaid safety (e.g. replacing newlines)
48
52
  msg = self._escape_message(msg)
49
-
53
+
54
+ # Format: Source->>Target: Message
50
55
  return f"{src}{arrow}{tgt}: {msg}"
51
56
 
52
57
  def _sanitize(self, name: str) -> str:
53
58
  """
54
59
  Sanitizes participant names to be valid Mermaid identifiers.
55
60
  Allows alphanumeric and underscores. Replaces others.
61
+
62
+ Mermaid doesn't like spaces or special characters in participant aliases
63
+ unless they are quoted (which we are not doing here for simplicity),
64
+ so we replace them with underscores.
56
65
  """
57
66
  # Replace any non-alphanumeric character (except underscore) with underscore
58
- clean_name = re.sub(r'[^a-zA-Z0-9_]', '_', name)
67
+ clean_name = re.sub(r"[^a-zA-Z0-9_]", "_", name)
59
68
  # Ensure it doesn't start with a digit (Mermaid doesn't like that sometimes, though often okay)
60
69
  if clean_name and clean_name[0].isdigit():
61
70
  clean_name = "_" + clean_name
@@ -67,6 +76,6 @@ class MermaidFormatter(logging.Formatter):
67
76
  Mermaid messages can contain most chars, but : and newlines can be tricky.
68
77
  """
69
78
  # Replace newlines with <br/> for Mermaid display
70
- msg = msg.replace('\n', '<br/>')
79
+ msg = msg.replace("\n", "<br/>")
71
80
  # We might want to escape other chars if needed, but usually text after : is forgiving.
72
81
  return msg
@@ -0,0 +1,52 @@
1
+ import logging
2
+ import logging.handlers
3
+ import queue
4
+ import atexit
5
+ from typing import List, Optional
6
+
7
+
8
+ class AsyncMermaidHandler(logging.handlers.QueueHandler):
9
+ """
10
+ A non-blocking logging handler that uses a background thread to write logs.
11
+
12
+ This handler pushes log records to a queue, which are then picked up by a
13
+ QueueListener running in a separate thread and dispatched to the actual
14
+ handlers (e.g., MermaidFileHandler).
15
+ """
16
+
17
+ def __init__(self, handlers: List[logging.Handler], queue_size: int = -1):
18
+ """
19
+ Initialize the async handler.
20
+
21
+ Args:
22
+ handlers: A list of handlers that should receive the logs from the queue.
23
+ (e.g., [MermaidFileHandler(...)])
24
+ queue_size: The maximum size of the queue. -1 means infinite.
25
+ """
26
+ self._log_queue: queue.Queue[logging.LogRecord] = queue.Queue(queue_size)
27
+ super().__init__(self._log_queue)
28
+
29
+ # Initialize QueueListener
30
+ # It starts an internal thread to monitor the queue
31
+ # respect_handler_level=True ensures the target handlers' log levels are respected
32
+ self._listener: Optional[logging.handlers.QueueListener] = logging.handlers.QueueListener(
33
+ self._log_queue,
34
+ *handlers,
35
+ respect_handler_level=True
36
+ )
37
+ self._listener.start()
38
+
39
+ # Ensure the listener is stopped and queue is flushed upon exit
40
+ # This prevents lost logs at program termination
41
+ atexit.register(self.stop)
42
+
43
+ def stop(self) -> None:
44
+ """
45
+ Stops the listener and flushes the queue.
46
+
47
+ This is registered with `atexit` to ensure that all pending logs
48
+ are written to disk before the application terminates.
49
+ """
50
+ if self._listener:
51
+ self._listener.stop()
52
+ self._listener = None