steplot 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.
- steplot/__init__.py +20 -0
- steplot/display.py +71 -0
- steplot/models.py +213 -0
- steplot/py.typed +0 -0
- steplot/storage.py +51 -0
- steplot/tracker.py +268 -0
- steplot-0.1.0.dist-info/METADATA +124 -0
- steplot-0.1.0.dist-info/RECORD +10 -0
- steplot-0.1.0.dist-info/WHEEL +4 -0
- steplot-0.1.0.dist-info/licenses/LICENSE +21 -0
steplot/__init__.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""steplot — lightweight agent step tracker with decorator and logging support."""
|
|
2
|
+
|
|
3
|
+
from .display import display_run
|
|
4
|
+
from .storage import load_run, save_run
|
|
5
|
+
from .tracker import get_current_run, get_current_step, log_event, reset, run_context, step_context, track
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"track",
|
|
9
|
+
"run_context",
|
|
10
|
+
"step_context",
|
|
11
|
+
"log_event",
|
|
12
|
+
"get_current_run",
|
|
13
|
+
"get_current_step",
|
|
14
|
+
"reset",
|
|
15
|
+
"display_run",
|
|
16
|
+
"save_run",
|
|
17
|
+
"load_run",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
__version__ = "0.1.0"
|
steplot/display.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Terminal display for steplot runs — renders the run tree to stdout."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .models import Event, Run, Step, StepStatus
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _status_annotation(step: Step) -> str:
|
|
9
|
+
"""Return a short status suffix for a step when it isn't a clean success."""
|
|
10
|
+
if step.status == StepStatus.FAILED:
|
|
11
|
+
return " ✗"
|
|
12
|
+
if step.status == StepStatus.RUNNING:
|
|
13
|
+
return " ..."
|
|
14
|
+
return ""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _format_duration(seconds: float | None) -> str:
|
|
18
|
+
return f"[{seconds:.2f}s]" if seconds is not None else ""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _render_step(step: Step, prefix: str, is_last: bool, lines: list[str]) -> None:
|
|
22
|
+
"""Recursively append a step and its events/children to ``lines``."""
|
|
23
|
+
branch = "└─ " if is_last else "├─ "
|
|
24
|
+
line = f"{prefix}{branch}{step.name} {_format_duration(step.duration)}{_status_annotation(step)}"
|
|
25
|
+
lines.append(line)
|
|
26
|
+
|
|
27
|
+
# Continuation prefix for events and children beneath this branch.
|
|
28
|
+
child_prefix = prefix + (" " if is_last else "│ ")
|
|
29
|
+
|
|
30
|
+
for event in step.events:
|
|
31
|
+
_render_event(event, child_prefix, lines)
|
|
32
|
+
|
|
33
|
+
for i, child in enumerate(step.children):
|
|
34
|
+
_render_step(child, child_prefix, i == len(step.children) - 1, lines)
|
|
35
|
+
|
|
36
|
+
if step.error:
|
|
37
|
+
lines.append(f"{child_prefix} error: {step.error}")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _render_event(event: Event, prefix: str, lines: list[str]) -> None:
|
|
41
|
+
lines.append(f"{prefix} • {event.message}")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def display_run(run: Run) -> None:
|
|
45
|
+
"""Pretty-print a Run tree to stdout.
|
|
46
|
+
|
|
47
|
+
Renders the run name, total duration, and each step (including nested
|
|
48
|
+
children and structured events) as a boxed tree.
|
|
49
|
+
"""
|
|
50
|
+
title = f"Run: {run.name}"
|
|
51
|
+
duration = _format_duration(run.duration)
|
|
52
|
+
if duration:
|
|
53
|
+
title += f" {duration}"
|
|
54
|
+
|
|
55
|
+
lines: list[str] = []
|
|
56
|
+
|
|
57
|
+
for i, step in enumerate(run.steps):
|
|
58
|
+
_render_step(step, " ", i == len(run.steps) - 1, lines)
|
|
59
|
+
|
|
60
|
+
for event in run.events:
|
|
61
|
+
lines.append(f" • {event.message}")
|
|
62
|
+
|
|
63
|
+
top = f"╔══ {title} ═══"
|
|
64
|
+
width = max([len(line) for line in lines] + [len(top) - 2])
|
|
65
|
+
bottom = "╚" + "═" * width + "╝"
|
|
66
|
+
|
|
67
|
+
print(top)
|
|
68
|
+
print("║")
|
|
69
|
+
for line in lines:
|
|
70
|
+
print(f"║{line}")
|
|
71
|
+
print(bottom)
|
steplot/models.py
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
"""Data models for steplot — Event, Step, Run, and supporting types."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from enum import Enum
|
|
8
|
+
from typing import Any
|
|
9
|
+
from uuid import uuid4
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class StepStatus(Enum):
|
|
13
|
+
"""Lifecycle status of a Step."""
|
|
14
|
+
|
|
15
|
+
RUNNING = "running"
|
|
16
|
+
SUCCESS = "success"
|
|
17
|
+
FAILED = "failed"
|
|
18
|
+
|
|
19
|
+
def __str__(self) -> str:
|
|
20
|
+
return self.value
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class Event:
|
|
25
|
+
"""A structured event emitted into a Step or Run.
|
|
26
|
+
|
|
27
|
+
Attributes:
|
|
28
|
+
level: Logging level (see Python's ``logging`` module).
|
|
29
|
+
message: Human-readable message for the event.
|
|
30
|
+
data: Arbitrary structured key/value payload attached to the event.
|
|
31
|
+
timestamp: Wall-clock time the event was emitted.
|
|
32
|
+
id: Stable identifier for the event.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
level: int
|
|
36
|
+
message: str
|
|
37
|
+
data: dict[str, Any] = field(default_factory=dict)
|
|
38
|
+
timestamp: datetime = field(default_factory=datetime.now)
|
|
39
|
+
id: str = field(default_factory=lambda: uuid4().hex)
|
|
40
|
+
|
|
41
|
+
def to_dict(self) -> dict[str, Any]:
|
|
42
|
+
return {
|
|
43
|
+
"id": self.id,
|
|
44
|
+
"level": self.level,
|
|
45
|
+
"message": self.message,
|
|
46
|
+
"data": self.data,
|
|
47
|
+
"timestamp": self.timestamp.isoformat() if self.timestamp else None,
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
@classmethod
|
|
51
|
+
def from_dict(cls, data: dict[str, Any]) -> Event:
|
|
52
|
+
return cls(
|
|
53
|
+
id=data.get("id", uuid4().hex),
|
|
54
|
+
level=data.get("level", 20),
|
|
55
|
+
message=data["message"],
|
|
56
|
+
data=data.get("data", {}),
|
|
57
|
+
timestamp=datetime.fromisoformat(data["timestamp"]) if data.get("timestamp") else datetime.now(),
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass
|
|
62
|
+
class Step:
|
|
63
|
+
"""A named, timed unit of work within a Run.
|
|
64
|
+
|
|
65
|
+
Steps form a tree: each step may contain nested ``children`` steps as well as
|
|
66
|
+
structured ``events``.
|
|
67
|
+
|
|
68
|
+
Attributes:
|
|
69
|
+
name: Human-readable step name (often the function name).
|
|
70
|
+
status: Current lifecycle status.
|
|
71
|
+
input: Optional input value passed to the step.
|
|
72
|
+
output: Optional output value produced by the step.
|
|
73
|
+
error: Error message if the step failed.
|
|
74
|
+
started_at: Wall-clock time the step started.
|
|
75
|
+
ended_at: Wall-clock time the step finished (None while running).
|
|
76
|
+
children: Nested child steps.
|
|
77
|
+
events: Structured events emitted while the step ran.
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
name: str
|
|
81
|
+
status: StepStatus = StepStatus.RUNNING
|
|
82
|
+
input: Any = None
|
|
83
|
+
output: Any = None
|
|
84
|
+
error: str | None = None
|
|
85
|
+
started_at: datetime = field(default_factory=datetime.now)
|
|
86
|
+
ended_at: datetime | None = None
|
|
87
|
+
id: str = field(default_factory=lambda: uuid4().hex)
|
|
88
|
+
children: list[Step] = field(default_factory=list)
|
|
89
|
+
events: list[Event] = field(default_factory=list)
|
|
90
|
+
|
|
91
|
+
@property
|
|
92
|
+
def duration(self) -> float | None:
|
|
93
|
+
"""Duration in seconds, or None while still running."""
|
|
94
|
+
if self.ended_at is None:
|
|
95
|
+
return None
|
|
96
|
+
return (self.ended_at - self.started_at).total_seconds()
|
|
97
|
+
|
|
98
|
+
def finish(self, status: StepStatus | None = None, **kwargs: Any) -> None:
|
|
99
|
+
"""Mark the step as finished with the given status and optional fields."""
|
|
100
|
+
if self.ended_at is None:
|
|
101
|
+
self.ended_at = datetime.now()
|
|
102
|
+
if status is not None:
|
|
103
|
+
self.status = status
|
|
104
|
+
for key, value in kwargs.items():
|
|
105
|
+
setattr(self, key, value)
|
|
106
|
+
|
|
107
|
+
def succeed(self, output: Any = None) -> None:
|
|
108
|
+
self.finish(status=StepStatus.SUCCESS, output=output)
|
|
109
|
+
|
|
110
|
+
def fail(self, error: str) -> None:
|
|
111
|
+
self.finish(status=StepStatus.FAILED, error=error)
|
|
112
|
+
|
|
113
|
+
def add_child(self, name: str, **kwargs: Any) -> Step:
|
|
114
|
+
"""Create and register a nested child step."""
|
|
115
|
+
child = Step(name=name, **kwargs)
|
|
116
|
+
self.children.append(child)
|
|
117
|
+
return child
|
|
118
|
+
|
|
119
|
+
def add_event(self, level: int, message: str, **data: Any) -> Event:
|
|
120
|
+
"""Record a structured event on this step."""
|
|
121
|
+
event = Event(level=level, message=message, data=data)
|
|
122
|
+
self.events.append(event)
|
|
123
|
+
return event
|
|
124
|
+
|
|
125
|
+
def to_dict(self) -> dict[str, Any]:
|
|
126
|
+
return {
|
|
127
|
+
"id": self.id,
|
|
128
|
+
"name": self.name,
|
|
129
|
+
"status": self.status.value,
|
|
130
|
+
"input": self.input,
|
|
131
|
+
"output": self.output,
|
|
132
|
+
"error": self.error,
|
|
133
|
+
"started_at": self.started_at.isoformat() if self.started_at else None,
|
|
134
|
+
"ended_at": self.ended_at.isoformat() if self.ended_at else None,
|
|
135
|
+
"children": [c.to_dict() for c in self.children],
|
|
136
|
+
"events": [e.to_dict() for e in self.events],
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
@classmethod
|
|
140
|
+
def from_dict(cls, data: dict[str, Any]) -> Step:
|
|
141
|
+
return cls(
|
|
142
|
+
id=data.get("id", uuid4().hex),
|
|
143
|
+
name=data["name"],
|
|
144
|
+
status=StepStatus(data.get("status", StepStatus.RUNNING.value)),
|
|
145
|
+
input=data.get("input"),
|
|
146
|
+
output=data.get("output"),
|
|
147
|
+
error=data.get("error"),
|
|
148
|
+
started_at=datetime.fromisoformat(data["started_at"])
|
|
149
|
+
if data.get("started_at")
|
|
150
|
+
else datetime.now(),
|
|
151
|
+
ended_at=datetime.fromisoformat(data["ended_at"]) if data.get("ended_at") else None,
|
|
152
|
+
children=[Step.from_dict(c) for c in data.get("children", [])],
|
|
153
|
+
events=[Event.from_dict(e) for e in data.get("events", [])],
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
@dataclass
|
|
158
|
+
class Run:
|
|
159
|
+
"""Top-level container for a tracked execution."""
|
|
160
|
+
|
|
161
|
+
id: str = field(default_factory=lambda: uuid4().hex)
|
|
162
|
+
name: str = "run"
|
|
163
|
+
started_at: datetime = field(default_factory=datetime.now)
|
|
164
|
+
ended_at: datetime | None = None
|
|
165
|
+
steps: list[Step] = field(default_factory=list)
|
|
166
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
167
|
+
events: list[Event] = field(default_factory=list)
|
|
168
|
+
|
|
169
|
+
@property
|
|
170
|
+
def duration(self) -> float | None:
|
|
171
|
+
if self.ended_at is None:
|
|
172
|
+
return None
|
|
173
|
+
return (self.ended_at - self.started_at).total_seconds()
|
|
174
|
+
|
|
175
|
+
def add_step(self, name: str, **kwargs: Any) -> Step:
|
|
176
|
+
step = Step(name=name, **kwargs)
|
|
177
|
+
self.steps.append(step)
|
|
178
|
+
return step
|
|
179
|
+
|
|
180
|
+
def add_event(self, level: int, message: str, **data: Any) -> Event:
|
|
181
|
+
"""Record a run-level structured event (not attached to any step)."""
|
|
182
|
+
event = Event(level=level, message=message, data=data)
|
|
183
|
+
self.events.append(event)
|
|
184
|
+
return event
|
|
185
|
+
|
|
186
|
+
def finish(self) -> None:
|
|
187
|
+
if self.ended_at is None:
|
|
188
|
+
self.ended_at = datetime.now()
|
|
189
|
+
|
|
190
|
+
def to_dict(self) -> dict[str, Any]:
|
|
191
|
+
return {
|
|
192
|
+
"id": self.id,
|
|
193
|
+
"name": self.name,
|
|
194
|
+
"started_at": self.started_at.isoformat() if self.started_at else None,
|
|
195
|
+
"ended_at": self.ended_at.isoformat() if self.ended_at else None,
|
|
196
|
+
"steps": [s.to_dict() for s in self.steps],
|
|
197
|
+
"metadata": self.metadata,
|
|
198
|
+
"events": [e.to_dict() for e in self.events],
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
@classmethod
|
|
202
|
+
def from_dict(cls, data: dict[str, Any]) -> Run:
|
|
203
|
+
return cls(
|
|
204
|
+
id=data.get("id", uuid4().hex),
|
|
205
|
+
name=data.get("name", "run"),
|
|
206
|
+
started_at=datetime.fromisoformat(data["started_at"])
|
|
207
|
+
if data.get("started_at")
|
|
208
|
+
else datetime.now(),
|
|
209
|
+
ended_at=datetime.fromisoformat(data["ended_at"]) if data.get("ended_at") else None,
|
|
210
|
+
steps=[Step.from_dict(s) for s in data.get("steps", [])],
|
|
211
|
+
metadata=data.get("metadata", {}),
|
|
212
|
+
events=[Event.from_dict(e) for e in data.get("events", [])],
|
|
213
|
+
)
|
steplot/py.typed
ADDED
|
File without changes
|
steplot/storage.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Storage for steplot runs — JSON-based."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from .models import Run, StepStatus
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _serialize(obj):
|
|
11
|
+
"""JSON default serializer for values not natively JSON-serializable."""
|
|
12
|
+
if isinstance(obj, datetime):
|
|
13
|
+
return obj.isoformat()
|
|
14
|
+
if isinstance(obj, StepStatus):
|
|
15
|
+
return obj.value
|
|
16
|
+
return str(obj)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def save_run(run: Run, path: str = ".steplot") -> str:
|
|
20
|
+
"""Save a Run to a JSON file.
|
|
21
|
+
|
|
22
|
+
The target is interpreted as a file path when it has a file suffix (for
|
|
23
|
+
example ``runs/run-1.json``), and as a directory otherwise. In the
|
|
24
|
+
directory case the file is written as ``{run.id}.json`` inside it. Parent
|
|
25
|
+
directories are created as needed.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
run: The Run to persist.
|
|
29
|
+
path: Destination file path (with suffix) or directory.
|
|
30
|
+
|
|
31
|
+
Returns:
|
|
32
|
+
The absolute path of the file that was written.
|
|
33
|
+
"""
|
|
34
|
+
target = Path(path)
|
|
35
|
+
if target.suffix:
|
|
36
|
+
# Treat as an explicit file path (e.g. "runs/run-1.json").
|
|
37
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
38
|
+
file = target
|
|
39
|
+
else:
|
|
40
|
+
# Treat as a directory; write {run.id}.json inside it.
|
|
41
|
+
target.mkdir(parents=True, exist_ok=True)
|
|
42
|
+
file = target / f"{run.id}.json"
|
|
43
|
+
|
|
44
|
+
file.write_text(json.dumps(run.to_dict(), default=_serialize, indent=2))
|
|
45
|
+
return str(file)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def load_run(path: str) -> Run:
|
|
49
|
+
"""Load a Run from a JSON file path."""
|
|
50
|
+
data = json.loads(Path(path).read_text())
|
|
51
|
+
return Run.from_dict(data)
|
steplot/tracker.py
ADDED
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
"""Core tracking logic — @track, run_context/step_context, and log_event."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import functools
|
|
6
|
+
import inspect
|
|
7
|
+
import logging
|
|
8
|
+
from collections.abc import Callable, Iterator
|
|
9
|
+
from contextlib import contextmanager
|
|
10
|
+
from contextvars import ContextVar
|
|
11
|
+
from datetime import datetime
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from steplot.models import Event, Run, Step, StepStatus
|
|
15
|
+
|
|
16
|
+
# Module-level current run and current step, stored in ContextVars for async-safety
|
|
17
|
+
_current_run: ContextVar[Run | None] = ContextVar("current_run", default=None)
|
|
18
|
+
_current_step: ContextVar[Step | None] = ContextVar("current_step", default=None)
|
|
19
|
+
|
|
20
|
+
# Logger integration
|
|
21
|
+
_logger = logging.getLogger("steplot")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def get_current_run() -> Run | None:
|
|
25
|
+
"""Return the active Run, or None if no run is active."""
|
|
26
|
+
return _current_run.get()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def get_current_step() -> Step | None:
|
|
30
|
+
"""Return the active Step, or None if no step context is active."""
|
|
31
|
+
return _current_step.get()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def reset() -> None:
|
|
35
|
+
"""Reset the global tracking state (useful between tests or runs)."""
|
|
36
|
+
_current_run.set(None)
|
|
37
|
+
_current_step.set(None)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@contextmanager
|
|
41
|
+
def run_context(name: str = "run", **metadata: Any) -> Iterator[Run]:
|
|
42
|
+
"""Context manager that creates and finishes a Run.
|
|
43
|
+
|
|
44
|
+
The run is exposed via the ``run`` returned by the context; it is also made
|
|
45
|
+
the current run so that ``@track`` and ``log_event`` calls inside the block
|
|
46
|
+
are recorded on it.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
name: Human-readable name for the run.
|
|
50
|
+
**metadata: Arbitrary key/value metadata attached to the run.
|
|
51
|
+
|
|
52
|
+
Example:
|
|
53
|
+
with run_context("my-agent", agent="research") as run:
|
|
54
|
+
do_work()
|
|
55
|
+
display_run(run)
|
|
56
|
+
"""
|
|
57
|
+
previous_run = _current_run.get()
|
|
58
|
+
previous_step = _current_step.get()
|
|
59
|
+
run = Run(name=name, metadata=metadata)
|
|
60
|
+
run_token = _current_run.set(run)
|
|
61
|
+
step_token = _current_step.set(None)
|
|
62
|
+
try:
|
|
63
|
+
yield run
|
|
64
|
+
finally:
|
|
65
|
+
run.finish()
|
|
66
|
+
_current_run.reset(run_token)
|
|
67
|
+
_current_run.set(previous_run)
|
|
68
|
+
_current_step.reset(step_token)
|
|
69
|
+
_current_step.set(previous_step)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@contextmanager
|
|
73
|
+
def step_context(name: str) -> Iterator[Step]:
|
|
74
|
+
"""Context manager that creates and finishes a Step, nesting automatically.
|
|
75
|
+
|
|
76
|
+
When used inside another step (either via ``@track`` or a parent
|
|
77
|
+
``step_context``), the new step becomes a child of the current step.
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
name: Human-readable name for the step.
|
|
81
|
+
|
|
82
|
+
Example:
|
|
83
|
+
with run_context("pipeline") as run:
|
|
84
|
+
with step_context("fetch"):
|
|
85
|
+
...
|
|
86
|
+
with step_context("process"):
|
|
87
|
+
with step_context("validate"):
|
|
88
|
+
...
|
|
89
|
+
"""
|
|
90
|
+
run, run_token = _ensure_run()
|
|
91
|
+
step = Step(name=name)
|
|
92
|
+
|
|
93
|
+
parent = _current_step.get()
|
|
94
|
+
if parent is not None:
|
|
95
|
+
parent.children.append(step)
|
|
96
|
+
else:
|
|
97
|
+
run.steps.append(step)
|
|
98
|
+
|
|
99
|
+
previous_step = _current_step.get()
|
|
100
|
+
step_token = _current_step.set(step)
|
|
101
|
+
try:
|
|
102
|
+
yield step
|
|
103
|
+
except Exception as exc: # noqa: BLE001 - always record and re-raise
|
|
104
|
+
step.fail(repr(exc))
|
|
105
|
+
raise
|
|
106
|
+
finally:
|
|
107
|
+
if step.ended_at is None:
|
|
108
|
+
step.succeed()
|
|
109
|
+
_current_step.reset(step_token)
|
|
110
|
+
_current_step.set(previous_step)
|
|
111
|
+
if run_token is not None:
|
|
112
|
+
run.finish()
|
|
113
|
+
_current_run.reset(run_token)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def log_event(level: int, message: str, **data: Any) -> Event | None:
|
|
117
|
+
"""Emit a structured event into the current step.
|
|
118
|
+
|
|
119
|
+
If a step context is active the event is stored on that step; otherwise it
|
|
120
|
+
is stored on the current run. When no run exists at all the event is only
|
|
121
|
+
passed through to the ``steplot`` logger.
|
|
122
|
+
|
|
123
|
+
Example:
|
|
124
|
+
import logging
|
|
125
|
+
log_event(logging.INFO, "processing item", item_id=42)
|
|
126
|
+
|
|
127
|
+
Returns:
|
|
128
|
+
The created :class:`~steplot.models.Event`, or None if no run existed.
|
|
129
|
+
"""
|
|
130
|
+
run = _current_run.get()
|
|
131
|
+
step = _current_step.get()
|
|
132
|
+
_logger.log(level, message)
|
|
133
|
+
if step is not None:
|
|
134
|
+
return step.add_event(level, message, **data)
|
|
135
|
+
if run is not None:
|
|
136
|
+
return run.add_event(level, message, **data)
|
|
137
|
+
return None
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def track(
|
|
141
|
+
func: Callable | None = None,
|
|
142
|
+
*,
|
|
143
|
+
step_name: str | None = None,
|
|
144
|
+
capture_args: bool = True,
|
|
145
|
+
) -> Callable:
|
|
146
|
+
"""Decorator that wraps a function in a Step and records its execution.
|
|
147
|
+
|
|
148
|
+
Works with both sync and async functions.
|
|
149
|
+
|
|
150
|
+
Args:
|
|
151
|
+
step_name: Override the step name (defaults to the function's __name__).
|
|
152
|
+
capture_args: When True (default), record the positional and keyword
|
|
153
|
+
arguments as the step's input. Set to False to skip capturing.
|
|
154
|
+
|
|
155
|
+
Usage:
|
|
156
|
+
@track
|
|
157
|
+
def my_step():
|
|
158
|
+
...
|
|
159
|
+
|
|
160
|
+
@track(step_name="custom")
|
|
161
|
+
async def another_step(x, y):
|
|
162
|
+
...
|
|
163
|
+
|
|
164
|
+
@track(capture_args=False)
|
|
165
|
+
def secret_step(x):
|
|
166
|
+
...
|
|
167
|
+
"""
|
|
168
|
+
|
|
169
|
+
def decorator(fn: Callable) -> Callable:
|
|
170
|
+
name = step_name or fn.__name__
|
|
171
|
+
|
|
172
|
+
if inspect.iscoroutinefunction(fn):
|
|
173
|
+
|
|
174
|
+
@functools.wraps(fn)
|
|
175
|
+
async def async_wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
176
|
+
return await _run_tracked_async(fn, name, args, kwargs, capture_args)
|
|
177
|
+
|
|
178
|
+
return async_wrapper
|
|
179
|
+
|
|
180
|
+
@functools.wraps(fn)
|
|
181
|
+
def sync_wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
182
|
+
return _run_tracked_sync(fn, name, args, kwargs, capture_args)
|
|
183
|
+
|
|
184
|
+
return sync_wrapper
|
|
185
|
+
|
|
186
|
+
if func is not None and callable(func):
|
|
187
|
+
# Used as @track without arguments
|
|
188
|
+
return decorator(func)
|
|
189
|
+
return decorator
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _ensure_run() -> tuple[Run, Any | None]:
|
|
193
|
+
"""Return the current run, creating one if needed.
|
|
194
|
+
|
|
195
|
+
Returns (run, token) where token is non-None if a run was created here.
|
|
196
|
+
"""
|
|
197
|
+
run = _current_run.get()
|
|
198
|
+
if run is None:
|
|
199
|
+
run = Run()
|
|
200
|
+
token = _current_run.set(run)
|
|
201
|
+
return run, token
|
|
202
|
+
return run, None
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _append_step(step: Step) -> None:
|
|
206
|
+
"""Attach a step to the current run, nesting it under the active step if any."""
|
|
207
|
+
parent = _current_step.get()
|
|
208
|
+
if parent is not None:
|
|
209
|
+
parent.children.append(step)
|
|
210
|
+
else:
|
|
211
|
+
run = _current_run.get()
|
|
212
|
+
if run is not None:
|
|
213
|
+
run.steps.append(step)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _run_tracked_sync(fn: Callable, name: str, args: tuple, kwargs: dict, capture_args: bool) -> Any:
|
|
217
|
+
run, token = _ensure_run()
|
|
218
|
+
step = Step(
|
|
219
|
+
name=name,
|
|
220
|
+
input={"args": list(args), "kwargs": kwargs} if capture_args else None,
|
|
221
|
+
)
|
|
222
|
+
_append_step(step)
|
|
223
|
+
_emit(logging.INFO, f"→ start: {name}")
|
|
224
|
+
try:
|
|
225
|
+
result = fn(*args, **kwargs)
|
|
226
|
+
step.succeed(output=result)
|
|
227
|
+
_emit(logging.INFO, f"✓ done: {name}")
|
|
228
|
+
return result
|
|
229
|
+
except Exception as exc:
|
|
230
|
+
step.fail(repr(exc))
|
|
231
|
+
_emit(logging.ERROR, f"✗ error: {name} — {exc!r}")
|
|
232
|
+
if token is not None:
|
|
233
|
+
_current_run.reset(token)
|
|
234
|
+
raise
|
|
235
|
+
finally:
|
|
236
|
+
step.ended_at = datetime.now()
|
|
237
|
+
if token is not None and step.status == StepStatus.SUCCESS:
|
|
238
|
+
_current_run.reset(token)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
async def _run_tracked_async(fn: Callable, name: str, args: tuple, kwargs: dict, capture_args: bool) -> Any:
|
|
242
|
+
run, token = _ensure_run()
|
|
243
|
+
step = Step(
|
|
244
|
+
name=name,
|
|
245
|
+
input={"args": list(args), "kwargs": kwargs} if capture_args else None,
|
|
246
|
+
)
|
|
247
|
+
_append_step(step)
|
|
248
|
+
_emit(logging.INFO, f"→ start: {name}")
|
|
249
|
+
try:
|
|
250
|
+
result = await fn(*args, **kwargs)
|
|
251
|
+
step.succeed(output=result)
|
|
252
|
+
_emit(logging.INFO, f"✓ done: {name}")
|
|
253
|
+
return result
|
|
254
|
+
except Exception as exc:
|
|
255
|
+
step.fail(repr(exc))
|
|
256
|
+
_emit(logging.ERROR, f"✗ error: {name} — {exc!r}")
|
|
257
|
+
if token is not None:
|
|
258
|
+
_current_run.reset(token)
|
|
259
|
+
raise
|
|
260
|
+
finally:
|
|
261
|
+
step.ended_at = datetime.now()
|
|
262
|
+
if token is not None and step.status == StepStatus.SUCCESS:
|
|
263
|
+
_current_run.reset(token)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _emit(level: int, message: str) -> None:
|
|
267
|
+
"""Internal helper to log without raising if no run exists."""
|
|
268
|
+
_logger.log(level, message)
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: steplot
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Lightweight observability for AI agents
|
|
5
|
+
Project-URL: Homepage, https://github.com/MohammadaminAlbooyeh/steplot
|
|
6
|
+
Project-URL: Repository, https://github.com/MohammadaminAlbooyeh/steplot
|
|
7
|
+
Project-URL: Issues, https://github.com/MohammadaminAlbooyeh/steplot/issues
|
|
8
|
+
License: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Requires-Python: >=3.12
|
|
11
|
+
Provides-Extra: dev
|
|
12
|
+
Requires-Dist: mypy>=1.10; extra == 'dev'
|
|
13
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
14
|
+
Requires-Dist: ruff>=0.5; extra == 'dev'
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
|
|
17
|
+
# steplot
|
|
18
|
+
|
|
19
|
+
Lightweight agent step tracker with decorator and logging support.
|
|
20
|
+
|
|
21
|
+
`steplot` helps you visualize and persist the execution flow of your AI agents,
|
|
22
|
+
scripts, or any multi-step pipeline. Wrap functions with `@track`, emit log
|
|
23
|
+
events with `log_event`, and render a tree of steps to the terminal or to a
|
|
24
|
+
JSON file.
|
|
25
|
+
|
|
26
|
+
## Features
|
|
27
|
+
|
|
28
|
+
- `@track` decorator -- wraps any function in a timed `Step`.
|
|
29
|
+
- `run_context` / `step_context` -- context managers for nesting.
|
|
30
|
+
- `log_event` -- emit structured events into the current step.
|
|
31
|
+
- `display_run` -- pretty-print a run tree to the terminal.
|
|
32
|
+
- `save_run` / `load_run` -- persist runs as JSON.
|
|
33
|
+
|
|
34
|
+
## Installation
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
pip install -e .
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Quick start
|
|
41
|
+
|
|
42
|
+
```python
|
|
43
|
+
from steplot import track, run_context, display_run
|
|
44
|
+
|
|
45
|
+
@track
|
|
46
|
+
def research(topic: str) -> str:
|
|
47
|
+
return f"papers about {topic}"
|
|
48
|
+
|
|
49
|
+
@track
|
|
50
|
+
def summarize(papers: str) -> str:
|
|
51
|
+
return papers.upper()
|
|
52
|
+
|
|
53
|
+
with run_context("my-agent") as run:
|
|
54
|
+
papers = research("transformers")
|
|
55
|
+
summary = summarize(papers)
|
|
56
|
+
|
|
57
|
+
display_run(run)
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Output:
|
|
61
|
+
|
|
62
|
+
```
|
|
63
|
+
╔══ Run: my-agent [0.02s] ═══
|
|
64
|
+
║
|
|
65
|
+
║ ├─ research [0.01s]
|
|
66
|
+
║ └─ summarize [0.00s]
|
|
67
|
+
╚════════════════════════════╝
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## Nested steps
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
from steplot import run_context, step_context
|
|
74
|
+
|
|
75
|
+
with run_context("pipeline") as run:
|
|
76
|
+
with step_context("fetch"):
|
|
77
|
+
...
|
|
78
|
+
with step_context("process"):
|
|
79
|
+
with step_context("validate"):
|
|
80
|
+
with step_context("score"):
|
|
81
|
+
...
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## Persisting runs
|
|
85
|
+
|
|
86
|
+
```python
|
|
87
|
+
from steplot import save_run, load_run
|
|
88
|
+
|
|
89
|
+
save_run(run, "steplot/runs/run-1.json")
|
|
90
|
+
loaded = load_run("steplot/runs/run-1.json")
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## API reference
|
|
94
|
+
|
|
95
|
+
### `track`
|
|
96
|
+
|
|
97
|
+
```python
|
|
98
|
+
@track
|
|
99
|
+
def my_step(): ...
|
|
100
|
+
|
|
101
|
+
@track(step_name="custom", capture_args=True)
|
|
102
|
+
def another_step(x, y): ...
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
### `run_context`, `step_context`
|
|
106
|
+
|
|
107
|
+
Context managers that create and automatically finish a `Run` or `Step`.
|
|
108
|
+
|
|
109
|
+
### `log_event`
|
|
110
|
+
|
|
111
|
+
```python
|
|
112
|
+
import logging
|
|
113
|
+
from steplot import log_event
|
|
114
|
+
|
|
115
|
+
log_event(logging.INFO, "processing item", item_id=42)
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
### `display_run`
|
|
119
|
+
|
|
120
|
+
Prints a tree of steps and events to stdout.
|
|
121
|
+
|
|
122
|
+
## License
|
|
123
|
+
|
|
124
|
+
MIT
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
steplot/__init__.py,sha256=jlcRqt0ZGEn20a_GMvy2XQr6S28J2BJBy9IMqoZDGMo,484
|
|
2
|
+
steplot/display.py,sha256=7PKyKEsRP5R2yqUjmtLTOQEvXM_2qO6dRO_Vl8uVWzU,2230
|
|
3
|
+
steplot/models.py,sha256=ufilJevaYsUTpevi1X0ge3teDjyon3we7xWmnOWiLyo,7599
|
|
4
|
+
steplot/storage.py,sha256=DMVtEn-fJnC07LnQrui_9pqoYqY8FGDRNZ8ceppSqlQ,1550
|
|
5
|
+
steplot/tracker.py,sha256=AUmd0e8HJANeUfxeu4zYfmm2wcemG6O2ii7i2NHOXEE,8145
|
|
6
|
+
steplot/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
+
steplot-0.1.0.dist-info/METADATA,sha256=arlWVxWxULznCWpYLYpwa87HxJ7PLsZy_VMtZuHnWv8,2746
|
|
8
|
+
steplot-0.1.0.dist-info/WHEEL,sha256=THafob7ofN-NsuMN7Mg4qZyHaQI7KkD-QlcQatYhXPo,87
|
|
9
|
+
steplot-0.1.0.dist-info/licenses/LICENSE,sha256=mgNwCgyMtHiCT4FEnUAFizIETCFVyhI7szPUujHYM_Y,1077
|
|
10
|
+
steplot-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Mohammadamin Albooyeh
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|