handoffmap 0.2.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.
- handoffmap/__init__.py +50 -0
- handoffmap/__main__.py +5 -0
- handoffmap/adapters/__init__.py +70 -0
- handoffmap/adapters/genai.py +56 -0
- handoffmap/adapters/openinference.py +40 -0
- handoffmap/checks.py +33 -0
- handoffmap/cli.py +169 -0
- handoffmap/errors.py +37 -0
- handoffmap/i18n.py +262 -0
- handoffmap/model.py +156 -0
- handoffmap/normalize.py +195 -0
- handoffmap/otlp.py +166 -0
- handoffmap/py.typed +0 -0
- handoffmap/render/__init__.py +1 -0
- handoffmap/render/explain.py +42 -0
- handoffmap/render/jsonout.py +54 -0
- handoffmap/render/markdown.py +30 -0
- handoffmap/render/mermaid.py +115 -0
- handoffmap/render/table.py +45 -0
- handoffmap/samples/SOURCES.md +10 -0
- handoffmap/samples/openinference_agents.otlp.json +556 -0
- handoffmap/samples/pydantic_ai_v2.otlp.json +1032 -0
- handoffmap/samples/pydantic_ai_v5.otlp.json +1032 -0
- handoffmap/samples/pydantic_ai_v6.otlp.json +1032 -0
- handoffmap/usage.py +35 -0
- handoffmap-0.2.0.dist-info/METADATA +425 -0
- handoffmap-0.2.0.dist-info/RECORD +30 -0
- handoffmap-0.2.0.dist-info/WHEEL +4 -0
- handoffmap-0.2.0.dist-info/entry_points.txt +3 -0
- handoffmap-0.2.0.dist-info/licenses/LICENSE +21 -0
handoffmap/__init__.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""handoffmap: fold OTLP agent traces into a Mermaid flow and a per-agent table."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .errors import HandoffmapError, NoAgentSpansError, NotOtlpError
|
|
6
|
+
from .model import (
|
|
7
|
+
AgentRun,
|
|
8
|
+
AgentStats,
|
|
9
|
+
Edge,
|
|
10
|
+
Flow,
|
|
11
|
+
FlowWarning,
|
|
12
|
+
Member,
|
|
13
|
+
ParallelGroup,
|
|
14
|
+
Span,
|
|
15
|
+
ToolCall,
|
|
16
|
+
Trace,
|
|
17
|
+
)
|
|
18
|
+
from .normalize import analyze
|
|
19
|
+
from .otlp import load, parse
|
|
20
|
+
from .render.jsonout import to_dict
|
|
21
|
+
from .render.explain import render_explain
|
|
22
|
+
from .render.markdown import render_markdown
|
|
23
|
+
from .render.mermaid import render_mermaid
|
|
24
|
+
from .render.table import render_table
|
|
25
|
+
|
|
26
|
+
__version__ = "0.2.0"
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"AgentRun",
|
|
30
|
+
"AgentStats",
|
|
31
|
+
"Edge",
|
|
32
|
+
"Flow",
|
|
33
|
+
"FlowWarning",
|
|
34
|
+
"HandoffmapError",
|
|
35
|
+
"Member",
|
|
36
|
+
"NoAgentSpansError",
|
|
37
|
+
"NotOtlpError",
|
|
38
|
+
"ParallelGroup",
|
|
39
|
+
"Span",
|
|
40
|
+
"ToolCall",
|
|
41
|
+
"Trace",
|
|
42
|
+
"analyze",
|
|
43
|
+
"load",
|
|
44
|
+
"parse",
|
|
45
|
+
"render_explain",
|
|
46
|
+
"render_markdown",
|
|
47
|
+
"render_mermaid",
|
|
48
|
+
"render_table",
|
|
49
|
+
"to_dict",
|
|
50
|
+
]
|
handoffmap/__main__.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Convention adapters: decide what role a span plays (agent, tool, LLM call).
|
|
2
|
+
|
|
3
|
+
Each adapter looks at attributes only, never at the span name, because the
|
|
4
|
+
OTel GenAI conventions are still in development and frameworks name spans
|
|
5
|
+
differently (Pydantic AI instrumentation v2 says ``agent run``, v5 says
|
|
6
|
+
``invoke_agent orchestrator``).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
|
|
13
|
+
from ..model import Span
|
|
14
|
+
|
|
15
|
+
GENAI = "otel-genai"
|
|
16
|
+
OPENINFERENCE = "openinference"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class Classified:
|
|
21
|
+
"""What an adapter learned about one span."""
|
|
22
|
+
|
|
23
|
+
role: str # "agent" | "tool" | "llm" | "ignored"
|
|
24
|
+
convention: str
|
|
25
|
+
name: str = ""
|
|
26
|
+
input_tokens: int | None = None
|
|
27
|
+
output_tokens: int | None = None
|
|
28
|
+
cost: float | None = None
|
|
29
|
+
handoff_marker: bool = False # a tool span that only records "transfer to X"
|
|
30
|
+
handoff_from: str | None = None # OpenInference graph.node.parent_id
|
|
31
|
+
reported_usage: tuple[int, int] | None = None # usage attributes on an agent span
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def classify(span: Span, unknown_operations: set[str] | None = None) -> Classified | None:
|
|
35
|
+
"""Classify a span, or return None if it is neither an agent, a tool nor an LLM call."""
|
|
36
|
+
from . import genai, openinference
|
|
37
|
+
|
|
38
|
+
if "gen_ai.operation.name" in span.attributes:
|
|
39
|
+
result = genai.classify(span, unknown_operations)
|
|
40
|
+
if result is not None or "openinference.span.kind" not in span.attributes:
|
|
41
|
+
return result
|
|
42
|
+
if "openinference.span.kind" in span.attributes:
|
|
43
|
+
return openinference.classify(span)
|
|
44
|
+
return None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def as_int(value: object) -> int | None:
|
|
48
|
+
"""Return value as int when it is a number (or numeric string), else None."""
|
|
49
|
+
if isinstance(value, bool):
|
|
50
|
+
return None
|
|
51
|
+
if isinstance(value, int):
|
|
52
|
+
return value
|
|
53
|
+
if isinstance(value, float):
|
|
54
|
+
return int(value)
|
|
55
|
+
if isinstance(value, str) and value.strip().lstrip("-").isdigit():
|
|
56
|
+
return int(value)
|
|
57
|
+
return None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def as_float(value: object) -> float | None:
|
|
61
|
+
if isinstance(value, bool):
|
|
62
|
+
return None
|
|
63
|
+
if isinstance(value, (int, float)):
|
|
64
|
+
return float(value)
|
|
65
|
+
if isinstance(value, str):
|
|
66
|
+
try:
|
|
67
|
+
return float(value)
|
|
68
|
+
except ValueError:
|
|
69
|
+
return None
|
|
70
|
+
return None
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""OTel GenAI semantic conventions (``gen_ai.operation.name`` based)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from ..model import Span
|
|
6
|
+
from . import GENAI, Classified, as_int
|
|
7
|
+
|
|
8
|
+
LLM_OPERATIONS = {"chat", "text_completion", "generate_content"}
|
|
9
|
+
# Operations the conventions define that carry no agent/tool/LLM role here.
|
|
10
|
+
OTHER_KNOWN_OPERATIONS = {"create_agent", "invoke_workflow", "embeddings", "retrieval"}
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _strip_prefix(name: str, prefix: str) -> str:
|
|
14
|
+
return name[len(prefix) :].strip() if name.startswith(prefix) else name
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def classify(span: Span, unknown_operations: set[str] | None = None) -> Classified | None:
|
|
18
|
+
attrs = span.attributes
|
|
19
|
+
operation = attrs.get("gen_ai.operation.name")
|
|
20
|
+
|
|
21
|
+
if operation == "invoke_agent":
|
|
22
|
+
name = (
|
|
23
|
+
attrs.get("gen_ai.agent.name")
|
|
24
|
+
or attrs.get("agent_name") # Pydantic AI also sets this non-standard key
|
|
25
|
+
or _strip_prefix(span.name, "invoke_agent")
|
|
26
|
+
or attrs.get("gen_ai.agent.id")
|
|
27
|
+
or "agent"
|
|
28
|
+
)
|
|
29
|
+
reported = _reported_usage(attrs)
|
|
30
|
+
return Classified(role="agent", convention=GENAI, name=str(name), reported_usage=reported)
|
|
31
|
+
|
|
32
|
+
if operation == "execute_tool":
|
|
33
|
+
name = attrs.get("gen_ai.tool.name") or _strip_prefix(span.name, "execute_tool") or "tool"
|
|
34
|
+
return Classified(role="tool", convention=GENAI, name=str(name))
|
|
35
|
+
|
|
36
|
+
if operation in LLM_OPERATIONS:
|
|
37
|
+
return Classified(
|
|
38
|
+
role="llm",
|
|
39
|
+
convention=GENAI,
|
|
40
|
+
input_tokens=as_int(attrs.get("gen_ai.usage.input_tokens")),
|
|
41
|
+
output_tokens=as_int(attrs.get("gen_ai.usage.output_tokens")),
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
if operation not in OTHER_KNOWN_OPERATIONS and unknown_operations is not None:
|
|
45
|
+
unknown_operations.add(str(operation))
|
|
46
|
+
return None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _reported_usage(attrs: dict) -> tuple[int, int] | None:
|
|
50
|
+
"""Usage attributes on an agent span. Used only as a cross-check, never summed."""
|
|
51
|
+
for prefix in ("gen_ai.aggregated_usage", "gen_ai.usage"):
|
|
52
|
+
i = as_int(attrs.get(f"{prefix}.input_tokens"))
|
|
53
|
+
o = as_int(attrs.get(f"{prefix}.output_tokens"))
|
|
54
|
+
if i is not None or o is not None:
|
|
55
|
+
return (i or 0, o or 0)
|
|
56
|
+
return None
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""OpenInference conventions (``openinference.span.kind`` based)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from ..model import Span
|
|
6
|
+
from . import OPENINFERENCE, Classified, as_float, as_int
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def classify(span: Span) -> Classified | None:
|
|
10
|
+
attrs = span.attributes
|
|
11
|
+
kind = str(attrs.get("openinference.span.kind", "")).upper()
|
|
12
|
+
|
|
13
|
+
if kind == "AGENT":
|
|
14
|
+
name = attrs.get("agent.name") or attrs.get("graph.node.id")
|
|
15
|
+
if not name:
|
|
16
|
+
# e.g. the OpenAI Agents SDK "Agent workflow" wrapper: an AGENT span with no agent behind it
|
|
17
|
+
return Classified(role="ignored", convention=OPENINFERENCE, name=span.name)
|
|
18
|
+
parent = attrs.get("graph.node.parent_id")
|
|
19
|
+
return Classified(
|
|
20
|
+
role="agent",
|
|
21
|
+
convention=OPENINFERENCE,
|
|
22
|
+
name=str(name),
|
|
23
|
+
handoff_from=str(parent) if parent else None,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
if kind == "TOOL":
|
|
27
|
+
name = attrs.get("tool.name") or span.name or "tool"
|
|
28
|
+
# The Agents SDK records a handoff as a TOOL span named "handoff to <agent>".
|
|
29
|
+
marker = span.name.lower().startswith("handoff to ")
|
|
30
|
+
return Classified(role="tool", convention=OPENINFERENCE, name=str(name), handoff_marker=marker)
|
|
31
|
+
|
|
32
|
+
if kind == "LLM":
|
|
33
|
+
return Classified(
|
|
34
|
+
role="llm",
|
|
35
|
+
convention=OPENINFERENCE,
|
|
36
|
+
input_tokens=as_int(attrs.get("llm.token_count.prompt")),
|
|
37
|
+
output_tokens=as_int(attrs.get("llm.token_count.completion")),
|
|
38
|
+
cost=as_float(attrs.get("llm.cost.total")),
|
|
39
|
+
)
|
|
40
|
+
return None
|
handoffmap/checks.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Flow warnings: things that usually mean a loop or a runaway fan-out."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections import Counter
|
|
6
|
+
|
|
7
|
+
from .i18n import t
|
|
8
|
+
from .model import Edge, FlowWarning, ParallelGroup
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def find_warnings(
|
|
12
|
+
edges: list[Edge],
|
|
13
|
+
parallel: list[ParallelGroup],
|
|
14
|
+
repeat_threshold: int = 3,
|
|
15
|
+
max_parallel: int = 8,
|
|
16
|
+
) -> list[FlowWarning]:
|
|
17
|
+
"""Return ``repeated-delegation`` and ``wide-parallel`` warnings.
|
|
18
|
+
|
|
19
|
+
``message`` is English; ``args`` lets the renderers rebuild it in another language.
|
|
20
|
+
"""
|
|
21
|
+
warnings: list[FlowWarning] = []
|
|
22
|
+
|
|
23
|
+
pairs = Counter((e.caller, e.callee) for e in edges if e.kind != "root")
|
|
24
|
+
for (caller, callee), count in pairs.items():
|
|
25
|
+
if count >= repeat_threshold:
|
|
26
|
+
args = {"caller": caller, "callee": callee, "count": count, "threshold": repeat_threshold}
|
|
27
|
+
warnings.append(FlowWarning("repeated-delegation", t("en", "warn.repeated-delegation", **args), args))
|
|
28
|
+
|
|
29
|
+
for group in parallel:
|
|
30
|
+
if len(group.members) > max_parallel:
|
|
31
|
+
args = {"agent": group.agent, "count": len(group.members), "limit": max_parallel}
|
|
32
|
+
warnings.append(FlowWarning("wide-parallel", t("en", "warn.wide-parallel", **args), args))
|
|
33
|
+
return warnings
|
handoffmap/cli.py
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""Command line interface.
|
|
2
|
+
|
|
3
|
+
Exit codes: 0 ok, 1 no agent spans in the trace, 2 bad input (not OTLP, missing file, bad option).
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import argparse
|
|
9
|
+
import json
|
|
10
|
+
import sys
|
|
11
|
+
from collections.abc import Iterator
|
|
12
|
+
from contextlib import contextmanager
|
|
13
|
+
from importlib import resources
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from . import __version__
|
|
17
|
+
from .errors import HandoffmapError, NoAgentSpansError, NotOtlpError
|
|
18
|
+
from .i18n import LANGS, argparse_translator, resolve_lang, t
|
|
19
|
+
from .model import Flow, Trace
|
|
20
|
+
from .normalize import analyze
|
|
21
|
+
from .otlp import load, parse
|
|
22
|
+
from .render.explain import render_explain
|
|
23
|
+
from .render.jsonout import to_dict
|
|
24
|
+
from .render.markdown import render_markdown
|
|
25
|
+
from .render.mermaid import render_mermaid
|
|
26
|
+
from .render.table import render_table
|
|
27
|
+
|
|
28
|
+
SAMPLES = {
|
|
29
|
+
"pydantic-ai": "pydantic_ai_v5.otlp.json",
|
|
30
|
+
"pydantic-ai-v2": "pydantic_ai_v2.otlp.json",
|
|
31
|
+
"pydantic-ai-v6": "pydantic_ai_v6.otlp.json",
|
|
32
|
+
"openinference": "openinference_agents.otlp.json",
|
|
33
|
+
}
|
|
34
|
+
FORMATS = ("md", "mermaid", "table", "json")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _positive_for(lang: str):
|
|
38
|
+
"""Return an argparse ``type=`` function whose error messages are in ``lang``."""
|
|
39
|
+
|
|
40
|
+
def _positive(text: str) -> int:
|
|
41
|
+
try:
|
|
42
|
+
value = int(text)
|
|
43
|
+
except ValueError:
|
|
44
|
+
raise argparse.ArgumentTypeError(t(lang, "cli.err.not_whole", text=text)) from None
|
|
45
|
+
if value < 1:
|
|
46
|
+
raise argparse.ArgumentTypeError(t(lang, "cli.err.min_one"))
|
|
47
|
+
return value
|
|
48
|
+
|
|
49
|
+
return _positive
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _peek_lang(argv: list[str]) -> str:
|
|
53
|
+
"""Read only ``--lang`` from argv first, so help and parse errors can already be localized."""
|
|
54
|
+
peek = argparse.ArgumentParser(add_help=False, allow_abbrev=False)
|
|
55
|
+
peek.add_argument("--lang")
|
|
56
|
+
known, _rest = peek.parse_known_args(argv)
|
|
57
|
+
return resolve_lang(known.lang)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@contextmanager
|
|
61
|
+
def _argparse_in(lang: str) -> Iterator[None]:
|
|
62
|
+
"""Render argparse's built-in messages (usage, choices errors, ...) in ``lang`` while parsing."""
|
|
63
|
+
original = argparse._ # type: ignore[attr-defined]
|
|
64
|
+
argparse._ = argparse_translator(lang) # type: ignore[attr-defined]
|
|
65
|
+
try:
|
|
66
|
+
yield
|
|
67
|
+
finally:
|
|
68
|
+
argparse._ = original # type: ignore[attr-defined]
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def build_parser(lang: str = "en") -> argparse.ArgumentParser:
|
|
72
|
+
"""Build the argument parser with help text in ``lang`` (``en`` or ``ko``)."""
|
|
73
|
+
positive = _positive_for(lang)
|
|
74
|
+
p = argparse.ArgumentParser(prog="handoffmap", description=t(lang, "cli.description"), add_help=False)
|
|
75
|
+
p.add_argument("-h", "--help", action="help", help=t(lang, "argparse.help"))
|
|
76
|
+
p.add_argument("file", nargs="?", help=t(lang, "cli.help.file"))
|
|
77
|
+
p.add_argument("--sample", choices=sorted(SAMPLES), help=t(lang, "cli.help.sample"))
|
|
78
|
+
p.add_argument("--format", "-f", choices=FORMATS, default="md", help=t(lang, "cli.help.format"))
|
|
79
|
+
p.add_argument("--explain", action="store_true", help=t(lang, "cli.help.explain"))
|
|
80
|
+
p.add_argument("--trace", metavar="TRACE_ID", help=t(lang, "cli.help.trace"))
|
|
81
|
+
p.add_argument("--max-parallel", type=positive, default=8, metavar="N", help=t(lang, "cli.help.max_parallel"))
|
|
82
|
+
p.add_argument("--repeat-threshold", type=positive, default=3, metavar="N", help=t(lang, "cli.help.repeat_threshold"))
|
|
83
|
+
p.add_argument("--no-tools", action="store_true", help=t(lang, "cli.help.no_tools"))
|
|
84
|
+
p.add_argument("-o", "--output", metavar="OUT", help=t(lang, "cli.help.output"))
|
|
85
|
+
p.add_argument("--lang", choices=LANGS, metavar="{en,ko}", help=t(lang, "cli.help.lang"))
|
|
86
|
+
p.add_argument("--version", action="version", version=f"handoffmap {__version__}", help=t(lang, "cli.help.version"))
|
|
87
|
+
return p
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _load_traces(args: argparse.Namespace) -> list[Trace]:
|
|
91
|
+
if args.sample:
|
|
92
|
+
text = resources.files("handoffmap").joinpath("samples", SAMPLES[args.sample]).read_text(encoding="utf-8")
|
|
93
|
+
traces = parse(text)
|
|
94
|
+
else:
|
|
95
|
+
traces = load(args.file)
|
|
96
|
+
if args.trace:
|
|
97
|
+
traces = [t for t in traces if t.trace_id.startswith(args.trace.lower())]
|
|
98
|
+
if not traces:
|
|
99
|
+
raise NotOtlpError("cli.err.no_trace", prefix=args.trace)
|
|
100
|
+
return traces
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _analyze_all(traces: list[Trace], args: argparse.Namespace) -> list[Flow]:
|
|
104
|
+
flows: list[Flow] = []
|
|
105
|
+
last_error: NoAgentSpansError | None = None
|
|
106
|
+
for trace in traces:
|
|
107
|
+
try:
|
|
108
|
+
flows.append(analyze(trace, repeat_threshold=args.repeat_threshold, max_parallel=args.max_parallel))
|
|
109
|
+
except NoAgentSpansError as exc:
|
|
110
|
+
last_error = exc # other traces in the file may still have agents
|
|
111
|
+
if not flows:
|
|
112
|
+
assert last_error is not None
|
|
113
|
+
raise last_error
|
|
114
|
+
return flows
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _render(flows: list[Flow], args: argparse.Namespace, lang: str) -> tuple[str, str]:
|
|
118
|
+
"""Return (report, explain) text. ``json`` and ``mermaid`` ignore ``lang`` (machine output)."""
|
|
119
|
+
show_tools = not args.no_tools
|
|
120
|
+
explain = (
|
|
121
|
+
"".join(render_explain(f, lang) + ("\n" if len(flows) > 1 else "") for f in flows) if args.explain else ""
|
|
122
|
+
)
|
|
123
|
+
if args.format == "json":
|
|
124
|
+
payload = [to_dict(f) for f in flows]
|
|
125
|
+
report = json.dumps(payload[0] if len(payload) == 1 else {"traces": payload}, indent=2, ensure_ascii=False) + "\n"
|
|
126
|
+
elif args.format == "mermaid":
|
|
127
|
+
report = "\n".join(render_mermaid(f, show_tools=show_tools) for f in flows)
|
|
128
|
+
elif args.format == "table":
|
|
129
|
+
report = "\n".join(render_table(f, lang=lang) for f in flows)
|
|
130
|
+
else:
|
|
131
|
+
report = "\n".join(render_markdown(f, show_tools=show_tools, heading=True, lang=lang) for f in flows)
|
|
132
|
+
if args.sample and args.format == "md":
|
|
133
|
+
report += "\n" + t(lang, "cli.sample_note") + "\n"
|
|
134
|
+
return report, explain
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def main(argv: list[str] | None = None) -> int:
|
|
138
|
+
for stream in (sys.stdout, sys.stderr):
|
|
139
|
+
if hasattr(stream, "reconfigure"):
|
|
140
|
+
stream.reconfigure(encoding="utf-8") # the table uses an en dash; C locales would choke
|
|
141
|
+
raw_args = sys.argv[1:] if argv is None else list(argv)
|
|
142
|
+
lang = _peek_lang(raw_args) # --lang > HANDOFFMAP_LANG > LC_ALL/LC_MESSAGES/LANG > en
|
|
143
|
+
with _argparse_in(lang): # the parser must be built inside: argparse looks up some titles at construction
|
|
144
|
+
parser = build_parser(lang)
|
|
145
|
+
args = parser.parse_args(raw_args)
|
|
146
|
+
if bool(args.file) == bool(args.sample):
|
|
147
|
+
parser.error(t(lang, "cli.err.one_of"))
|
|
148
|
+
|
|
149
|
+
try:
|
|
150
|
+
flows = _analyze_all(_load_traces(args), args)
|
|
151
|
+
report, explain = _render(flows, args, lang)
|
|
152
|
+
except HandoffmapError as exc:
|
|
153
|
+
print(f"handoffmap: {exc.localized(lang)}", file=sys.stderr)
|
|
154
|
+
return exc.exit_code
|
|
155
|
+
|
|
156
|
+
# explain goes before the report for md/table; to stderr for mermaid/json so stdout stays machine-readable
|
|
157
|
+
if explain and args.format in ("mermaid", "json"):
|
|
158
|
+
print(explain, file=sys.stderr, end="")
|
|
159
|
+
explain = ""
|
|
160
|
+
text = (explain + "\n" if explain else "") + report
|
|
161
|
+
if args.output:
|
|
162
|
+
try:
|
|
163
|
+
Path(args.output).write_text(text, encoding="utf-8")
|
|
164
|
+
except OSError as exc:
|
|
165
|
+
print(f"handoffmap: {t(lang, 'cli.err.cannot_write', path=args.output, error=exc)}", file=sys.stderr)
|
|
166
|
+
return 2
|
|
167
|
+
else:
|
|
168
|
+
sys.stdout.write(text)
|
|
169
|
+
return 0
|
handoffmap/errors.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Exceptions raised by handoffmap. The CLI maps them to exit codes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .i18n import MESSAGES, t
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class HandoffmapError(Exception):
|
|
9
|
+
"""Base class for all handoffmap errors.
|
|
10
|
+
|
|
11
|
+
Built from a message key of :mod:`handoffmap.i18n` plus its parameters. ``str(exc)`` is
|
|
12
|
+
always English; the CLI calls :meth:`localized` to show the user's language.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
exit_code = 2
|
|
16
|
+
|
|
17
|
+
def __init__(self, key: str, **params: object) -> None:
|
|
18
|
+
self.key = key
|
|
19
|
+
self.params = params
|
|
20
|
+
# a key that is not in the table is treated as a plain English message
|
|
21
|
+
super().__init__(t("en", key, **params) if key in MESSAGES["en"] else key)
|
|
22
|
+
|
|
23
|
+
def localized(self, lang: str) -> str:
|
|
24
|
+
"""Return the message in ``lang`` (``en`` or ``ko``)."""
|
|
25
|
+
return t(lang, self.key, **self.params) if self.key in MESSAGES["en"] else str(self)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class NotOtlpError(HandoffmapError):
|
|
29
|
+
"""The input is not an OTLP trace export (exit code 2)."""
|
|
30
|
+
|
|
31
|
+
exit_code = 2
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class NoAgentSpansError(HandoffmapError):
|
|
35
|
+
"""The trace has no agent spans, so there is no flow to draw (exit code 1)."""
|
|
36
|
+
|
|
37
|
+
exit_code = 1
|