severn-trace 0.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.
severn/__init__.py ADDED
@@ -0,0 +1,17 @@
1
+ """severn-trace: lightweight execution tracing for autonomous AI agents."""
2
+
3
+ __version__ = "0.1.0"
4
+
5
+ from .exporter import Exporter, configure, flush, get_exporter
6
+ from .tracer import FAILED, SUCCESS, trace
7
+
8
+ __all__ = [
9
+ "trace",
10
+ "configure",
11
+ "flush",
12
+ "get_exporter",
13
+ "Exporter",
14
+ "SUCCESS",
15
+ "FAILED",
16
+ "__version__",
17
+ ]
severn/cli.py ADDED
@@ -0,0 +1,243 @@
1
+ import sys
2
+ import json
3
+ import os
4
+ import time
5
+
6
+ # Rich Cyberpunk & Terminal ANSI Color Palette
7
+ CYAN = "\033[96m"
8
+ GREEN = "\033[92m"
9
+ YELLOW = "\033[93m"
10
+ MAGENTA = "\033[95m"
11
+ BLUE = "\033[94m"
12
+ RED = "\033[91m"
13
+ WHITE = "\033[97m"
14
+ RESET = "\033[0m"
15
+ BOLD = "\033[1m"
16
+ DIM = "\033[2m"
17
+ ORANGE = "\033[38;5;208m"
18
+ PURPLE = "\033[38;5;141m"
19
+ TEAL = "\033[38;5;38m"
20
+
21
+ def show_banner():
22
+ os.system('clear' if os.name == 'posix' else 'cls')
23
+
24
+ # Clean 3D Block ASCII Banner for SEVERN
25
+ banner = f"""{CYAN}{BOLD}
26
+ ███████╗███████╗██╗ ██╗███████╗██████╗ ███╗ ██╗
27
+ ██╔════╝██╔════╝██║ ██║██╔════╝██╔══██╗████╗ ██║
28
+ ███████╗█████╗ ██║ ██║█████╗ ██████╔╝██╔██╗ ██║
29
+ ╚════██║██╔══╝ ╚██ ██╔╝██╔══╝ ██╔══██╗██║╚██╗██║
30
+ ███████║███████╗ ╚████╔╝ ███████╗██║ ██║██║ ╚████║
31
+ ╚══════╝╚══════╝ ╚═══╝ ╚══════╝╚═╝ ╚═╝╚═╝ ╚═══╝
32
+ {RESET}{PURPLE}=================================================================================={RESET}
33
+ {MAGENTA}{BOLD} ⚡ ZERO-DEPENDENCY EXECUTION RUNTIME & ERROR TELEMETRY LAYER FOR AI AGENTS ⚡{RESET}
34
+ {PURPLE}=================================================================================={RESET}
35
+ """
36
+ print(banner)
37
+
38
+ def get_trace_stats():
39
+ """Analyzes local log files to compute real-time metrics."""
40
+ log_path = "severn_traces.jsonl"
41
+ if not os.path.exists(log_path):
42
+ return 0, 0, 0
43
+
44
+ total = 0
45
+ passed = 0
46
+ failed = 0
47
+
48
+ try:
49
+ with open(log_path, "r", encoding="utf-8") as f:
50
+ for line in f:
51
+ total += 1
52
+ if '"status": "SUCCESS"' in line or '"status":"SUCCESS"' in line:
53
+ passed += 1
54
+ elif '"status": "FAILED"' in line or '"status":"FAILED"' in line:
55
+ failed += 1
56
+ except Exception:
57
+ pass
58
+
59
+ return total, passed, failed
60
+
61
+ def pause_and_return():
62
+ input(f"\n{DIM}Press Enter to return...{RESET}")
63
+
64
+ # -------------------------------------------------------------------------
65
+ # MULTI-STEP AGENT FRAMEWORK ARCHITECTURE EXPLORER (LangChain / CrewAI / AutoGen)
66
+ # -------------------------------------------------------------------------
67
+ def sub_menu_agent_topology():
68
+ while True:
69
+ show_banner()
70
+ print(f"{TEAL}{BOLD}🤖 AI AGENT FRAMEWORK INTEGRATION & TOPOLOGY RUNTIME:{RESET}\n")
71
+ print(f" {GREEN}[1]{RESET} {BOLD}CrewAI Agent Goal & Task Trapping{RESET}")
72
+ print(f" {CYAN}[2]{RESET} {BOLD}LangChain AgentExecutor State Machine Tracing{RESET}")
73
+ print(f" {YELLOW}[3]{RESET} {BOLD}AutoGen Multi-Agent Conversational Routing{RESET}")
74
+ print(f" {MAGENTA}[4]{RESET} {BOLD}Custom Autonomous Loop Pattern (ReAct / Plan-and-Solve){RESET}")
75
+ print(f" {RED}[B]{RESET} {BOLD}Back to Main Menu{RESET}\n")
76
+
77
+ choice = input(f"{CYAN}{BOLD}Select framework workflow (1-4 or B): {RESET}").strip().upper()
78
+
79
+ if choice == "1":
80
+ print(f"\n{GREEN}[+] CrewAI Task Execution Telemetry:{RESET}")
81
+ print(f" Wrap sequential/hierarchical CrewAI tasks directly with {BOLD}@trace{RESET}.")
82
+ print(" Tracks crew role assignments, action output schemas, and tool-retry loops.")
83
+ pause_and_return()
84
+ elif choice == "2":
85
+ print(f"\n{CYAN}[+] LangChain AgentExecutor Hooking:{RESET}")
86
+ print(f" Decorate intermediate tool executions and custom LLMChain steps with {BOLD}@trace{RESET}.")
87
+ print(" Traps tool input parsing errors before state degradation occurs.")
88
+ pause_and_return()
89
+ elif choice == "3":
90
+ print(f"\n{YELLOW}[+] AutoGen Multi-Agent Conversation Tracing:{RESET}")
91
+ print(f" Monitor inter-agent messages between ConversableAgent instances.")
92
+ print(" Logs step duration, message payload sizes, and dead-end conversation loops.")
93
+ pause_and_return()
94
+ elif choice == "4":
95
+ print(f"\n{MAGENTA}[+] Custom ReAct Execution Loop:{RESET}")
96
+ print(f" Pattern: {BOLD}Thought -> Action -> Observation -> Final Answer{RESET}")
97
+ print(" Severn exports structured JSON metadata per step for instant replay analysis.")
98
+ pause_and_return()
99
+ elif choice == "B":
100
+ break
101
+
102
+ # -------------------------------------------------------------------------
103
+ # LABLAB.AI HACKATHON MULTI-STEP PORTAL
104
+ # -------------------------------------------------------------------------
105
+ def sub_menu_hackathon():
106
+ while True:
107
+ show_banner()
108
+ print(f"{YELLOW}{BOLD}🏆 LABLAB.AI HACKATHON BUILDER PORTAL (MULTI-STEP EXPLORER):{RESET}\n")
109
+ print(f" {GREEN}[1]{RESET} {BOLD}Challenge Focus & Multi-Agent Tracks{RESET}")
110
+ print(f" {CYAN}[2]{RESET} {BOLD}Participant Tooling & Sponsor Grants{RESET}")
111
+ print(f" {MAGENTA}[3]{RESET} {BOLD}Telemetry Submission Guidelines{RESET}")
112
+ print(f" {RED}[B]{RESET} {BOLD}Back to Main Menu{RESET}\n")
113
+
114
+ choice = input(f"{CYAN}{BOLD}Select hackathon section (1-3 or B): {RESET}").strip().upper()
115
+
116
+ if choice == "1":
117
+ print(f"\n{GREEN}[+] Challenge Focus:{RESET}")
118
+ print(" Architect multi-step AI agents, trap tool-call failure loops,")
119
+ print(" and provide zero-friction local telemetry using Severn-Trace.")
120
+ pause_and_return()
121
+ elif choice == "2":
122
+ print(f"\n{CYAN}[+] Participant Perks:{RESET}")
123
+ print(" Access sponsor API credits, developer tooling grants, priority community channels,")
124
+ print(" and fast-track investor introductions for top projects.")
125
+ pause_and_return()
126
+ elif choice == "3":
127
+ print(f"\n{MAGENTA}[+] Submission Setup:{RESET}")
128
+ print(" 1. Enroll on Lablab.ai")
129
+ print(" 2. Import Severn: `from severn import trace`")
130
+ print(" 3. Decorate tool calls with `@trace` & include `severn_traces.jsonl` in your repo.")
131
+ pause_and_return()
132
+ elif choice == "B":
133
+ break
134
+
135
+ # -------------------------------------------------------------------------
136
+ # ROADMAP & ADVANCED FEATURES MULTI-STEP
137
+ # -------------------------------------------------------------------------
138
+ def sub_menu_premium():
139
+ while True:
140
+ show_banner()
141
+ print(f"{MAGENTA}{BOLD}👑 ADVANCED ROADMAP & ENGINE SPECS (MULTI-STEP EXPLORER):{RESET}\n")
142
+ print(f" {GREEN}[1]{RESET} {BOLD}Automated Self-Healing Retry Loop{RESET}")
143
+ print(f" {CYAN}[2]{RESET} {BOLD}Zero-Dependency Telemetry Exporter{RESET}")
144
+ print(f" {YELLOW}[3]{RESET} {BOLD}Founding Builder Foundation Access{RESET}")
145
+ print(f" {RED}[B]{RESET} {BOLD}Back to Main Menu{RESET}\n")
146
+
147
+ choice = input(f"{CYAN}{BOLD}Select option (1-3 or B): {RESET}").strip().upper()
148
+
149
+ if choice == "1":
150
+ print(f"\n{GREEN}[+] Self-Healing Retries:{RESET}")
151
+ print(" Upcoming SDK releases will feature automatic retry loops for broken API calls,")
152
+ print(" sanitizing invalid JSON schemas before agent execution fails.")
153
+ pause_and_return()
154
+ elif choice == "2":
155
+ print(f"\n{CYAN}[+] Zero-Dependency Core:{RESET}")
156
+ print(" Built strictly with standard Python modules (json, functools, time, uuid).")
157
+ print(" Zero external library conflicts across Python 3.8+ environments.")
158
+ pause_and_return()
159
+ elif choice == "3":
160
+ print(f"\n{YELLOW}[+] Founding Builder Foundation:{RESET}")
161
+ print(" Early competition participants and top builders receive priority roadmap voting")
162
+ print(" and early access to enterprise telemetry scaling tools.")
163
+ pause_and_return()
164
+ elif choice == "B":
165
+ break
166
+
167
+ # -------------------------------------------------------------------------
168
+ # MAIN INTERACTIVE MENU LOOP
169
+ # -------------------------------------------------------------------------
170
+ def interactive_menu():
171
+ while True:
172
+ show_banner()
173
+
174
+ total, passed, failed = get_trace_stats()
175
+
176
+ print(f"{WHITE}{BOLD}📊 PUBLIC LIVE SESSION DASHBOARD (LOCAL TELEMETRY):{RESET}")
177
+ print(f" {CYAN}Total Local Telemetry Records:{RESET} {WHITE}{total}{RESET}")
178
+ print(f" {GREEN}Successfully Executed Steps: {RESET} {WHITE}{passed}{RESET}")
179
+ print(f" {RED}Caught Agent Loop Failures: {RESET} {WHITE}{failed}{RESET}\n")
180
+
181
+ print(f"{YELLOW}{BOLD}SELECT AN OPTION TO NAVIGATE SEVERN (1 - 7):{RESET}\n")
182
+ print(f" {GREEN}[1]{RESET} {BOLD}Verify Active Trace Status{RESET} - Check if local runtime engine is active")
183
+ print(f" {CYAN}[2]{RESET} {BOLD}View Local Telemetry Database{RESET} - Inspect raw `severn_traces.jsonl` export records")
184
+ print(f" {TEAL}[3]{RESET} {BOLD}Agent Framework Topologies{RESET} - Explore CrewAI, LangChain & AutoGen integration options")
185
+ print(f" {YELLOW}[4]{RESET} {BOLD}Lablab.ai Hackathon Portal{RESET} - Access multi-step AI Agent competition guides")
186
+ print(f" {MAGENTA}[5]{RESET} {BOLD}Claim Premium SDK Access Perks{RESET} - Explore future self-healing & framework features")
187
+ print(f" {BLUE}[6]{RESET} {BOLD}Direct Founder & Community Support{RESET} - Connect via Discord (@severnteam)")
188
+ print(f" {RED}[7]{RESET} {BOLD}Exit Severn Console{RESET}\n")
189
+
190
+ choice = input(f"{CYAN}{BOLD}Enter your choice (1-7): {RESET}").strip()
191
+
192
+ if choice == "1":
193
+ print(f"\n{GREEN}[+] Status: Severn Tracing Engine v0.1.0 is fully active and listening.{RESET}")
194
+ print(f" Instructions: Place {BOLD}@trace{RESET} on top of any Python function or agent tool call to auto-capture telemetry.")
195
+ pause_and_return()
196
+
197
+ elif choice == "2":
198
+ print(f"\n{YELLOW}[*] Reading local trace logs from severn_traces.jsonl...{RESET}")
199
+ log_path = "severn_traces.jsonl"
200
+ if os.path.exists(log_path):
201
+ with open(log_path, "r", encoding="utf-8") as f:
202
+ lines = f.readlines()
203
+ print(f"{GREEN}Found {len(lines)} total trace records stored locally on your machine:{RESET}\n")
204
+ for line in lines[-5:]:
205
+ print(f" {CYAN}→{RESET} {line.strip()}")
206
+ else:
207
+ print(f"{YELLOW}[!] No local trace records found yet. Execute a test agent script decorated with @trace first!{RESET}")
208
+ pause_and_return()
209
+
210
+ elif choice == "3":
211
+ sub_menu_agent_topology()
212
+
213
+ elif choice == "4":
214
+ sub_menu_hackathon()
215
+
216
+ elif choice == "5":
217
+ sub_menu_premium()
218
+
219
+ elif choice == "6":
220
+ blue_discord_art = f"""{BLUE}
221
+ ______
222
+ .-' '-.
223
+ / \\
224
+ | () () |
225
+ | __ |
226
+ \\ /
227
+ '-.______.-'
228
+ {RESET}"""
229
+ print(blue_discord_art)
230
+ print(f"{ORANGE}[💬] DIRECT FOUNDER & COMMUNITY SUPPORT:{RESET}")
231
+ print(f" Have questions, need help configuring your trace setup, or want to report agent failure stress tests?")
232
+ print(f" Reach out directly through our community channel:")
233
+ print(f" - Discord Community Handle: {BOLD}@severnteam{RESET}")
234
+ print(f" - Direct Founder Email Support: {BOLD}david@severn.dev{RESET}")
235
+ print(f" - Mission Statement: Built by a 15-year-old solo founder to solve autonomous agent reliability forever.")
236
+ pause_and_return()
237
+
238
+ elif choice == "7":
239
+ print(f"\n{YELLOW}Exiting Severn Console. Keep building exceptional autonomous systems!{RESET}\n")
240
+ sys.exit(0)
241
+
242
+ if __name__ == "__main__":
243
+ interactive_menu()
severn/exporter.py ADDED
@@ -0,0 +1,171 @@
1
+ from __future__ import annotations
2
+
3
+ import atexit
4
+ import json
5
+ import os
6
+ import queue
7
+ import threading
8
+ import time
9
+ import urllib.request
10
+ from typing import Any, Dict, Optional
11
+
12
+ from . import __version__
13
+
14
+ ENV_ENDPOINT = "SEVERN_ENDPOINT"
15
+ ENV_API_KEY = "SEVERN_API_KEY"
16
+ ENV_TIMEOUT = "SEVERN_TIMEOUT"
17
+
18
+ DEFAULT_TIMEOUT = 2.0
19
+ DEFAULT_QUEUE_SIZE = 1000
20
+ DEFAULT_FLUSH_TIMEOUT = 2.0
21
+ LOCAL_LOG_FILE = "severn_traces.jsonl"
22
+
23
+
24
+ class Exporter:
25
+ def __init__(
26
+ self,
27
+ endpoint: Optional[str] = None,
28
+ timeout: Optional[float] = None,
29
+ api_key: Optional[str] = None,
30
+ max_queue: int = DEFAULT_QUEUE_SIZE,
31
+ ) -> None:
32
+ self._endpoint_override = endpoint
33
+ self._timeout_override = timeout
34
+ self._api_key_override = api_key
35
+ self._queue: "queue.Queue[Dict[str, Any]]" = queue.Queue(maxsize=max_queue)
36
+ self._thread: Optional[threading.Thread] = None
37
+ self._lock = threading.Lock()
38
+ self.dropped = 0
39
+
40
+ @property
41
+ def endpoint(self) -> Optional[str]:
42
+ value = self._endpoint_override or os.environ.get(ENV_ENDPOINT, "")
43
+ value = (value or "").strip()
44
+ if value.lower().startswith(("http://", "https://")):
45
+ return value
46
+ return None
47
+
48
+ @property
49
+ def timeout(self) -> float:
50
+ if self._timeout_override is not None:
51
+ candidate: Any = self._timeout_override
52
+ else:
53
+ candidate = os.environ.get(ENV_TIMEOUT, "")
54
+ try:
55
+ value = float(candidate)
56
+ except (TypeError, ValueError):
57
+ return DEFAULT_TIMEOUT
58
+ return value if value > 0 else DEFAULT_TIMEOUT
59
+
60
+ @property
61
+ def api_key(self) -> Optional[str]:
62
+ return self._api_key_override or os.environ.get(ENV_API_KEY) or None
63
+
64
+ def export(self, payload: Dict[str, Any]) -> bool:
65
+ try:
66
+ with open(LOCAL_LOG_FILE, "a", encoding="utf-8") as f:
67
+ f.write(json.dumps(payload, default=str) + "\n")
68
+ except Exception:
69
+ pass
70
+
71
+ try:
72
+ if self.endpoint is None:
73
+ return False
74
+ self._ensure_thread()
75
+ self._queue.put_nowait(payload)
76
+ return True
77
+ except queue.Full:
78
+ self.dropped += 1
79
+ return False
80
+ except Exception:
81
+ return False
82
+
83
+ def flush(self, timeout: float = DEFAULT_FLUSH_TIMEOUT) -> bool:
84
+ deadline = time.monotonic() + max(timeout, 0.0)
85
+ while self._queue.unfinished_tasks > 0:
86
+ if time.monotonic() >= deadline:
87
+ return False
88
+ time.sleep(0.01)
89
+ return True
90
+
91
+ def _ensure_thread(self) -> None:
92
+ thread = self._thread
93
+ if thread is not None and thread.is_alive():
94
+ return
95
+ with self._lock:
96
+ thread = self._thread
97
+ if thread is not None and thread.is_alive():
98
+ return
99
+ thread = threading.Thread(
100
+ target=self._run, name="severn-exporter", daemon=True
101
+ )
102
+ thread.start()
103
+ self._thread = thread
104
+
105
+ def _run(self) -> None:
106
+ while True:
107
+ payload = self._queue.get()
108
+ try:
109
+ self._send(payload)
110
+ except Exception:
111
+ pass
112
+ finally:
113
+ self._queue.task_done()
114
+
115
+ def _send(self, payload: Dict[str, Any]) -> None:
116
+ endpoint = self.endpoint
117
+ if endpoint is None:
118
+ return
119
+ body = json.dumps(payload, default=str).encode("utf-8")
120
+ headers = {
121
+ "Content-Type": "application/json",
122
+ "User-Agent": "severn-trace/" + __version__,
123
+ }
124
+ api_key = self.api_key
125
+ if api_key:
126
+ headers["Authorization"] = "Bearer " + api_key
127
+ request = urllib.request.Request(
128
+ endpoint, data=body, headers=headers, method="POST"
129
+ )
130
+ with urllib.request.urlopen(request, timeout=self.timeout) as response:
131
+ response.read(1024)
132
+
133
+
134
+ _default_exporter = Exporter()
135
+
136
+
137
+ def get_exporter() -> Exporter:
138
+ return _default_exporter
139
+
140
+
141
+ def configure(
142
+ endpoint: Optional[str] = None,
143
+ timeout: Optional[float] = None,
144
+ api_key: Optional[str] = None,
145
+ ) -> Exporter:
146
+ exporter = _default_exporter
147
+ if endpoint is not None:
148
+ exporter._endpoint_override = endpoint
149
+ if timeout is not None:
150
+ exporter._timeout_override = timeout
151
+ if api_key is not None:
152
+ exporter._api_key_override = api_key
153
+ return exporter
154
+
155
+
156
+ def export(payload: Dict[str, Any]) -> bool:
157
+ return _default_exporter.export(payload)
158
+
159
+
160
+ def flush(timeout: float = DEFAULT_FLUSH_TIMEOUT) -> bool:
161
+ return _default_exporter.flush(timeout)
162
+
163
+
164
+ def _flush_at_exit() -> None:
165
+ try:
166
+ _default_exporter.flush(DEFAULT_FLUSH_TIMEOUT)
167
+ except Exception:
168
+ pass
169
+
170
+
171
+ atexit.register(_flush_at_exit)
severn/tracer.py ADDED
@@ -0,0 +1,433 @@
1
+ from __future__ import annotations
2
+
3
+ import functools
4
+ import inspect
5
+ import os
6
+ import sys
7
+ import time
8
+ import traceback
9
+ import uuid
10
+ from contextvars import ContextVar
11
+ from datetime import datetime, timezone
12
+ from typing import Any, Callable, Dict, Iterable, Optional, TypeVar, Union
13
+
14
+ from . import __version__
15
+ from . import exporter as _exporter
16
+
17
+ F = TypeVar("F", bound=Callable[..., Any])
18
+
19
+ SUCCESS = "SUCCESS"
20
+ FAILED = "FAILED"
21
+
22
+ _SENSITIVE_FRAGMENTS = (
23
+ "password",
24
+ "passwd",
25
+ "secret",
26
+ "token",
27
+ "api_key",
28
+ "apikey",
29
+ "authorization",
30
+ "credential",
31
+ "private_key",
32
+ )
33
+ _REDACTED = "***REDACTED***"
34
+ _TERMINAL_PREVIEW_CHARS = 80
35
+
36
+ _RESET = "\033[0m"
37
+ _BOLD = "\033[1m"
38
+ _DIM = "\033[2m"
39
+ _RED = "\033[31m"
40
+ _GREEN = "\033[32m"
41
+
42
+
43
+ class _SpanContext:
44
+ __slots__ = ("trace_id", "span_id", "depth")
45
+
46
+ def __init__(self, trace_id: str, span_id: str, depth: int) -> None:
47
+ self.trace_id = trace_id
48
+ self.span_id = span_id
49
+ self.depth = depth
50
+
51
+
52
+ _current_span: "ContextVar[Optional[_SpanContext]]" = ContextVar(
53
+ "severn_current_span", default=None
54
+ )
55
+
56
+
57
+ def _preview(value: Any, limit: int) -> str:
58
+ try:
59
+ text = repr(value)
60
+ except Exception:
61
+ text = "<unrepresentable %s>" % type(value).__name__
62
+ text = text.replace("\r", " ").replace("\n", " ")
63
+ if len(text) > limit:
64
+ extra = len(text) - limit
65
+ text = text[:limit] + "...(+%d chars)" % extra
66
+ return text
67
+
68
+
69
+ def _is_sensitive(name: str, extra: frozenset) -> bool:
70
+ lowered = name.lower()
71
+ if lowered in extra:
72
+ return True
73
+ return any(fragment in lowered for fragment in _SENSITIVE_FRAGMENTS)
74
+
75
+
76
+ def _capture_args(
77
+ signature: Optional[inspect.Signature],
78
+ args: tuple,
79
+ kwargs: dict,
80
+ limit: int,
81
+ redact: frozenset,
82
+ ) -> Dict[str, str]:
83
+ captured: Dict[str, str] = {}
84
+
85
+ def put(name: str, value: Any) -> None:
86
+ if _is_sensitive(name, redact):
87
+ captured[name] = _REDACTED
88
+ else:
89
+ captured[name] = _preview(value, limit)
90
+
91
+ bound = None
92
+ if signature is not None:
93
+ try:
94
+ bound = signature.bind_partial(*args, **kwargs)
95
+ except TypeError:
96
+ bound = None
97
+
98
+ if bound is None:
99
+ for index, value in enumerate(args):
100
+ put("arg%d" % index, value)
101
+ for key, value in kwargs.items():
102
+ put(str(key), value)
103
+ return captured
104
+
105
+ for name, value in bound.arguments.items():
106
+ kind = signature.parameters[name].kind # type: ignore[union-attr]
107
+ if kind is inspect.Parameter.VAR_KEYWORD and isinstance(value, dict):
108
+ for key, item in value.items():
109
+ put(str(key), item)
110
+ elif name in ("self", "cls"):
111
+ continue
112
+ else:
113
+ put(name, value)
114
+ return captured
115
+
116
+
117
+ def _format_duration(ms: float) -> str:
118
+ if ms < 1000:
119
+ return "%.1fms" % ms
120
+ return "%.2fs" % (ms / 1000.0)
121
+
122
+
123
+ def _error_type_name(exc: BaseException) -> str:
124
+ cls = type(exc)
125
+ if cls.__module__ in ("builtins", "__main__"):
126
+ return cls.__qualname__
127
+ return "%s.%s" % (cls.__module__, cls.__qualname__)
128
+
129
+
130
+ def _color_enabled(stream: Any) -> bool:
131
+ if os.environ.get("NO_COLOR"):
132
+ return False
133
+ if os.environ.get("FORCE_COLOR"):
134
+ return True
135
+ try:
136
+ return bool(stream.isatty())
137
+ except Exception:
138
+ return False
139
+
140
+
141
+ def _symbols(stream: Any):
142
+ encoding = getattr(stream, "encoding", None) or "ascii"
143
+ try:
144
+ "✔✘→".encode(encoding)
145
+ return "✔", "✘", "→"
146
+ except Exception:
147
+ return "OK", "FAIL", "->"
148
+
149
+
150
+ def _quiet(override: Optional[bool]) -> bool:
151
+ if override is not None:
152
+ return override
153
+ return os.environ.get("SEVERN_QUIET", "").strip().lower() in (
154
+ "1",
155
+ "true",
156
+ "yes",
157
+ "on",
158
+ )
159
+
160
+
161
+ class _Config:
162
+ __slots__ = (
163
+ "step",
164
+ "signature",
165
+ "capture_args",
166
+ "capture_output",
167
+ "max_preview",
168
+ "quiet",
169
+ "show_traceback",
170
+ "redact",
171
+ )
172
+
173
+ def __init__(
174
+ self,
175
+ step: str,
176
+ signature: Optional[inspect.Signature],
177
+ capture_args: bool,
178
+ capture_output: bool,
179
+ max_preview: int,
180
+ quiet: Optional[bool],
181
+ show_traceback: bool,
182
+ redact: frozenset,
183
+ ) -> None:
184
+ self.step = step
185
+ self.signature = signature
186
+ self.capture_args = capture_args
187
+ self.capture_output = capture_output
188
+ self.max_preview = max_preview
189
+ self.quiet = quiet
190
+ self.show_traceback = show_traceback
191
+ self.redact = redact
192
+
193
+
194
+ class _Span:
195
+ def __init__(self, config: _Config, args: tuple, kwargs: dict) -> None:
196
+ self.config = config
197
+ self.args_snapshot: Dict[str, str] = {}
198
+ self.trace_id = ""
199
+ self.span_id = ""
200
+ self.parent_span_id: Optional[str] = None
201
+ self.depth = 0
202
+ self.timestamp = ""
203
+ self._token: Any = None
204
+ self._t0 = time.perf_counter()
205
+ try:
206
+ if config.capture_args:
207
+ self.args_snapshot = _capture_args(
208
+ config.signature, args, kwargs, config.max_preview, config.redact
209
+ )
210
+ parent = _current_span.get()
211
+ self.trace_id = parent.trace_id if parent else uuid.uuid4().hex
212
+ self.span_id = uuid.uuid4().hex[:16]
213
+ self.parent_span_id = parent.span_id if parent else None
214
+ self.depth = parent.depth + 1 if parent else 0
215
+ self.timestamp = datetime.now(timezone.utc).isoformat(
216
+ timespec="milliseconds"
217
+ )
218
+ self._token = _current_span.set(
219
+ _SpanContext(self.trace_id, self.span_id, self.depth)
220
+ )
221
+ except Exception:
222
+ pass
223
+ self._t0 = time.perf_counter()
224
+
225
+ def succeed(self, result: Any) -> None:
226
+ latency_ms = (time.perf_counter() - self._t0) * 1000.0
227
+ self._finish(SUCCESS, latency_ms, result=result, exc=None)
228
+
229
+ def fail(self, exc: BaseException) -> None:
230
+ latency_ms = (time.perf_counter() - self._t0) * 1000.0
231
+ self._finish(FAILED, latency_ms, result=None, exc=exc)
232
+
233
+ def _finish(
234
+ self,
235
+ status: str,
236
+ latency_ms: float,
237
+ result: Any,
238
+ exc: Optional[BaseException],
239
+ ) -> None:
240
+ try:
241
+ if self._token is not None:
242
+ try:
243
+ _current_span.reset(self._token)
244
+ except ValueError:
245
+ pass
246
+ self._token = None
247
+
248
+ config = self.config
249
+ output_preview: Optional[str] = None
250
+ error: Optional[Dict[str, str]] = None
251
+
252
+ if exc is None:
253
+ if config.capture_output:
254
+ output_preview = _preview(result, config.max_preview)
255
+ else:
256
+ tb = exc.__traceback__
257
+ inner = tb.tb_next if tb is not None and tb.tb_next is not None else tb
258
+ try:
259
+ message = str(exc)
260
+ except Exception:
261
+ message = "<unprintable %s>" % type(exc).__name__
262
+ error = {
263
+ "type": _error_type_name(exc),
264
+ "message": message,
265
+ "stacktrace": "".join(
266
+ traceback.format_exception(type(exc), exc, inner)
267
+ ),
268
+ }
269
+
270
+ payload: Dict[str, Any] = {
271
+ "sdk": "severn-trace",
272
+ "sdk_version": __version__,
273
+ "trace_id": self.trace_id,
274
+ "span_id": self.span_id,
275
+ "parent_span_id": self.parent_span_id,
276
+ "step": config.step,
277
+ "status": status,
278
+ "timestamp": self.timestamp,
279
+ "latency_ms": round(latency_ms, 3),
280
+ "args": self.args_snapshot if config.capture_args else None,
281
+ "output_preview": output_preview,
282
+ "error": error,
283
+ }
284
+
285
+ self._print(status, latency_ms, output_preview, error, exc)
286
+ _exporter.export(payload)
287
+ except Exception:
288
+ pass
289
+
290
+ def _print(
291
+ self,
292
+ status: str,
293
+ latency_ms: float,
294
+ output_preview: Optional[str],
295
+ error: Optional[Dict[str, str]],
296
+ exc: Optional[BaseException],
297
+ ) -> None:
298
+ config = self.config
299
+ if _quiet(config.quiet):
300
+ return
301
+ stream = sys.stderr
302
+ use_color = _color_enabled(stream)
303
+ ok_sym, fail_sym, arrow = _symbols(stream)
304
+
305
+ def paint(code: str, text: str) -> str:
306
+ return code + text + _RESET if use_color else text
307
+
308
+ indent = " " * self.depth
309
+ duration = _format_duration(latency_ms)
310
+ lines = []
311
+
312
+ if status == SUCCESS:
313
+ head = "%s%s %s %s" % (
314
+ indent,
315
+ paint(_GREEN, ok_sym),
316
+ paint(_BOLD, config.step),
317
+ paint(_DIM, duration),
318
+ )
319
+ if output_preview is not None:
320
+ head += " %s %s" % (
321
+ paint(_DIM, arrow),
322
+ paint(_DIM, _truncate(output_preview, _TERMINAL_PREVIEW_CHARS)),
323
+ )
324
+ lines.append(head)
325
+ else:
326
+ assert error is not None
327
+ lines.append(
328
+ "%s%s %s %s %s %s: %s"
329
+ % (
330
+ indent,
331
+ paint(_RED, fail_sym),
332
+ paint(_BOLD, config.step),
333
+ paint(_RED, "FAILED"),
334
+ paint(_DIM, duration),
335
+ paint(_RED, error["type"]),
336
+ _truncate(error["message"].replace("\n", " "), 200),
337
+ )
338
+ )
339
+ location = _innermost_location(exc)
340
+ if location:
341
+ lines.append("%s %s" % (indent, paint(_DIM, "at " + location)))
342
+ if config.show_traceback:
343
+ for raw in error["stacktrace"].rstrip().splitlines():
344
+ lines.append("%s %s" % (indent, paint(_DIM, raw)))
345
+
346
+ try:
347
+ stream.write("\n".join(lines) + "\n")
348
+ stream.flush()
349
+ except Exception:
350
+ pass
351
+
352
+
353
+ def _truncate(text: str, limit: int) -> str:
354
+ if len(text) <= limit:
355
+ return text
356
+ return text[: max(limit - 3, 0)] + "..."
357
+
358
+
359
+ def _innermost_location(exc: Optional[BaseException]) -> Optional[str]:
360
+ if exc is None or exc.__traceback__ is None:
361
+ return None
362
+ try:
363
+ frames = traceback.extract_tb(exc.__traceback__)
364
+ if not frames:
365
+ return None
366
+ frame = frames[-1]
367
+ return "%s:%s in %s" % (frame.filename, frame.lineno, frame.name)
368
+ except Exception:
369
+ return None
370
+
371
+
372
+ def trace(
373
+ func: Optional[F] = None,
374
+ *,
375
+ name: Optional[str] = None,
376
+ capture_args: bool = True,
377
+ capture_output: bool = True,
378
+ max_preview: int = 200,
379
+ quiet: Optional[bool] = None,
380
+ show_traceback: bool = False,
381
+ redact: Iterable[str] = (),
382
+ ) -> Union[F, Callable[[F], F]]:
383
+ redact_names = frozenset(str(item).lower() for item in redact)
384
+
385
+ def decorator(target: F) -> F:
386
+ try:
387
+ signature: Optional[inspect.Signature] = inspect.signature(target)
388
+ except (TypeError, ValueError):
389
+ signature = None
390
+
391
+ config = _Config(
392
+ step=name or getattr(target, "__qualname__", None) or repr(target),
393
+ signature=signature,
394
+ capture_args=capture_args,
395
+ capture_output=capture_output,
396
+ max_preview=max(int(max_preview), 1),
397
+ quiet=quiet,
398
+ show_traceback=show_traceback,
399
+ redact=redact_names,
400
+ )
401
+
402
+ if inspect.iscoroutinefunction(target):
403
+ @functools.wraps(target)
404
+ async def async_wrapper(*args: Any, **kwargs: Any) -> Any:
405
+ span = _Span(config, args, kwargs)
406
+ try:
407
+ result = await target(*args, **kwargs)
408
+ except BaseException as exc:
409
+ span.fail(exc)
410
+ raise
411
+ span.succeed(result)
412
+ return result
413
+
414
+ async_wrapper.__severn_traced__ = True # type: ignore[attr-defined]
415
+ return async_wrapper # type: ignore[return-value]
416
+
417
+ @functools.wraps(target)
418
+ def sync_wrapper(*args: Any, **kwargs: Any) -> Any:
419
+ span = _Span(config, args, kwargs)
420
+ try:
421
+ result = target(*args, **kwargs)
422
+ except BaseException as exc:
423
+ span.fail(exc)
424
+ raise
425
+ span.succeed(result)
426
+ return result
427
+
428
+ sync_wrapper.__severn_traced__ = True # type: ignore[attr-defined]
429
+ return sync_wrapper # type: ignore[return-value]
430
+
431
+ if func is not None:
432
+ return decorator(func)
433
+ return decorator
@@ -0,0 +1,3 @@
1
+ Metadata-Version: 2.4
2
+ Name: severn-trace
3
+ Version: 0.1.0
@@ -0,0 +1,9 @@
1
+ severn/__init__.py,sha256=Dgj5UM_OiC4vuU4nwSTccMns6HQ6CmH9lCz4dUgx5Ww,348
2
+ severn/cli.py,sha256=31zB_9BzD-1hxVJB-Q-u8FMQhw7Vf_PRUHXVvzku8M0,12484
3
+ severn/exporter.py,sha256=wzaUiiYwcx2reesrqBMTP2nmR8NDNPIjYTw8g5AH3nA,4898
4
+ severn/tracer.py,sha256=CEA5V9xVEjw9nBBmkDoRa3eUmyVAd3fnAgZmcAg7P9w,12957
5
+ severn_trace-0.1.0.dist-info/METADATA,sha256=hIwPPxrLMmpidkdm7RuHFTnUg-xUagW7qNCVviGgyo8,56
6
+ severn_trace-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
7
+ severn_trace-0.1.0.dist-info/entry_points.txt,sha256=GtvAEwz1EapkVVNQ80181vrFaaK0ougC2v1B5UuDZsc,55
8
+ severn_trace-0.1.0.dist-info/top_level.txt,sha256=lNiy40FjePfjV_RUlCZOkyDencIbCJunDncFmLtJRcc,7
9
+ severn_trace-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ severn = severn.cli:interactive_menu
@@ -0,0 +1 @@
1
+ severn