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.
mermaid_trace/__init__.py CHANGED
@@ -7,14 +7,28 @@ developers understand the flow of their applications, debug complex interactions
7
7
  and document system behavior.
8
8
 
9
9
  Key Components:
10
- - `trace`: A decorator to instrument functions for tracing.
10
+ - `trace`: A decorator to instrument functions for tracing. It captures arguments,
11
+ return values, and errors, and logs them as interactions.
11
12
  - `LogContext`: Manages execution context (like thread-local storage) to track
12
13
  caller/callee relationships across async tasks and threads.
13
14
  - `configure_flow`: Sets up the logging handler to write diagrams to a file.
15
+ It handles handler configuration, file modes, and async logging setup.
16
+
17
+ Usage Example:
18
+ from mermaid_trace import trace, configure_flow
19
+
20
+ configure_flow("my_flow.mmd")
21
+
22
+ @trace
23
+ def hello():
24
+ print("Hello")
25
+
26
+ hello()
14
27
  """
15
28
 
16
29
  from .core.decorators import trace_interaction, trace
17
30
  from .handlers.mermaid_handler import MermaidFileHandler
31
+ from .handlers.async_handler import AsyncMermaidHandler
18
32
  from .core.events import FlowEvent
19
33
  from .core.context import LogContext
20
34
  from .core.formatter import MermaidFormatter
@@ -22,42 +36,80 @@ from .core.formatter import MermaidFormatter
22
36
  # Integrations (like FastAPI) must be imported explicitly by the user if needed.
23
37
 
24
38
  from importlib.metadata import PackageNotFoundError, version
39
+ from typing import List, Optional
25
40
 
26
41
  import logging
27
42
 
28
- def configure_flow(output_file: str = "flow.mmd") -> logging.Logger:
43
+
44
+ def configure_flow(
45
+ output_file: str = "flow.mmd",
46
+ handlers: Optional[List[logging.Handler]] = None,
47
+ append: bool = False,
48
+ async_mode: bool = False,
49
+ ) -> logging.Logger:
29
50
  """
30
51
  Configures the flow logger to output to a Mermaid file.
31
-
52
+
32
53
  This function sets up the logging infrastructure required to capture
33
54
  trace events and write them to the specified output file. It should
34
- be called once at the start of your application.
35
-
55
+ be called once at the start of your application to initialize the tracing system.
56
+
36
57
  Args:
37
58
  output_file (str): The absolute or relative path to the output .mmd file.
38
59
  Defaults to "flow.mmd" in the current directory.
39
-
60
+ If the file does not exist, it will be created with the correct header.
61
+ handlers (List[logging.Handler], optional): A list of custom logging handlers.
62
+ If provided, 'output_file' is ignored unless
63
+ you explicitly include a MermaidFileHandler.
64
+ Useful if you want to stream logs to other destinations.
65
+ append (bool): If True, adds the new handler(s) without removing existing ones.
66
+ Defaults to False (clears existing handlers to prevent duplicate logging).
67
+ async_mode (bool): If True, uses a non-blocking background thread for logging (QueueHandler).
68
+ Recommended for high-performance production environments to avoid
69
+ blocking the main execution thread during file I/O.
70
+ Defaults to False.
71
+
40
72
  Returns:
41
73
  logging.Logger: The configured logger instance used for flow tracing.
42
74
  """
43
75
  # Get the specific logger used by the tracing decorators
76
+ # This logger is isolated from the root logger to prevent pollution
44
77
  logger = logging.getLogger("mermaid_trace.flow")
45
78
  logger.setLevel(logging.INFO)
46
-
79
+
47
80
  # Remove existing handlers to avoid duplicate logs if configured multiple times
48
- if logger.hasHandlers():
81
+ # unless 'append' is requested. This ensures idempotency when calling configure_flow multiple times.
82
+ if not append and logger.hasHandlers():
49
83
  logger.handlers.clear()
50
-
51
- # Create and attach the custom handler that writes Mermaid syntax
52
- handler = MermaidFileHandler(output_file)
53
-
54
- # Use the custom formatter to convert FlowEvents to Mermaid strings
55
- handler.setFormatter(MermaidFormatter())
56
-
57
- logger.addHandler(handler)
58
-
84
+
85
+ # Determine the target handlers
86
+ target_handlers = []
87
+
88
+ if handlers:
89
+ # Use user-provided handlers if available
90
+ target_handlers = handlers
91
+ else:
92
+ # Create default Mermaid handler
93
+ # This handler knows how to write the Mermaid header and format events
94
+ handler = MermaidFileHandler(output_file)
95
+ handler.setFormatter(MermaidFormatter())
96
+ target_handlers = [handler]
97
+
98
+ if async_mode:
99
+ # Wrap the target handlers in an AsyncMermaidHandler (QueueHandler)
100
+ # The QueueListener will pick up logs from the queue and dispatch to target_handlers
101
+ # This decouples the application execution from the logging I/O
102
+ async_handler = AsyncMermaidHandler(target_handlers)
103
+ logger.addHandler(async_handler)
104
+ else:
105
+ # Attach handlers directly to the logger for synchronous logging
106
+ # Simple and reliable for debugging or low-throughput applications
107
+ for h in target_handlers:
108
+ logger.addHandler(h)
109
+
59
110
  return logger
60
111
 
112
+
61
113
  try:
62
114
  # Attempt to retrieve the installed package version
63
115
  __version__ = version("mermaid-trace")
@@ -67,4 +119,13 @@ except PackageNotFoundError:
67
119
 
68
120
 
69
121
  # Export public API for easy access
70
- __all__ = ["trace_interaction", "trace", "configure_flow", "MermaidFileHandler", "LogContext", "FlowEvent", "MermaidFormatter"]
122
+ __all__ = [
123
+ "trace_interaction",
124
+ "trace",
125
+ "configure_flow",
126
+ "MermaidFileHandler",
127
+ "AsyncMermaidHandler",
128
+ "LogContext",
129
+ "FlowEvent",
130
+ "MermaidFormatter",
131
+ ]
mermaid_trace/cli.py CHANGED
@@ -8,13 +8,22 @@ from pathlib import Path
8
8
  from typing import Type, Any
9
9
 
10
10
  try:
11
- from watchdog.observers import Observer
12
- from watchdog.events import FileSystemEventHandler
11
+ # Watchdog is an optional dependency that allows efficient file monitoring.
12
+ # If installed, we use it to detect file changes instantly.
13
+ from watchdog.observers import Observer
14
+ from watchdog.events import FileSystemEventHandler
15
+
13
16
  HAS_WATCHDOG = True
14
17
  except ImportError:
18
+ # Fallback for when watchdog is not installed (e.g., minimal install).
15
19
  HAS_WATCHDOG = False
16
20
 
17
21
  # HTML Template for the preview page
22
+ # This template provides a self-contained environment to render Mermaid diagrams.
23
+ # It includes:
24
+ # 1. Mermaid.js library from CDN.
25
+ # 2. CSS for basic styling and layout.
26
+ # 3. JavaScript logic for auto-refreshing when the source file changes.
18
27
  HTML_TEMPLATE = """
19
28
  <!DOCTYPE html>
20
29
  <html lang="en">
@@ -59,12 +68,15 @@ HTML_TEMPLATE = """
59
68
  mermaid.initialize({{ startOnLoad: true, theme: 'default' }});
60
69
 
61
70
  // Live Reload Logic
71
+ // We track the file's modification time (mtime) sent by the server.
62
72
  const currentMtime = "{mtime}";
63
73
 
64
74
  function checkUpdate() {{
75
+ // Poll the /_status endpoint to check if the file has changed on disk
65
76
  fetch('/_status')
66
77
  .then(response => response.text())
67
78
  .then(mtime => {{
79
+ // If the server reports a different mtime, reload the page
68
80
  if (mtime && mtime !== currentMtime) {{
69
81
  console.log("File changed, reloading...");
70
82
  location.reload();
@@ -74,20 +86,40 @@ HTML_TEMPLATE = """
74
86
  }}
75
87
 
76
88
  // Poll every 1 second
89
+ // This is a simple alternative to WebSockets for local dev tools
77
90
  setInterval(checkUpdate, 1000);
78
91
  </script>
79
92
  </body>
80
93
  </html>
81
94
  """
82
95
 
83
- def _create_handler(filename: str, path: Path) -> Type[http.server.SimpleHTTPRequestHandler]:
84
- """Helper to create the request handler class with closure over filename/path."""
96
+
97
+ def _create_handler(
98
+ filename: str, path: Path
99
+ ) -> Type[http.server.SimpleHTTPRequestHandler]:
100
+ """
101
+ Factory function to create a custom request handler class.
102
+
103
+ This uses a closure to inject `filename` and `path` into the handler's scope,
104
+ allowing the `do_GET` method to access them without global variables.
105
+
106
+ Args:
107
+ filename (str): Display name of the file.
108
+ path (Path): Path object to the file on disk.
109
+
110
+ Returns:
111
+ Type[SimpleHTTPRequestHandler]: A custom handler class.
112
+ """
113
+
85
114
  class Handler(http.server.SimpleHTTPRequestHandler):
86
115
  """
87
116
  Custom Request Handler to serve the generated HTML dynamically.
117
+ It intercepts GET requests to serve the constructed HTML instead of static files.
88
118
  """
119
+
89
120
  def log_message(self, format: str, *args: Any) -> None:
90
121
  # Suppress default logging to keep console clean
122
+ # We only want to see application logs, not every HTTP request
91
123
  pass
92
124
 
93
125
  def do_GET(self) -> None:
@@ -99,22 +131,26 @@ def _create_handler(filename: str, path: Path) -> Type[http.server.SimpleHTTPReq
99
131
  self.send_response(200)
100
132
  self.send_header("Content-type", "text/html")
101
133
  self.end_headers()
102
-
134
+
103
135
  try:
104
136
  # Read the current content of the mermaid file
105
137
  content = path.read_text(encoding="utf-8")
106
138
  mtime = str(path.stat().st_mtime)
107
139
  except Exception as e:
108
140
  # Fallback if reading fails (e.g., file locked)
141
+ # Show the error directly in the diagram area
109
142
  content = f"sequenceDiagram\nNote right of Error: Failed to read file: {e}"
110
143
  mtime = "0"
111
-
144
+
112
145
  # Inject content into the HTML template
113
- html = HTML_TEMPLATE.format(filename=filename, content=content, mtime=mtime)
146
+ html = HTML_TEMPLATE.format(
147
+ filename=filename, content=content, mtime=mtime
148
+ )
114
149
  self.wfile.write(html.encode("utf-8"))
115
-
150
+
116
151
  elif self.path == "/_status":
117
- # API endpoint for client to check file status
152
+ # API endpoint for client-side polling.
153
+ # Returns the current modification time of the file.
118
154
  self.send_response(200)
119
155
  self.send_header("Content-type", "text/plain")
120
156
  self.end_headers()
@@ -123,15 +159,29 @@ def _create_handler(filename: str, path: Path) -> Type[http.server.SimpleHTTPReq
123
159
  except OSError:
124
160
  mtime = "0"
125
161
  self.wfile.write(mtime.encode("utf-8"))
126
-
162
+
127
163
  else:
128
- # Serve static files if needed, or return 404
164
+ # Serve other static files if needed, or return 404
129
165
  super().do_GET()
166
+
130
167
  return Handler
131
168
 
169
+
132
170
  def serve(filename: str, port: int = 8000) -> None:
133
171
  """
134
172
  Starts a local HTTP server to preview the Mermaid diagram.
173
+
174
+ This function blocks the main thread and runs a TCP server.
175
+ It automatically opens the default web browser to the preview URL.
176
+
177
+ Features:
178
+ - Serves the .mmd file wrapped in an HTML viewer.
179
+ - Uses Watchdog (if available) or client-side polling for live reloads.
180
+ - Gracefully handles shutdown on Ctrl+C.
181
+
182
+ Args:
183
+ filename (str): Path to the .mmd file to serve.
184
+ port (int): Port to bind the server to. Default is 8000.
135
185
  """
136
186
  path = Path(filename)
137
187
  if not path.exists():
@@ -139,11 +189,18 @@ def serve(filename: str, port: int = 8000) -> None:
139
189
  sys.exit(1)
140
190
 
141
191
  # Setup Watchdog if available
192
+ # Watchdog allows us to print console messages when the file changes.
193
+ # The actual browser reload is triggered by the client polling the /_status endpoint,
194
+ # but Watchdog gives immediate feedback in the terminal.
142
195
  observer = None
143
196
  if HAS_WATCHDOG:
197
+
144
198
  class FileChangeHandler(FileSystemEventHandler):
145
199
  def on_modified(self, event: Any) -> None:
146
- if not event.is_directory and os.path.abspath(event.src_path) == str(path.resolve()):
200
+ # Filter for the specific file we are watching
201
+ if not event.is_directory and os.path.abspath(event.src_path) == str(
202
+ path.resolve()
203
+ ):
147
204
  print(f"[Watchdog] File changed: {filename}")
148
205
 
149
206
  print("Initializing file watcher...")
@@ -151,17 +208,21 @@ def serve(filename: str, port: int = 8000) -> None:
151
208
  observer.schedule(FileChangeHandler(), path=str(path.parent), recursive=False)
152
209
  observer.start()
153
210
  else:
154
- print("Watchdog not installed. Falling back to polling mode (client-side only).")
211
+ print(
212
+ "Watchdog not installed. Falling back to polling mode (client-side only)."
213
+ )
155
214
 
156
215
  HandlerClass = _create_handler(filename, path)
157
216
 
158
217
  print(f"Serving {filename} at http://localhost:{port}")
159
218
  print("Press Ctrl+C to stop.")
160
-
219
+
161
220
  # Open browser automatically to the server URL
162
221
  webbrowser.open(f"http://localhost:{port}")
163
-
222
+
164
223
  # Start the TCP server
224
+ # ThreadingTCPServer is used to handle multiple requests concurrently if needed,
225
+ # ensuring the browser polling doesn't block the initial load.
165
226
  with socketserver.ThreadingTCPServer(("", port), HandlerClass) as httpd:
166
227
  try:
167
228
  httpd.serve_forever()
@@ -172,22 +233,29 @@ def serve(filename: str, port: int = 8000) -> None:
172
233
  observer.join()
173
234
  httpd.server_close()
174
235
 
236
+
175
237
  def main() -> None:
176
238
  """
177
239
  Entry point for the CLI application.
240
+ Parses arguments and dispatches to the appropriate command handler.
178
241
  """
179
242
  parser = argparse.ArgumentParser(description="MermaidTrace CLI")
180
243
  subparsers = parser.add_subparsers(dest="command", required=True)
181
-
244
+
182
245
  # 'serve' command definition
183
- serve_parser = subparsers.add_parser("serve", help="Serve a Mermaid file in the browser")
246
+ serve_parser = subparsers.add_parser(
247
+ "serve", help="Serve a Mermaid file in the browser"
248
+ )
184
249
  serve_parser.add_argument("file", help="Path to the .mmd file")
185
- serve_parser.add_argument("--port", type=int, default=8000, help="Port to bind to (default: 8000)")
186
-
250
+ serve_parser.add_argument(
251
+ "--port", type=int, default=8000, help="Port to bind to (default: 8000)"
252
+ )
253
+
187
254
  args = parser.parse_args()
188
-
255
+
189
256
  if args.command == "serve":
190
257
  serve(args.file, args.port)
191
258
 
259
+
192
260
  if __name__ == "__main__":
193
261
  main()
@@ -3,17 +3,18 @@ from contextlib import asynccontextmanager, contextmanager
3
3
  from typing import Any, AsyncIterator, Dict, Iterator
4
4
  import uuid
5
5
 
6
+
6
7
  class LogContext:
7
8
  """
8
9
  Manages global context information for logging (e.g., request_id, user_id, current_participant).
9
-
10
- This class utilizes `contextvars.ContextVar` to ensure thread-safety and
10
+
11
+ This class utilizes `contextvars.ContextVar` to ensure thread-safety and
11
12
  correct context propagation in asynchronous (asyncio) environments.
12
- Unlike `threading.local()`, `ContextVar` works natively with Python's async/await
13
- event loop, ensuring that context is preserved across `await` points but isolated
13
+ Unlike `threading.local()`, `ContextVar` works natively with Python's async/await
14
+ event loop, ensuring that context is preserved across `await` points but isolated
14
15
  between different concurrent tasks.
15
16
  """
16
-
17
+
17
18
  # ContextVar is the key mechanism here.
18
19
  # It stores a dictionary unique to the current execution context (Task/Thread).
19
20
  # "log_context" is the name of the variable, useful for debugging.
@@ -24,9 +25,9 @@ class LogContext:
24
25
  def _get_store(cls) -> Dict[str, Any]:
25
26
  """
26
27
  Retrieves the current context dictionary.
27
-
28
- If the context variable has not been set in the current context,
29
- it returns a fresh empty dictionary. This prevents LookupError
28
+
29
+ If the context variable has not been set in the current context,
30
+ it returns a fresh empty dictionary. This prevents LookupError
30
31
  and ensures there's always a valid dictionary to work with.
31
32
  """
32
33
  try:
@@ -38,8 +39,8 @@ class LogContext:
38
39
  def set(cls, key: str, value: Any) -> None:
39
40
  """
40
41
  Sets a specific key-value pair in the current context.
41
-
42
- Important: ContextVars are immutable collections. To modify the context,
42
+
43
+ Important: ContextVars are immutable collections. To modify the context,
43
44
  we must:
44
45
  1. Retrieve the current dictionary.
45
46
  2. Create a shallow copy (to avoid affecting parent contexts if we were reusing the object).
@@ -54,8 +55,8 @@ class LogContext:
54
55
  def update(cls, data: Dict[str, Any]) -> None:
55
56
  """
56
57
  Updates multiple keys in the current context at once.
57
-
58
- This follows the same Copy-Update-Set pattern as `set()` to maintain
58
+
59
+ This follows the same Copy-Update-Set pattern as `set()` to maintain
59
60
  context isolation.
60
61
  """
61
62
  if not data:
@@ -83,18 +84,18 @@ class LogContext:
83
84
  def scope(cls, data: Dict[str, Any]) -> Iterator[None]:
84
85
  """
85
86
  Synchronous context manager for temporary context updates.
86
-
87
+
87
88
  Usage:
88
89
  with LogContext.scope({"user_id": 123}):
89
90
  # user_id is 123 here
90
91
  some_function()
91
92
  # user_id reverts to previous value (or disappears) here
92
-
93
+
93
94
  Mechanism:
94
95
  1. Copies current context and updates it with new data.
95
96
  2. Sets the ContextVar to this new state, receiving a `Token`.
96
97
  3. Yields control to the block.
97
- 4. Finally, uses the `Token` to reset the ContextVar to its exact state
98
+ 4. Finally, uses the `Token` to reset the ContextVar to its exact state
98
99
  before the block entered.
99
100
  """
100
101
  current_ctx = cls._get_store().copy()
@@ -111,11 +112,11 @@ class LogContext:
111
112
  async def ascope(cls, data: Dict[str, Any]) -> AsyncIterator[None]:
112
113
  """
113
114
  Async context manager for temporary context updates in coroutines.
114
-
115
+
115
116
  Usage:
116
117
  async with LogContext.ascope({"request_id": "abc"}):
117
118
  await some_async_function()
118
-
119
+
119
120
  This is functionally identical to `scope` but designed for `async with` blocks.
120
121
  It ensures that even if the code inside `yield` suspends execution (await),
121
122
  the context remains valid for that task.
@@ -165,12 +166,12 @@ class LogContext:
165
166
  def current_trace_id(cls) -> str:
166
167
  """
167
168
  Retrieves the current trace ID for correlating events in a single flow.
168
-
169
+
169
170
  Lazy Initialization Logic:
170
- If no trace_id exists in the current context, it generates a new UUIDv4
171
+ If no trace_id exists in the current context, it generates a new UUIDv4
171
172
  and sets it immediately. This ensures that:
172
173
  1. A trace ID is always available when asked for.
173
- 2. Once generated, the same ID persists for the duration of the context
174
+ 2. Once generated, the same ID persists for the duration of the context
174
175
  (unless manually changed), linking all subsequent logs together.
175
176
  """
176
177
  tid = cls.get("trace_id")