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.
@@ -0,0 +1,5 @@
1
+ from .decorator import MFlowResult, mflow, span, trace
2
+ from .models import TraceNode
3
+ from .version import __version__
4
+
5
+ __all__ = ["MFlowResult", "TraceNode", "__version__", "mflow", "span", "trace"]
@@ -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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any