mithra-flow 1.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.
- mithra_flow/__init__.py +5 -0
- mithra_flow/decorator.py +639 -0
- mithra_flow/models.py +37 -0
- mithra_flow/version.py +1 -0
- mithra_flow-1.0.dist-info/METADATA +723 -0
- mithra_flow-1.0.dist-info/RECORD +7 -0
- mithra_flow-1.0.dist-info/WHEEL +4 -0
mithra_flow/__init__.py
ADDED
mithra_flow/decorator.py
ADDED
|
@@ -0,0 +1,639 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import contextvars
|
|
4
|
+
import dis
|
|
5
|
+
import fnmatch
|
|
6
|
+
import functools
|
|
7
|
+
import inspect
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import sys
|
|
11
|
+
import time
|
|
12
|
+
from collections.abc import Callable, Iterable
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from types import FrameType
|
|
16
|
+
from typing import Any, Literal, TypeVar, overload
|
|
17
|
+
|
|
18
|
+
from rich.console import Console
|
|
19
|
+
from rich.text import Text
|
|
20
|
+
from rich.tree import Tree
|
|
21
|
+
|
|
22
|
+
from .models import TraceNode
|
|
23
|
+
from .version import __version__
|
|
24
|
+
|
|
25
|
+
F = TypeVar("F", bound=Callable[..., Any])
|
|
26
|
+
OutputFormat = Literal["terminal", "dict", "json", "mermaid", "none"]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class MFlowResult:
|
|
31
|
+
value: Any
|
|
32
|
+
trace: dict[str, Any] | str | None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class _TraceConfig:
|
|
37
|
+
title: str | None = None
|
|
38
|
+
include: tuple[str, ...] = ()
|
|
39
|
+
exclude: tuple[str, ...] = ()
|
|
40
|
+
enabled: bool = True
|
|
41
|
+
min_duration_ms: float = 0.0
|
|
42
|
+
max_depth: int | None = None
|
|
43
|
+
show_args: bool = False
|
|
44
|
+
show_return: bool = False
|
|
45
|
+
show_file: bool = False
|
|
46
|
+
show_line: bool = False
|
|
47
|
+
on_error: bool = False
|
|
48
|
+
output: OutputFormat = "terminal"
|
|
49
|
+
save_to: str | None = None
|
|
50
|
+
return_trace: bool = False
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass
|
|
54
|
+
class _TraceState:
|
|
55
|
+
config: _TraceConfig
|
|
56
|
+
stack: list[TraceNode] = field(default_factory=list)
|
|
57
|
+
frame_nodes: dict[int, TraceNode] = field(default_factory=dict)
|
|
58
|
+
root: TraceNode | None = None
|
|
59
|
+
previous_profile: Callable[[FrameType, str, Any], Any] | None = None
|
|
60
|
+
had_error: bool = False
|
|
61
|
+
exported_trace: dict[str, Any] | str | None = None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
_active_trace: contextvars.ContextVar[_TraceState | None] = contextvars.ContextVar(
|
|
65
|
+
"mithra_flow_active_trace",
|
|
66
|
+
default=None,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@overload
|
|
71
|
+
def mflow(func: F) -> F:
|
|
72
|
+
...
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@overload
|
|
76
|
+
def mflow(
|
|
77
|
+
*,
|
|
78
|
+
name: str | None = None,
|
|
79
|
+
title: str | None = None,
|
|
80
|
+
include: Iterable[str] | None = None,
|
|
81
|
+
exclude: Iterable[str] | None = None,
|
|
82
|
+
enabled: bool | None = None,
|
|
83
|
+
min_duration_ms: float = 0.0,
|
|
84
|
+
max_depth: int | None = None,
|
|
85
|
+
show_args: bool = False,
|
|
86
|
+
show_return: bool = False,
|
|
87
|
+
show_file: bool = False,
|
|
88
|
+
show_line: bool = False,
|
|
89
|
+
on_error: bool = False,
|
|
90
|
+
output: OutputFormat = "terminal",
|
|
91
|
+
save_to: str | None = None,
|
|
92
|
+
return_trace: bool = False,
|
|
93
|
+
) -> Callable[[F], F]:
|
|
94
|
+
...
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def mflow(
|
|
98
|
+
func: F | None = None,
|
|
99
|
+
*,
|
|
100
|
+
name: str | None = None,
|
|
101
|
+
title: str | None = None,
|
|
102
|
+
include: Iterable[str] | None = None,
|
|
103
|
+
exclude: Iterable[str] | None = None,
|
|
104
|
+
enabled: bool | None = None,
|
|
105
|
+
min_duration_ms: float = 0.0,
|
|
106
|
+
max_depth: int | None = None,
|
|
107
|
+
show_args: bool = False,
|
|
108
|
+
show_return: bool = False,
|
|
109
|
+
show_file: bool = False,
|
|
110
|
+
show_line: bool = False,
|
|
111
|
+
on_error: bool = False,
|
|
112
|
+
output: OutputFormat = "terminal",
|
|
113
|
+
save_to: str | None = None,
|
|
114
|
+
return_trace: bool = False,
|
|
115
|
+
) -> F | Callable[[F], F]:
|
|
116
|
+
"""Trace project calls made while the decorated function executes."""
|
|
117
|
+
|
|
118
|
+
config = _make_config(
|
|
119
|
+
name=name,
|
|
120
|
+
title=title,
|
|
121
|
+
include=include,
|
|
122
|
+
exclude=exclude,
|
|
123
|
+
enabled=enabled,
|
|
124
|
+
min_duration_ms=min_duration_ms,
|
|
125
|
+
max_depth=max_depth,
|
|
126
|
+
show_args=show_args,
|
|
127
|
+
show_return=show_return,
|
|
128
|
+
show_file=show_file,
|
|
129
|
+
show_line=show_line,
|
|
130
|
+
on_error=on_error,
|
|
131
|
+
output=output,
|
|
132
|
+
save_to=save_to,
|
|
133
|
+
return_trace=return_trace,
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
def decorate(target: F) -> F:
|
|
137
|
+
if inspect.iscoroutinefunction(target):
|
|
138
|
+
|
|
139
|
+
@functools.wraps(target)
|
|
140
|
+
async def async_wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
141
|
+
if not config.enabled:
|
|
142
|
+
return await target(*args, **kwargs)
|
|
143
|
+
state, token = _start_trace(config)
|
|
144
|
+
try:
|
|
145
|
+
value = await target(*args, **kwargs)
|
|
146
|
+
_finish_trace(state)
|
|
147
|
+
return _return_value(value, state)
|
|
148
|
+
except Exception as error:
|
|
149
|
+
_mark_error(state, error)
|
|
150
|
+
_finish_trace(state)
|
|
151
|
+
raise
|
|
152
|
+
finally:
|
|
153
|
+
_active_trace.reset(token)
|
|
154
|
+
|
|
155
|
+
return async_wrapper # type: ignore[return-value]
|
|
156
|
+
|
|
157
|
+
@functools.wraps(target)
|
|
158
|
+
def sync_wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
159
|
+
if not config.enabled:
|
|
160
|
+
return target(*args, **kwargs)
|
|
161
|
+
state, token = _start_trace(config)
|
|
162
|
+
try:
|
|
163
|
+
value = target(*args, **kwargs)
|
|
164
|
+
_finish_trace(state)
|
|
165
|
+
return _return_value(value, state)
|
|
166
|
+
except Exception as error:
|
|
167
|
+
_mark_error(state, error)
|
|
168
|
+
_finish_trace(state)
|
|
169
|
+
raise
|
|
170
|
+
finally:
|
|
171
|
+
_active_trace.reset(token)
|
|
172
|
+
|
|
173
|
+
return sync_wrapper # type: ignore[return-value]
|
|
174
|
+
|
|
175
|
+
if func is None:
|
|
176
|
+
return decorate
|
|
177
|
+
|
|
178
|
+
return decorate(func)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
class trace:
|
|
182
|
+
def __init__(
|
|
183
|
+
self,
|
|
184
|
+
name: str = "manual trace",
|
|
185
|
+
*,
|
|
186
|
+
include: Iterable[str] | None = None,
|
|
187
|
+
exclude: Iterable[str] | None = None,
|
|
188
|
+
enabled: bool | None = None,
|
|
189
|
+
min_duration_ms: float = 0.0,
|
|
190
|
+
max_depth: int | None = None,
|
|
191
|
+
show_args: bool = False,
|
|
192
|
+
show_return: bool = False,
|
|
193
|
+
show_file: bool = False,
|
|
194
|
+
show_line: bool = False,
|
|
195
|
+
on_error: bool = False,
|
|
196
|
+
output: OutputFormat = "terminal",
|
|
197
|
+
save_to: str | None = None,
|
|
198
|
+
return_trace: bool = False,
|
|
199
|
+
) -> None:
|
|
200
|
+
self.config = _make_config(
|
|
201
|
+
name=name,
|
|
202
|
+
title=name,
|
|
203
|
+
include=include,
|
|
204
|
+
exclude=exclude,
|
|
205
|
+
enabled=enabled,
|
|
206
|
+
min_duration_ms=min_duration_ms,
|
|
207
|
+
max_depth=max_depth,
|
|
208
|
+
show_args=show_args,
|
|
209
|
+
show_return=show_return,
|
|
210
|
+
show_file=show_file,
|
|
211
|
+
show_line=show_line,
|
|
212
|
+
on_error=on_error,
|
|
213
|
+
output=output,
|
|
214
|
+
save_to=save_to,
|
|
215
|
+
return_trace=return_trace,
|
|
216
|
+
)
|
|
217
|
+
self.state: _TraceState | None = None
|
|
218
|
+
self.token: contextvars.Token[_TraceState | None] | None = None
|
|
219
|
+
self.result: dict[str, Any] | str | None = None
|
|
220
|
+
|
|
221
|
+
def __enter__(self) -> trace:
|
|
222
|
+
if self.config.enabled:
|
|
223
|
+
self.state, self.token = _start_trace(self.config, root_name=self.config.title)
|
|
224
|
+
return self
|
|
225
|
+
|
|
226
|
+
def __exit__(self, exc_type: Any, exc: BaseException | None, traceback: Any) -> None:
|
|
227
|
+
if self.state is None or self.token is None:
|
|
228
|
+
return
|
|
229
|
+
if exc is not None:
|
|
230
|
+
_mark_error(self.state, exc)
|
|
231
|
+
_finish_trace(self.state)
|
|
232
|
+
self.result = self.state.exported_trace
|
|
233
|
+
_active_trace.reset(self.token)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
class span:
|
|
237
|
+
def __init__(self, name: str) -> None:
|
|
238
|
+
self.name = name
|
|
239
|
+
self.node: TraceNode | None = None
|
|
240
|
+
|
|
241
|
+
def __enter__(self) -> span:
|
|
242
|
+
state = _active_trace.get()
|
|
243
|
+
if state is None:
|
|
244
|
+
return self
|
|
245
|
+
now = time.perf_counter()
|
|
246
|
+
self.node = TraceNode(
|
|
247
|
+
name=f"{self.name}()",
|
|
248
|
+
filename="<manual>",
|
|
249
|
+
lineno=0,
|
|
250
|
+
start=now,
|
|
251
|
+
is_span=True,
|
|
252
|
+
)
|
|
253
|
+
if state.stack:
|
|
254
|
+
state.stack[-1].children.append(self.node)
|
|
255
|
+
elif state.root is None:
|
|
256
|
+
state.root = self.node
|
|
257
|
+
state.stack.append(self.node)
|
|
258
|
+
return self
|
|
259
|
+
|
|
260
|
+
def __exit__(self, exc_type: Any, exc: BaseException | None, traceback: Any) -> None:
|
|
261
|
+
state = _active_trace.get()
|
|
262
|
+
if state is None or self.node is None:
|
|
263
|
+
return
|
|
264
|
+
if exc is not None:
|
|
265
|
+
self.node.exception = f"{type(exc).__name__}: {exc}"
|
|
266
|
+
state.had_error = True
|
|
267
|
+
self.node.end = time.perf_counter()
|
|
268
|
+
if state.stack and state.stack[-1] is self.node:
|
|
269
|
+
state.stack.pop()
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _make_config(
|
|
273
|
+
*,
|
|
274
|
+
name: str | None,
|
|
275
|
+
title: str | None,
|
|
276
|
+
include: Iterable[str] | None,
|
|
277
|
+
exclude: Iterable[str] | None,
|
|
278
|
+
enabled: bool | None,
|
|
279
|
+
min_duration_ms: float,
|
|
280
|
+
max_depth: int | None,
|
|
281
|
+
show_args: bool,
|
|
282
|
+
show_return: bool,
|
|
283
|
+
show_file: bool,
|
|
284
|
+
show_line: bool,
|
|
285
|
+
on_error: bool,
|
|
286
|
+
output: OutputFormat,
|
|
287
|
+
save_to: str | None,
|
|
288
|
+
return_trace: bool,
|
|
289
|
+
) -> _TraceConfig:
|
|
290
|
+
env_enabled = os.getenv("MITHRA_FLOW", "1").lower() not in {"0", "false", "no", "off"}
|
|
291
|
+
return _TraceConfig(
|
|
292
|
+
title=title or name,
|
|
293
|
+
include=_normalize_filters(include),
|
|
294
|
+
exclude=_normalize_filters(exclude),
|
|
295
|
+
enabled=env_enabled if enabled is None else enabled,
|
|
296
|
+
min_duration_ms=max(0.0, min_duration_ms),
|
|
297
|
+
max_depth=max_depth,
|
|
298
|
+
show_args=show_args,
|
|
299
|
+
show_return=show_return,
|
|
300
|
+
show_file=show_file,
|
|
301
|
+
show_line=show_line,
|
|
302
|
+
on_error=on_error,
|
|
303
|
+
output=output,
|
|
304
|
+
save_to=save_to,
|
|
305
|
+
return_trace=return_trace,
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _normalize_filters(filters: Iterable[str] | None) -> tuple[str, ...]:
|
|
310
|
+
return tuple(str(item) for item in filters or () if str(item))
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _start_trace(
|
|
314
|
+
config: _TraceConfig,
|
|
315
|
+
*,
|
|
316
|
+
root_name: str | None = None,
|
|
317
|
+
) -> tuple[_TraceState, contextvars.Token[_TraceState | None]]:
|
|
318
|
+
state = _TraceState(config=config)
|
|
319
|
+
token = _active_trace.set(state)
|
|
320
|
+
if root_name is not None:
|
|
321
|
+
now = time.perf_counter()
|
|
322
|
+
root = TraceNode(root_name, "<manual>", 0, now, is_span=True)
|
|
323
|
+
state.root = root
|
|
324
|
+
state.stack.append(root)
|
|
325
|
+
_install_profile(state)
|
|
326
|
+
return state, token
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _install_profile(state: _TraceState) -> None:
|
|
330
|
+
state.previous_profile = sys.getprofile()
|
|
331
|
+
sys.setprofile(_profile)
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _finish_trace(state: _TraceState) -> None:
|
|
335
|
+
sys.setprofile(state.previous_profile)
|
|
336
|
+
now = time.perf_counter()
|
|
337
|
+
while state.stack:
|
|
338
|
+
node = state.stack.pop()
|
|
339
|
+
if node.end is None:
|
|
340
|
+
node.end = now
|
|
341
|
+
if state.root is None:
|
|
342
|
+
return
|
|
343
|
+
state.exported_trace = _export_trace(state)
|
|
344
|
+
_save_trace(state)
|
|
345
|
+
if state.config.on_error and not state.had_error:
|
|
346
|
+
return
|
|
347
|
+
if state.config.output == "terminal":
|
|
348
|
+
_print_trace(state.root, state.config)
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def _return_value(value: Any, state: _TraceState) -> Any:
|
|
352
|
+
if state.config.return_trace:
|
|
353
|
+
return MFlowResult(value=value, trace=state.exported_trace)
|
|
354
|
+
if state.config.output == "dict":
|
|
355
|
+
return state.exported_trace
|
|
356
|
+
if state.config.output in {"json", "mermaid"}:
|
|
357
|
+
return state.exported_trace
|
|
358
|
+
return value
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def _profile(frame: FrameType, event: str, arg: Any) -> None:
|
|
362
|
+
state = _active_trace.get()
|
|
363
|
+
if state is None:
|
|
364
|
+
return
|
|
365
|
+
|
|
366
|
+
if event == "call":
|
|
367
|
+
_handle_call(state, frame)
|
|
368
|
+
return
|
|
369
|
+
|
|
370
|
+
if event == "return":
|
|
371
|
+
_handle_return(state, frame, arg)
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def _handle_call(state: _TraceState, frame: FrameType) -> None:
|
|
375
|
+
if not _should_trace_frame(state, frame):
|
|
376
|
+
return
|
|
377
|
+
|
|
378
|
+
frame_id = id(frame)
|
|
379
|
+
if frame_id in state.frame_nodes:
|
|
380
|
+
state.stack.append(state.frame_nodes[frame_id])
|
|
381
|
+
return
|
|
382
|
+
|
|
383
|
+
code = frame.f_code
|
|
384
|
+
node = TraceNode(
|
|
385
|
+
name=f"{code.co_name}()",
|
|
386
|
+
filename=os.path.abspath(code.co_filename),
|
|
387
|
+
lineno=code.co_firstlineno,
|
|
388
|
+
start=time.perf_counter(),
|
|
389
|
+
args=_format_args(frame) if state.config.show_args else None,
|
|
390
|
+
)
|
|
391
|
+
|
|
392
|
+
if state.stack:
|
|
393
|
+
state.stack[-1].children.append(node)
|
|
394
|
+
elif state.root is None:
|
|
395
|
+
state.root = node
|
|
396
|
+
else:
|
|
397
|
+
return
|
|
398
|
+
|
|
399
|
+
state.stack.append(node)
|
|
400
|
+
state.frame_nodes[frame_id] = node
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def _handle_return(state: _TraceState, frame: FrameType, arg: Any) -> None:
|
|
404
|
+
if not state.stack:
|
|
405
|
+
return
|
|
406
|
+
|
|
407
|
+
code = frame.f_code
|
|
408
|
+
if state.stack[-1].filename != os.path.abspath(code.co_filename):
|
|
409
|
+
return
|
|
410
|
+
if state.stack[-1].lineno != code.co_firstlineno:
|
|
411
|
+
return
|
|
412
|
+
if state.stack[-1].name != f"{code.co_name}()":
|
|
413
|
+
return
|
|
414
|
+
|
|
415
|
+
if _is_coroutine_suspension(frame):
|
|
416
|
+
state.stack.pop()
|
|
417
|
+
return
|
|
418
|
+
|
|
419
|
+
node = state.stack[-1]
|
|
420
|
+
if state.config.show_return:
|
|
421
|
+
node.return_value = _safe_repr(arg)
|
|
422
|
+
node.end = time.perf_counter()
|
|
423
|
+
state.stack.pop()
|
|
424
|
+
state.frame_nodes.pop(id(frame), None)
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def _mark_error(state: _TraceState, error: BaseException) -> None:
|
|
428
|
+
state.had_error = True
|
|
429
|
+
message = f"{type(error).__name__}: {error}"
|
|
430
|
+
if state.stack:
|
|
431
|
+
state.stack[-1].exception = message
|
|
432
|
+
elif state.root is not None:
|
|
433
|
+
state.root.exception = message
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def _should_trace_frame(state: _TraceState, frame: FrameType) -> bool:
|
|
437
|
+
filename = os.path.abspath(frame.f_code.co_filename)
|
|
438
|
+
module = frame.f_globals.get("__name__", "")
|
|
439
|
+
target = f"{module}:{frame.f_code.co_name}:{filename}"
|
|
440
|
+
|
|
441
|
+
if _matches(target, state.config.exclude):
|
|
442
|
+
return False
|
|
443
|
+
|
|
444
|
+
if state.config.include:
|
|
445
|
+
return _matches(target, state.config.include)
|
|
446
|
+
|
|
447
|
+
return not _is_external_frame(filename)
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def _matches(value: str, filters: tuple[str, ...]) -> bool:
|
|
451
|
+
return any(item in value or fnmatch.fnmatch(value, item) for item in filters)
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def _is_external_frame(filename: str) -> bool:
|
|
455
|
+
if filename.startswith("<"):
|
|
456
|
+
return True
|
|
457
|
+
|
|
458
|
+
cwd = os.path.abspath(os.getcwd())
|
|
459
|
+
return not os.path.abspath(filename).startswith(cwd + os.sep)
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def _is_coroutine_suspension(frame: FrameType) -> bool:
|
|
463
|
+
if not frame.f_code.co_flags & inspect.CO_COROUTINE:
|
|
464
|
+
return False
|
|
465
|
+
if frame.f_lasti < 0 or frame.f_lasti >= len(frame.f_code.co_code):
|
|
466
|
+
return False
|
|
467
|
+
return dis.opname[frame.f_code.co_code[frame.f_lasti]] == "YIELD_VALUE"
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
def _print_trace(root: TraceNode, config: _TraceConfig) -> None:
|
|
471
|
+
console = Console()
|
|
472
|
+
banner = _banner(config.title)
|
|
473
|
+
console.print()
|
|
474
|
+
console.print(Text(banner, style="dim bold cyan"))
|
|
475
|
+
tree = Tree(_format_node(root, config, depth=0), guide_style="bold bright_blue")
|
|
476
|
+
_append_children(tree, root, config, depth=1)
|
|
477
|
+
console.print(tree)
|
|
478
|
+
console.print(Text("─" * len(banner), style="dim cyan"))
|
|
479
|
+
console.print()
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def _banner(title: str | None = None) -> str:
|
|
483
|
+
label = title or "MITHRA FLOW"
|
|
484
|
+
text = f"{label} v{__version__}"
|
|
485
|
+
width = max(36, len(text) + 4)
|
|
486
|
+
side = max(1, (width - len(text) - 2) // 2)
|
|
487
|
+
line = "─" * side
|
|
488
|
+
banner = f"{line} {text} {line}"
|
|
489
|
+
if len(banner) < width:
|
|
490
|
+
banner += "─" * (width - len(banner))
|
|
491
|
+
return banner
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def _append_children(tree: Tree, node: TraceNode, config: _TraceConfig, depth: int) -> None:
|
|
495
|
+
if config.max_depth is not None and depth > config.max_depth:
|
|
496
|
+
return
|
|
497
|
+
for child in node.children:
|
|
498
|
+
if not _should_show_node(child, config, depth):
|
|
499
|
+
continue
|
|
500
|
+
branch = tree.add(_format_node(child, config, depth=depth), guide_style=_level_style(depth))
|
|
501
|
+
_append_children(branch, child, config, depth=depth + 1)
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def _should_show_node(node: TraceNode, config: _TraceConfig, depth: int) -> bool:
|
|
505
|
+
if config.max_depth is not None and depth > config.max_depth:
|
|
506
|
+
return False
|
|
507
|
+
if node.exception:
|
|
508
|
+
return True
|
|
509
|
+
if node.duration_ms >= config.min_duration_ms:
|
|
510
|
+
return True
|
|
511
|
+
return any(_should_show_node(child, config, depth + 1) for child in node.children)
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
def _format_node(node: TraceNode, config: _TraceConfig, depth: int) -> Text:
|
|
515
|
+
level_style = "red" if node.exception else _level_style(depth)
|
|
516
|
+
label = Text()
|
|
517
|
+
label.append("◆ " if node.is_span else "● ", style=level_style)
|
|
518
|
+
label.append(node.name, style=f"bold {level_style}")
|
|
519
|
+
if node.args:
|
|
520
|
+
label.append(node.args, style="dim")
|
|
521
|
+
label.append(" ")
|
|
522
|
+
label.append(f"{node.duration_ms:.2f} ms", style="yellow")
|
|
523
|
+
if config.show_return and node.return_value is not None:
|
|
524
|
+
label.append(f" -> {node.return_value}", style="dim green")
|
|
525
|
+
if node.exception:
|
|
526
|
+
label.append(f" ! {node.exception}", style="red")
|
|
527
|
+
location = _format_location(node, config)
|
|
528
|
+
if location:
|
|
529
|
+
label.append(f" {location}", style="dim")
|
|
530
|
+
return label
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
def _format_location(node: TraceNode, config: _TraceConfig) -> str:
|
|
534
|
+
if not config.show_file and not config.show_line:
|
|
535
|
+
return ""
|
|
536
|
+
if node.filename == "<manual>":
|
|
537
|
+
return "<manual>"
|
|
538
|
+
location = Path(node.filename).name if config.show_file else ""
|
|
539
|
+
if config.show_line:
|
|
540
|
+
location = f"{location}:{node.lineno}" if location else f":{node.lineno}"
|
|
541
|
+
return location
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
def _export_trace(state: _TraceState) -> dict[str, Any] | str | None:
|
|
545
|
+
if state.root is None:
|
|
546
|
+
return None
|
|
547
|
+
data = _node_to_dict(state.root, state.config, depth=0)
|
|
548
|
+
if state.config.output == "json":
|
|
549
|
+
return json.dumps(data, indent=2)
|
|
550
|
+
if state.config.output == "mermaid":
|
|
551
|
+
return _to_mermaid(state.root, state.config)
|
|
552
|
+
return data
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
def _node_to_dict(node: TraceNode, config: _TraceConfig, depth: int) -> dict[str, Any]:
|
|
556
|
+
data = node.to_dict()
|
|
557
|
+
if not config.show_args:
|
|
558
|
+
data.pop("args", None)
|
|
559
|
+
if not config.show_return:
|
|
560
|
+
data.pop("return_value", None)
|
|
561
|
+
if not config.show_file:
|
|
562
|
+
data.pop("filename", None)
|
|
563
|
+
if not config.show_line:
|
|
564
|
+
data.pop("lineno", None)
|
|
565
|
+
children = []
|
|
566
|
+
if config.max_depth is None or depth < config.max_depth:
|
|
567
|
+
for child in node.children:
|
|
568
|
+
if _should_show_node(child, config, depth + 1):
|
|
569
|
+
children.append(_node_to_dict(child, config, depth + 1))
|
|
570
|
+
data["children"] = children
|
|
571
|
+
return data
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
def _to_mermaid(root: TraceNode, config: _TraceConfig) -> str:
|
|
575
|
+
lines = ["graph TD"]
|
|
576
|
+
ids: dict[int, str] = {}
|
|
577
|
+
|
|
578
|
+
def walk(node: TraceNode, depth: int) -> str:
|
|
579
|
+
node_id = ids.setdefault(id(node), f"N{len(ids)}")
|
|
580
|
+
lines.append(f' {node_id}["{node.name} {node.duration_ms:.2f} ms"]')
|
|
581
|
+
if config.max_depth is not None and depth >= config.max_depth:
|
|
582
|
+
return node_id
|
|
583
|
+
for child in node.children:
|
|
584
|
+
if not _should_show_node(child, config, depth + 1):
|
|
585
|
+
continue
|
|
586
|
+
child_id = walk(child, depth + 1)
|
|
587
|
+
lines.append(f" {node_id} --> {child_id}")
|
|
588
|
+
return node_id
|
|
589
|
+
|
|
590
|
+
walk(root, 0)
|
|
591
|
+
return "\n".join(lines)
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
def _save_trace(state: _TraceState) -> None:
|
|
595
|
+
if not state.config.save_to or state.exported_trace is None:
|
|
596
|
+
return
|
|
597
|
+
path = Path(state.config.save_to)
|
|
598
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
599
|
+
if isinstance(state.exported_trace, str):
|
|
600
|
+
path.write_text(state.exported_trace, encoding="utf-8")
|
|
601
|
+
return
|
|
602
|
+
path.write_text(json.dumps(state.exported_trace, indent=2), encoding="utf-8")
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
def _format_args(frame: FrameType) -> str:
|
|
606
|
+
try:
|
|
607
|
+
args = inspect.getargvalues(frame)
|
|
608
|
+
except Exception:
|
|
609
|
+
return "()"
|
|
610
|
+
parts = []
|
|
611
|
+
for name in args.args:
|
|
612
|
+
parts.append(f"{name}={_safe_repr(args.locals.get(name))}")
|
|
613
|
+
if args.varargs:
|
|
614
|
+
parts.append(f"*{args.varargs}={_safe_repr(args.locals.get(args.varargs))}")
|
|
615
|
+
if args.keywords:
|
|
616
|
+
parts.append(f"**{args.keywords}={_safe_repr(args.locals.get(args.keywords))}")
|
|
617
|
+
return f"({', '.join(parts)})"
|
|
618
|
+
|
|
619
|
+
|
|
620
|
+
def _safe_repr(value: Any, limit: int = 80) -> str:
|
|
621
|
+
try:
|
|
622
|
+
text = repr(value)
|
|
623
|
+
except Exception:
|
|
624
|
+
text = f"<{type(value).__name__}>"
|
|
625
|
+
if len(text) > limit:
|
|
626
|
+
return text[: limit - 3] + "..."
|
|
627
|
+
return text
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
def _level_style(depth: int) -> str:
|
|
631
|
+
styles = (
|
|
632
|
+
"bright_cyan",
|
|
633
|
+
"bright_green",
|
|
634
|
+
"bright_magenta",
|
|
635
|
+
"bright_yellow",
|
|
636
|
+
"bright_blue",
|
|
637
|
+
"bright_red",
|
|
638
|
+
)
|
|
639
|
+
return styles[depth % len(styles)]
|
mithra_flow/models.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass
|
|
8
|
+
class TraceNode:
|
|
9
|
+
name: str
|
|
10
|
+
filename: str
|
|
11
|
+
lineno: int
|
|
12
|
+
start: float
|
|
13
|
+
end: float | None = None
|
|
14
|
+
args: str | None = None
|
|
15
|
+
return_value: str | None = None
|
|
16
|
+
exception: str | None = None
|
|
17
|
+
is_span: bool = False
|
|
18
|
+
children: list["TraceNode"] = field(default_factory=list)
|
|
19
|
+
|
|
20
|
+
@property
|
|
21
|
+
def duration_ms(self) -> float:
|
|
22
|
+
if self.end is None:
|
|
23
|
+
return 0.0
|
|
24
|
+
return (self.end - self.start) * 1000
|
|
25
|
+
|
|
26
|
+
def to_dict(self) -> dict[str, Any]:
|
|
27
|
+
return {
|
|
28
|
+
"name": self.name,
|
|
29
|
+
"filename": self.filename,
|
|
30
|
+
"lineno": self.lineno,
|
|
31
|
+
"duration_ms": round(self.duration_ms, 3),
|
|
32
|
+
"args": self.args,
|
|
33
|
+
"return_value": self.return_value,
|
|
34
|
+
"exception": self.exception,
|
|
35
|
+
"is_span": self.is_span,
|
|
36
|
+
"children": [child.to_dict() for child in self.children],
|
|
37
|
+
}
|
mithra_flow/version.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "1.0"
|
|
@@ -0,0 +1,723 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: mithra-flow
|
|
3
|
+
Version: 1.0
|
|
4
|
+
Summary: Trace nested project function calls from decorated Python functions.
|
|
5
|
+
Project-URL: Homepage, https://github.com/MITHUNMITS/mithra-flow
|
|
6
|
+
Project-URL: Repository, https://github.com/MITHUNMITS/mithra-flow
|
|
7
|
+
Project-URL: Issues, https://github.com/MITHUNMITS/mithra-flow/issues
|
|
8
|
+
Author: MITHUNMITS
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Topic :: Software Development :: Debuggers
|
|
21
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
22
|
+
Requires-Python: >=3.9
|
|
23
|
+
Requires-Dist: rich>=13.0.0
|
|
24
|
+
Provides-Extra: examples
|
|
25
|
+
Requires-Dist: fastapi>=0.115.0; extra == 'examples'
|
|
26
|
+
Requires-Dist: uvicorn>=0.30.0; extra == 'examples'
|
|
27
|
+
Provides-Extra: test
|
|
28
|
+
Requires-Dist: pytest-asyncio>=0.23.0; extra == 'test'
|
|
29
|
+
Requires-Dist: pytest>=8.0.0; extra == 'test'
|
|
30
|
+
Description-Content-Type: text/markdown
|
|
31
|
+
|
|
32
|
+
# mithra-flow
|
|
33
|
+
|
|
34
|
+
`mithra-flow` traces nested Python function calls while a decorated function runs.
|
|
35
|
+
It is built for terminal-first debugging: no web UI, no database, no framework lock-in.
|
|
36
|
+
|
|
37
|
+
```text
|
|
38
|
+
──────── MITHRA FLOW v1.0 ─────────
|
|
39
|
+
● basic_trace() 43.43 ms
|
|
40
|
+
┗━━ ● parent_flow() 43.42 ms
|
|
41
|
+
┣━━ ● child_one() 32.17 ms
|
|
42
|
+
┃ ┣━━ ● grandchild_a() 11.90 ms
|
|
43
|
+
┃ ┗━━ ● grandchild_b() 20.25 ms
|
|
44
|
+
┗━━ ● child_two() 11.24 ms
|
|
45
|
+
┗━━ ● grandchild_c() 11.23 ms
|
|
46
|
+
────────────────────────────────────
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## What It Does
|
|
50
|
+
|
|
51
|
+
- Traces sync and async decorated functions.
|
|
52
|
+
- Captures nested project-local child function calls with `sys.setprofile`.
|
|
53
|
+
- Isolates each trace with `contextvars`.
|
|
54
|
+
- Measures duration with `time.perf_counter`.
|
|
55
|
+
- Prints a colored Rich tree in the terminal.
|
|
56
|
+
- Supports filters, depth limits, duration thresholds, args, return values, errors, JSON, Mermaid, files, manual spans, and context manager traces.
|
|
57
|
+
|
|
58
|
+
## Use Cases
|
|
59
|
+
|
|
60
|
+
`mithra-flow` is useful when you need to understand what your code is doing without opening a full profiler, adding a web UI, or wiring a database.
|
|
61
|
+
|
|
62
|
+
| Use Case | How It Helps |
|
|
63
|
+
| --- | --- |
|
|
64
|
+
| Debug complex flows | See the exact nested function path that ran during one request, job, or script execution. |
|
|
65
|
+
| Understand a new codebase | New developers can trace one entry function and quickly see the project flow. |
|
|
66
|
+
| Check performance hotspots | Durations make slow child calls visible without running a heavy profiler. |
|
|
67
|
+
| Compare before and after changes | Run the same function before and after a refactor to check if the call tree or timing changed. |
|
|
68
|
+
| Debug async behavior | See nested async calls across `await` points in one readable tree. |
|
|
69
|
+
| Investigate API requests | Decorate a FastAPI route and inspect the internal service/helper calls behind that endpoint. |
|
|
70
|
+
| Find noisy helper calls | Use `min_duration_ms` and `max_depth` to reduce clutter in large call trees. |
|
|
71
|
+
| Explain business logic | Export Mermaid or JSON traces to show how a workflow moves through functions. |
|
|
72
|
+
| Review errors faster | Use `on_error=True` to print traces only for failed executions. |
|
|
73
|
+
| Trace manual work blocks | Use `span(...)` for database queries, cache writes, external API calls, or other blocks that are not standalone functions. |
|
|
74
|
+
| Generate debugging artifacts | Use `save_to="trace.json"` to keep trace data for later review. |
|
|
75
|
+
| Teach project architecture | Include trace examples in onboarding docs so new team members see real runtime structure. |
|
|
76
|
+
|
|
77
|
+
Typical places to use it:
|
|
78
|
+
|
|
79
|
+
- FastAPI route handlers.
|
|
80
|
+
- CLI commands.
|
|
81
|
+
- Background jobs.
|
|
82
|
+
- Data processing pipelines.
|
|
83
|
+
- Test/debug scripts.
|
|
84
|
+
- Service-layer functions.
|
|
85
|
+
- Async workflows.
|
|
86
|
+
- Refactor verification.
|
|
87
|
+
|
|
88
|
+
## Install
|
|
89
|
+
|
|
90
|
+
From PyPI:
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
python3 -m pip install mithra-flow
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
With FastAPI example dependencies:
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
python3 -m pip install 'mithra-flow[examples]'
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
For local development:
|
|
103
|
+
|
|
104
|
+
```bash
|
|
105
|
+
python3 -m pip install -e '.[test,examples]'
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Run tests:
|
|
109
|
+
|
|
110
|
+
```bash
|
|
111
|
+
python3 -m pytest -q
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
## Quick Start
|
|
115
|
+
|
|
116
|
+
```python
|
|
117
|
+
from mithra_flow import mflow
|
|
118
|
+
|
|
119
|
+
def child():
|
|
120
|
+
return "ok"
|
|
121
|
+
|
|
122
|
+
@mflow
|
|
123
|
+
def parent():
|
|
124
|
+
return child()
|
|
125
|
+
|
|
126
|
+
parent()
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
Output:
|
|
130
|
+
|
|
131
|
+
```text
|
|
132
|
+
──────── MITHRA FLOW v1.0 ─────────
|
|
133
|
+
● parent() 0.04 ms
|
|
134
|
+
┗━━ ● child() 0.01 ms
|
|
135
|
+
────────────────────────────────────
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
## Async Example
|
|
139
|
+
|
|
140
|
+
```python
|
|
141
|
+
import asyncio
|
|
142
|
+
from mithra_flow import mflow
|
|
143
|
+
|
|
144
|
+
async def child():
|
|
145
|
+
await asyncio.sleep(0.01)
|
|
146
|
+
return "ok"
|
|
147
|
+
|
|
148
|
+
@mflow
|
|
149
|
+
async def parent():
|
|
150
|
+
return await child()
|
|
151
|
+
|
|
152
|
+
asyncio.run(parent())
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
Output:
|
|
156
|
+
|
|
157
|
+
```text
|
|
158
|
+
──────── MITHRA FLOW v1.0 ─────────
|
|
159
|
+
● parent() 11.20 ms
|
|
160
|
+
┗━━ ● child() 11.14 ms
|
|
161
|
+
────────────────────────────────────
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
## Decorator Options
|
|
165
|
+
|
|
166
|
+
| Option | Purpose |
|
|
167
|
+
| --- | --- |
|
|
168
|
+
| `name="checkout"` | Sets a custom banner title. |
|
|
169
|
+
| `title="checkout"` | Alias-style title input. |
|
|
170
|
+
| `include=["examples"]` | Trace only matching module/function/path values. |
|
|
171
|
+
| `exclude=["grandchild_b"]` | Hide matching module/function/path values. |
|
|
172
|
+
| `enabled=False` | Disable tracing for that function. |
|
|
173
|
+
| `min_duration_ms=15` | Hide calls faster than the threshold. |
|
|
174
|
+
| `max_depth=1` | Limit displayed/exported child depth. |
|
|
175
|
+
| `show_args=True` | Show function arguments. |
|
|
176
|
+
| `show_return=True` | Show return values. |
|
|
177
|
+
| `show_file=True` | Show source file names. |
|
|
178
|
+
| `show_line=True` | Show source line numbers. |
|
|
179
|
+
| `on_error=True` | Print only when an exception happens. |
|
|
180
|
+
| `output="terminal"` | Print a Rich tree. |
|
|
181
|
+
| `output="dict"` | Return a trace dictionary. |
|
|
182
|
+
| `output="json"` | Return a JSON trace string. |
|
|
183
|
+
| `output="mermaid"` | Return a Mermaid graph string. |
|
|
184
|
+
| `output="none"` | Collect/save without terminal printing. |
|
|
185
|
+
| `save_to="trace.json"` | Write trace output to disk. |
|
|
186
|
+
| `return_trace=True` | Return `MFlowResult(value, trace)`. |
|
|
187
|
+
|
|
188
|
+
Disable globally:
|
|
189
|
+
|
|
190
|
+
```bash
|
|
191
|
+
MITHRA_FLOW=0 python3 your_script.py
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
## FastAPI Example
|
|
195
|
+
|
|
196
|
+
Run the example API:
|
|
197
|
+
|
|
198
|
+
```bash
|
|
199
|
+
python3 -m uvicorn examples.nested_flow:app --reload
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
Open docs:
|
|
203
|
+
|
|
204
|
+
```text
|
|
205
|
+
http://127.0.0.1:8000/docs
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
Or call endpoints directly:
|
|
209
|
+
|
|
210
|
+
```bash
|
|
211
|
+
curl http://127.0.0.1:8000/flow/basic
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
## Example API Map
|
|
215
|
+
|
|
216
|
+
| Endpoint | Demonstrates |
|
|
217
|
+
| --- | --- |
|
|
218
|
+
| `/flow/basic` | Default nested async tracing. |
|
|
219
|
+
| `/flow/title` | Custom banner title with `name=`. |
|
|
220
|
+
| `/flow/args` | Arguments and return values. |
|
|
221
|
+
| `/flow/location` | File name and line number display. |
|
|
222
|
+
| `/flow/max-depth` | Depth-limited tree. |
|
|
223
|
+
| `/flow/min-duration` | Duration filtering. |
|
|
224
|
+
| `/flow/json` | JSON trace response. |
|
|
225
|
+
| `/flow/mermaid` | Mermaid graph response. |
|
|
226
|
+
| `/flow/return-trace` | Return value plus trace data. |
|
|
227
|
+
| `/flow/include-exclude` | Include/exclude filtering. |
|
|
228
|
+
| `/flow/save-to` | Save trace to `/tmp/mithra-flow-example.json`. |
|
|
229
|
+
| `/flow/manual-span` | Manual spans inside a trace. |
|
|
230
|
+
| `/flow/context` | Context manager tracing. |
|
|
231
|
+
| `/flow/on-error` | Print only when an error happens. |
|
|
232
|
+
| `/flow/disabled` | Disabled tracing. |
|
|
233
|
+
|
|
234
|
+
## `/flow/basic`
|
|
235
|
+
|
|
236
|
+
Code:
|
|
237
|
+
|
|
238
|
+
```python
|
|
239
|
+
@app.get("/flow/basic")
|
|
240
|
+
@mflow(include=["examples"])
|
|
241
|
+
async def basic_trace():
|
|
242
|
+
return {"result": await parent_flow()}
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
Call:
|
|
246
|
+
|
|
247
|
+
```bash
|
|
248
|
+
curl http://127.0.0.1:8000/flow/basic
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
Response:
|
|
252
|
+
|
|
253
|
+
```json
|
|
254
|
+
{"result":[["grandchild-a","grandchild-b"],["grandchild-c"]]}
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
Terminal graph:
|
|
258
|
+
|
|
259
|
+
```text
|
|
260
|
+
──────── MITHRA FLOW v1.0 ─────────
|
|
261
|
+
● basic_trace() 43.43 ms
|
|
262
|
+
┗━━ ● parent_flow() 43.42 ms
|
|
263
|
+
┣━━ ● child_one() 32.17 ms
|
|
264
|
+
┃ ┣━━ ● grandchild_a() 11.90 ms
|
|
265
|
+
┃ ┗━━ ● grandchild_b() 20.25 ms
|
|
266
|
+
┗━━ ● child_two() 11.24 ms
|
|
267
|
+
┗━━ ● grandchild_c() 11.23 ms
|
|
268
|
+
────────────────────────────────────
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
## `/flow/title`
|
|
272
|
+
|
|
273
|
+
Code:
|
|
274
|
+
|
|
275
|
+
```python
|
|
276
|
+
@app.get("/flow/title")
|
|
277
|
+
@mflow(name="checkout request", include=["examples"])
|
|
278
|
+
async def custom_title():
|
|
279
|
+
return {"result": await parent_flow()}
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
Terminal graph:
|
|
283
|
+
|
|
284
|
+
```text
|
|
285
|
+
────── checkout request v1.0 ──────
|
|
286
|
+
● custom_title() 43.26 ms
|
|
287
|
+
┗━━ ● parent_flow() 43.26 ms
|
|
288
|
+
┣━━ ● child_one() 32.09 ms
|
|
289
|
+
┃ ┣━━ ● grandchild_a() 10.88 ms
|
|
290
|
+
┃ ┗━━ ● grandchild_b() 21.18 ms
|
|
291
|
+
┗━━ ● child_two() 11.16 ms
|
|
292
|
+
┗━━ ● grandchild_c() 11.15 ms
|
|
293
|
+
────────────────────────────────────
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
## `/flow/args`
|
|
297
|
+
|
|
298
|
+
Code:
|
|
299
|
+
|
|
300
|
+
```python
|
|
301
|
+
@app.get("/flow/args")
|
|
302
|
+
@mflow(include=["examples"], show_args=True, show_return=True)
|
|
303
|
+
def args_and_returns(amount: int = 100):
|
|
304
|
+
return sync_receipt(amount)
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
Call:
|
|
308
|
+
|
|
309
|
+
```bash
|
|
310
|
+
curl "http://127.0.0.1:8000/flow/args?amount=100"
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
Terminal graph:
|
|
314
|
+
|
|
315
|
+
```text
|
|
316
|
+
──────── MITHRA FLOW v1.0 ─────────
|
|
317
|
+
● args_and_returns()(amount=100) 0.05 ms -> {'amount': 100, 'total': 105.0}
|
|
318
|
+
┗━━ ● sync_receipt()(amount=100) 0.03 ms -> {'amount': 100, 'total': 105.0}
|
|
319
|
+
┗━━ ● sync_price()(amount=100, tax=0.05) 0.01 ms -> 105.0
|
|
320
|
+
────────────────────────────────────
|
|
321
|
+
```
|
|
322
|
+
|
|
323
|
+
## `/flow/location`
|
|
324
|
+
|
|
325
|
+
Code:
|
|
326
|
+
|
|
327
|
+
```python
|
|
328
|
+
@app.get("/flow/location")
|
|
329
|
+
@mflow(include=["examples"], show_file=True, show_line=True)
|
|
330
|
+
async def file_and_line():
|
|
331
|
+
return {"result": await parent_flow()}
|
|
332
|
+
```
|
|
333
|
+
|
|
334
|
+
Terminal graph:
|
|
335
|
+
|
|
336
|
+
```text
|
|
337
|
+
──────── MITHRA FLOW v1.0 ─────────
|
|
338
|
+
● file_and_line() 43.56 ms nested_flow.py:75
|
|
339
|
+
┗━━ ● parent_flow() 43.55 ms nested_flow.py:37
|
|
340
|
+
┣━━ ● child_one() 32.35 ms nested_flow.py:26
|
|
341
|
+
┃ ┣━━ ● grandchild_a() 11.14 ms nested_flow.py:11
|
|
342
|
+
┃ ┗━━ ● grandchild_b() 21.20 ms nested_flow.py:16
|
|
343
|
+
┗━━ ● child_two() 11.19 ms nested_flow.py:32
|
|
344
|
+
┗━━ ● grandchild_c() 11.18 ms nested_flow.py:21
|
|
345
|
+
────────────────────────────────────
|
|
346
|
+
```
|
|
347
|
+
|
|
348
|
+
## `/flow/max-depth`
|
|
349
|
+
|
|
350
|
+
Code:
|
|
351
|
+
|
|
352
|
+
```python
|
|
353
|
+
@app.get("/flow/max-depth")
|
|
354
|
+
@mflow(include=["examples"], max_depth=1)
|
|
355
|
+
async def max_depth():
|
|
356
|
+
return {"result": await parent_flow()}
|
|
357
|
+
```
|
|
358
|
+
|
|
359
|
+
Terminal graph:
|
|
360
|
+
|
|
361
|
+
```text
|
|
362
|
+
──────── MITHRA FLOW v1.0 ─────────
|
|
363
|
+
● max_depth() 42.98 ms
|
|
364
|
+
┗━━ ● parent_flow() 42.97 ms
|
|
365
|
+
────────────────────────────────────
|
|
366
|
+
```
|
|
367
|
+
|
|
368
|
+
## `/flow/min-duration`
|
|
369
|
+
|
|
370
|
+
Code:
|
|
371
|
+
|
|
372
|
+
```python
|
|
373
|
+
@app.get("/flow/min-duration")
|
|
374
|
+
@mflow(include=["examples"], min_duration_ms=15)
|
|
375
|
+
async def min_duration():
|
|
376
|
+
return {"result": await parent_flow()}
|
|
377
|
+
```
|
|
378
|
+
|
|
379
|
+
Terminal graph:
|
|
380
|
+
|
|
381
|
+
```text
|
|
382
|
+
──────── MITHRA FLOW v1.0 ─────────
|
|
383
|
+
● min_duration() 43.54 ms
|
|
384
|
+
┗━━ ● parent_flow() 43.54 ms
|
|
385
|
+
┗━━ ● child_one() 32.37 ms
|
|
386
|
+
┗━━ ● grandchild_b() 21.20 ms
|
|
387
|
+
────────────────────────────────────
|
|
388
|
+
```
|
|
389
|
+
|
|
390
|
+
## `/flow/json`
|
|
391
|
+
|
|
392
|
+
Code:
|
|
393
|
+
|
|
394
|
+
```python
|
|
395
|
+
@app.get("/flow/json", response_class=PlainTextResponse)
|
|
396
|
+
@mflow(include=["examples"], output="json", show_args=True)
|
|
397
|
+
def json_trace():
|
|
398
|
+
return sync_receipt(50)
|
|
399
|
+
```
|
|
400
|
+
|
|
401
|
+
Response:
|
|
402
|
+
|
|
403
|
+
```json
|
|
404
|
+
{
|
|
405
|
+
"name": "json_trace()",
|
|
406
|
+
"duration_ms": 0.03,
|
|
407
|
+
"args": "()",
|
|
408
|
+
"exception": null,
|
|
409
|
+
"is_span": false,
|
|
410
|
+
"children": [
|
|
411
|
+
{
|
|
412
|
+
"name": "sync_receipt()",
|
|
413
|
+
"duration_ms": 0.01,
|
|
414
|
+
"args": "(amount=50)",
|
|
415
|
+
"exception": null,
|
|
416
|
+
"is_span": false,
|
|
417
|
+
"children": [
|
|
418
|
+
{
|
|
419
|
+
"name": "sync_price()",
|
|
420
|
+
"duration_ms": 0.0,
|
|
421
|
+
"args": "(amount=50, tax=0.05)",
|
|
422
|
+
"exception": null,
|
|
423
|
+
"is_span": false,
|
|
424
|
+
"children": []
|
|
425
|
+
}
|
|
426
|
+
]
|
|
427
|
+
}
|
|
428
|
+
]
|
|
429
|
+
}
|
|
430
|
+
```
|
|
431
|
+
|
|
432
|
+
## `/flow/mermaid`
|
|
433
|
+
|
|
434
|
+
Code:
|
|
435
|
+
|
|
436
|
+
```python
|
|
437
|
+
@app.get("/flow/mermaid", response_class=PlainTextResponse)
|
|
438
|
+
@mflow(include=["examples"], output="mermaid")
|
|
439
|
+
async def mermaid_trace():
|
|
440
|
+
await parent_flow()
|
|
441
|
+
```
|
|
442
|
+
|
|
443
|
+
Response:
|
|
444
|
+
|
|
445
|
+
```mermaid
|
|
446
|
+
graph TD
|
|
447
|
+
N0["mermaid_trace() 43.50 ms"]
|
|
448
|
+
N1["parent_flow() 43.50 ms"]
|
|
449
|
+
N2["child_one() 32.30 ms"]
|
|
450
|
+
N3["grandchild_a() 11.15 ms"]
|
|
451
|
+
N2 --> N3
|
|
452
|
+
N4["grandchild_b() 21.15 ms"]
|
|
453
|
+
N2 --> N4
|
|
454
|
+
N1 --> N2
|
|
455
|
+
N5["child_two() 11.13 ms"]
|
|
456
|
+
N6["grandchild_c() 11.13 ms"]
|
|
457
|
+
N5 --> N6
|
|
458
|
+
N1 --> N5
|
|
459
|
+
N0 --> N1
|
|
460
|
+
```
|
|
461
|
+
|
|
462
|
+
Rendered shape:
|
|
463
|
+
|
|
464
|
+
```text
|
|
465
|
+
mermaid_trace()
|
|
466
|
+
└── parent_flow()
|
|
467
|
+
├── child_one()
|
|
468
|
+
│ ├── grandchild_a()
|
|
469
|
+
│ └── grandchild_b()
|
|
470
|
+
└── child_two()
|
|
471
|
+
└── grandchild_c()
|
|
472
|
+
```
|
|
473
|
+
|
|
474
|
+
## `/flow/return-trace`
|
|
475
|
+
|
|
476
|
+
Code:
|
|
477
|
+
|
|
478
|
+
```python
|
|
479
|
+
@app.get("/flow/return-trace")
|
|
480
|
+
@mflow(include=["examples"], return_trace=True, show_return=True)
|
|
481
|
+
async def return_trace():
|
|
482
|
+
result = await parent_flow()
|
|
483
|
+
return result
|
|
484
|
+
```
|
|
485
|
+
|
|
486
|
+
Response shape:
|
|
487
|
+
|
|
488
|
+
```json
|
|
489
|
+
{
|
|
490
|
+
"value": [["grandchild-a", "grandchild-b"], ["grandchild-c"]],
|
|
491
|
+
"trace": {
|
|
492
|
+
"name": "return_trace()",
|
|
493
|
+
"duration_ms": 43.459,
|
|
494
|
+
"children": []
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
```
|
|
498
|
+
|
|
499
|
+
## `/flow/include-exclude`
|
|
500
|
+
|
|
501
|
+
Code:
|
|
502
|
+
|
|
503
|
+
```python
|
|
504
|
+
@app.get("/flow/include-exclude")
|
|
505
|
+
@mflow(include=["examples"], exclude=["grandchild_b"])
|
|
506
|
+
async def include_exclude():
|
|
507
|
+
return {"result": await parent_flow()}
|
|
508
|
+
```
|
|
509
|
+
|
|
510
|
+
Terminal graph:
|
|
511
|
+
|
|
512
|
+
```text
|
|
513
|
+
──────── MITHRA FLOW v1.0 ─────────
|
|
514
|
+
● include_exclude() 42.71 ms
|
|
515
|
+
┗━━ ● parent_flow() 42.70 ms
|
|
516
|
+
┣━━ ● child_one() 32.33 ms
|
|
517
|
+
┃ ┗━━ ● grandchild_a() 11.17 ms
|
|
518
|
+
┗━━ ● child_two() 10.36 ms
|
|
519
|
+
┗━━ ● grandchild_c() 10.35 ms
|
|
520
|
+
────────────────────────────────────
|
|
521
|
+
```
|
|
522
|
+
|
|
523
|
+
`grandchild_b()` still executes, but it is hidden from the trace.
|
|
524
|
+
|
|
525
|
+
## `/flow/save-to`
|
|
526
|
+
|
|
527
|
+
Code:
|
|
528
|
+
|
|
529
|
+
```python
|
|
530
|
+
@app.get("/flow/save-to")
|
|
531
|
+
@mflow(include=["examples"], save_to="/tmp/mithra-flow-example.json")
|
|
532
|
+
async def save_to_file():
|
|
533
|
+
await parent_flow()
|
|
534
|
+
return {"saved_to": "/tmp/mithra-flow-example.json"}
|
|
535
|
+
```
|
|
536
|
+
|
|
537
|
+
Call:
|
|
538
|
+
|
|
539
|
+
```bash
|
|
540
|
+
curl http://127.0.0.1:8000/flow/save-to
|
|
541
|
+
cat /tmp/mithra-flow-example.json
|
|
542
|
+
```
|
|
543
|
+
|
|
544
|
+
On macOS, `/tmp/mithra-flow-example.json` resolves to:
|
|
545
|
+
|
|
546
|
+
```text
|
|
547
|
+
/private/tmp/mithra-flow-example.json
|
|
548
|
+
```
|
|
549
|
+
|
|
550
|
+
## `/flow/manual-span`
|
|
551
|
+
|
|
552
|
+
Code:
|
|
553
|
+
|
|
554
|
+
```python
|
|
555
|
+
@app.get("/flow/manual-span")
|
|
556
|
+
@mflow(include=["examples"])
|
|
557
|
+
async def manual_span():
|
|
558
|
+
with span("manual database query"):
|
|
559
|
+
await asyncio.sleep(0.01)
|
|
560
|
+
with span("manual cache write"):
|
|
561
|
+
await asyncio.sleep(0)
|
|
562
|
+
return {"ok": True}
|
|
563
|
+
```
|
|
564
|
+
|
|
565
|
+
Terminal graph:
|
|
566
|
+
|
|
567
|
+
```text
|
|
568
|
+
──────── MITHRA FLOW v1.0 ─────────
|
|
569
|
+
● manual_span() 11.54 ms
|
|
570
|
+
┣━━ ◆ manual database query() 11.30 ms
|
|
571
|
+
┗━━ ◆ manual cache write() 0.22 ms
|
|
572
|
+
────────────────────────────────────
|
|
573
|
+
```
|
|
574
|
+
|
|
575
|
+
Manual spans use `◆` instead of `●`.
|
|
576
|
+
|
|
577
|
+
## `/flow/context`
|
|
578
|
+
|
|
579
|
+
Code:
|
|
580
|
+
|
|
581
|
+
```python
|
|
582
|
+
@app.get("/flow/context")
|
|
583
|
+
async def context_manager():
|
|
584
|
+
with trace("context managed flow", include=["examples"], show_return=True) as traced:
|
|
585
|
+
result = sync_receipt(25)
|
|
586
|
+
return {"result": result, "trace": traced.result}
|
|
587
|
+
```
|
|
588
|
+
|
|
589
|
+
Terminal graph:
|
|
590
|
+
|
|
591
|
+
```text
|
|
592
|
+
──── context managed flow v1.0 ────
|
|
593
|
+
◆ context managed flow 0.12 ms
|
|
594
|
+
┗━━ ● sync_receipt() 0.01 ms -> {'amount': 25, 'total': 26.25}
|
|
595
|
+
┗━━ ● sync_price() 0.01 ms -> 26.25
|
|
596
|
+
────────────────────────────────────
|
|
597
|
+
```
|
|
598
|
+
|
|
599
|
+
## `/flow/on-error`
|
|
600
|
+
|
|
601
|
+
Code:
|
|
602
|
+
|
|
603
|
+
```python
|
|
604
|
+
@app.get("/flow/on-error")
|
|
605
|
+
@mflow(include=["examples"], on_error=True)
|
|
606
|
+
async def on_error_only():
|
|
607
|
+
try:
|
|
608
|
+
await failing_child()
|
|
609
|
+
except RuntimeError as error:
|
|
610
|
+
raise HTTPException(status_code=500, detail=str(error)) from error
|
|
611
|
+
```
|
|
612
|
+
|
|
613
|
+
Response:
|
|
614
|
+
|
|
615
|
+
```json
|
|
616
|
+
{"detail":"example failure"}
|
|
617
|
+
```
|
|
618
|
+
|
|
619
|
+
Terminal graph:
|
|
620
|
+
|
|
621
|
+
```text
|
|
622
|
+
──────── MITHRA FLOW v1.0 ─────────
|
|
623
|
+
● on_error_only() 0.12 ms ! HTTPException: 500: example failure
|
|
624
|
+
┗━━ ● failing_child() 0.10 ms
|
|
625
|
+
────────────────────────────────────
|
|
626
|
+
```
|
|
627
|
+
|
|
628
|
+
## `/flow/disabled`
|
|
629
|
+
|
|
630
|
+
Code:
|
|
631
|
+
|
|
632
|
+
```python
|
|
633
|
+
@app.get("/flow/disabled")
|
|
634
|
+
@mflow(include=["examples"], enabled=False)
|
|
635
|
+
async def disabled_trace():
|
|
636
|
+
return {"result": await parent_flow()}
|
|
637
|
+
```
|
|
638
|
+
|
|
639
|
+
Response:
|
|
640
|
+
|
|
641
|
+
```json
|
|
642
|
+
{"result":[["grandchild-a","grandchild-b"],["grandchild-c"]]}
|
|
643
|
+
```
|
|
644
|
+
|
|
645
|
+
Terminal graph:
|
|
646
|
+
|
|
647
|
+
```text
|
|
648
|
+
No trace is printed.
|
|
649
|
+
```
|
|
650
|
+
|
|
651
|
+
## Context Manager
|
|
652
|
+
|
|
653
|
+
Use `trace(...)` when you do not want to decorate a function.
|
|
654
|
+
|
|
655
|
+
```python
|
|
656
|
+
from mithra_flow import trace
|
|
657
|
+
|
|
658
|
+
with trace("batch job", include=["jobs"]) as traced:
|
|
659
|
+
run_job()
|
|
660
|
+
|
|
661
|
+
print(traced.result)
|
|
662
|
+
```
|
|
663
|
+
|
|
664
|
+
## Manual Spans
|
|
665
|
+
|
|
666
|
+
Use `span(...)` to mark work that is not naturally represented by a function call.
|
|
667
|
+
|
|
668
|
+
```python
|
|
669
|
+
from mithra_flow import mflow, span
|
|
670
|
+
|
|
671
|
+
@mflow
|
|
672
|
+
def process():
|
|
673
|
+
with span("database query"):
|
|
674
|
+
query_database()
|
|
675
|
+
```
|
|
676
|
+
|
|
677
|
+
Output:
|
|
678
|
+
|
|
679
|
+
```text
|
|
680
|
+
──────── MITHRA FLOW v1.0 ─────────
|
|
681
|
+
● process() 12.30 ms
|
|
682
|
+
┗━━ ◆ database query() 11.80 ms
|
|
683
|
+
────────────────────────────────────
|
|
684
|
+
```
|
|
685
|
+
|
|
686
|
+
## Return Trace Data
|
|
687
|
+
|
|
688
|
+
```python
|
|
689
|
+
from mithra_flow import mflow
|
|
690
|
+
|
|
691
|
+
@mflow(return_trace=True)
|
|
692
|
+
def parent():
|
|
693
|
+
return "ok"
|
|
694
|
+
|
|
695
|
+
result = parent()
|
|
696
|
+
|
|
697
|
+
print(result.value)
|
|
698
|
+
print(result.trace)
|
|
699
|
+
```
|
|
700
|
+
|
|
701
|
+
`result` is an `MFlowResult`:
|
|
702
|
+
|
|
703
|
+
```python
|
|
704
|
+
MFlowResult(value="ok", trace={...})
|
|
705
|
+
```
|
|
706
|
+
|
|
707
|
+
## Versioning
|
|
708
|
+
|
|
709
|
+
The banner version comes from package versioning:
|
|
710
|
+
|
|
711
|
+
```python
|
|
712
|
+
from mithra_flow import __version__
|
|
713
|
+
|
|
714
|
+
print(__version__)
|
|
715
|
+
```
|
|
716
|
+
|
|
717
|
+
Current source:
|
|
718
|
+
|
|
719
|
+
```python
|
|
720
|
+
__version__ = "1.0"
|
|
721
|
+
```
|
|
722
|
+
|
|
723
|
+
`pyproject.toml` reads the version from `src/mithra_flow/version.py`.
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
mithra_flow/__init__.py,sha256=r8HAz5kRc0BDy9hbiGcXTP2AZC3ELn9StKg8p4p93Ww,199
|
|
2
|
+
mithra_flow/decorator.py,sha256=Y0RRsQnShm38f76aBkzI2vGtVdjFsDjFdyVxP2gFHIw,19452
|
|
3
|
+
mithra_flow/models.py,sha256=dztK2QljILfEhe5McVs7gCGXdcFAAVTQMIUKLDLs9OA,1013
|
|
4
|
+
mithra_flow/version.py,sha256=kM55qqhSrc4b-StNTuN03Srg0kwfg1G4BckCKhUytaE,20
|
|
5
|
+
mithra_flow-1.0.dist-info/METADATA,sha256=guB4Bt0KyPLPcq_bfD86aKtaafcK0kNc_SnunXYwFEU,18296
|
|
6
|
+
mithra_flow-1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
7
|
+
mithra_flow-1.0.dist-info/RECORD,,
|