proofstep 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.
- proofstep/__init__.py +192 -0
- proofstep/client.py +209 -0
- proofstep/config.py +152 -0
- proofstep/context.py +74 -0
- proofstep/decorators.py +170 -0
- proofstep/exporter.py +276 -0
- proofstep/propagation.py +49 -0
- proofstep/py.typed +0 -0
- proofstep/recorder.py +366 -0
- proofstep/redaction.py +22 -0
- proofstep/safety.py +119 -0
- proofstep-0.1.0.dist-info/METADATA +58 -0
- proofstep-0.1.0.dist-info/RECORD +14 -0
- proofstep-0.1.0.dist-info/WHEEL +4 -0
proofstep/recorder.py
ADDED
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
"""Mutable span and trace recorders that produce the frozen shared types.
|
|
2
|
+
|
|
3
|
+
The recorder is what user code touches; `Span`/`Trace` from `proofstep-types` are
|
|
4
|
+
the immutable snapshots it emits. Keeping those two apart is what lets the wire
|
|
5
|
+
format stay frozen and validated while the in-flight object stays cheap to mutate.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import secrets
|
|
11
|
+
import threading
|
|
12
|
+
from datetime import UTC, datetime
|
|
13
|
+
from decimal import Decimal
|
|
14
|
+
from typing import TYPE_CHECKING, Any
|
|
15
|
+
|
|
16
|
+
from proofstep.safety import log_once, never_raises
|
|
17
|
+
from proofstep_core.redaction import RedactionPipeline
|
|
18
|
+
from proofstep_types import CaptureMode, Span, SpanEvent, SpanType, Status, TokenUsage, Trace
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
from proofstep.config import Config
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def new_trace_id() -> str:
|
|
25
|
+
return secrets.token_hex(16) # 32 hex chars, W3C-compatible
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def new_span_id() -> str:
|
|
29
|
+
return secrets.token_hex(8) # 16 hex chars
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _now() -> datetime:
|
|
33
|
+
return datetime.now(UTC)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class SpanRecorder:
|
|
37
|
+
"""One operation in progress."""
|
|
38
|
+
|
|
39
|
+
__slots__ = (
|
|
40
|
+
"_ended",
|
|
41
|
+
"_trace",
|
|
42
|
+
"args",
|
|
43
|
+
"attributes",
|
|
44
|
+
"cost",
|
|
45
|
+
"depth",
|
|
46
|
+
"ended_at",
|
|
47
|
+
"error_type",
|
|
48
|
+
"events",
|
|
49
|
+
"input",
|
|
50
|
+
"model",
|
|
51
|
+
"name",
|
|
52
|
+
"output",
|
|
53
|
+
"parent_span_id",
|
|
54
|
+
"provider",
|
|
55
|
+
"sequence_index",
|
|
56
|
+
"span_id",
|
|
57
|
+
"span_type",
|
|
58
|
+
"started_at",
|
|
59
|
+
"status",
|
|
60
|
+
"status_message",
|
|
61
|
+
"tokens",
|
|
62
|
+
"tool_name",
|
|
63
|
+
"trace_id",
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
def __init__(
|
|
67
|
+
self,
|
|
68
|
+
name: str,
|
|
69
|
+
*,
|
|
70
|
+
trace: TraceRecorder,
|
|
71
|
+
span_type: SpanType = SpanType.CUSTOM,
|
|
72
|
+
parent_span_id: str | None = None,
|
|
73
|
+
span_id: str | None = None,
|
|
74
|
+
tool_name: str | None = None,
|
|
75
|
+
depth: int = 0,
|
|
76
|
+
) -> None:
|
|
77
|
+
self._trace = trace
|
|
78
|
+
self._ended = False
|
|
79
|
+
self.span_id = span_id or new_span_id()
|
|
80
|
+
self.trace_id = trace.trace_id
|
|
81
|
+
self.parent_span_id = parent_span_id
|
|
82
|
+
self.name = name
|
|
83
|
+
self.span_type = span_type
|
|
84
|
+
self.tool_name = tool_name
|
|
85
|
+
self.depth = depth
|
|
86
|
+
self.started_at = _now()
|
|
87
|
+
self.ended_at: datetime | None = None
|
|
88
|
+
self.status = Status.OK
|
|
89
|
+
self.status_message: str | None = None
|
|
90
|
+
self.attributes: dict[str, Any] = {}
|
|
91
|
+
self.input: Any = None
|
|
92
|
+
self.output: Any = None
|
|
93
|
+
self.args: dict[str, Any] | None = None
|
|
94
|
+
self.events: list[SpanEvent] = []
|
|
95
|
+
self.model: str | None = None
|
|
96
|
+
self.provider: str | None = None
|
|
97
|
+
self.tokens: TokenUsage | None = None
|
|
98
|
+
self.cost: Decimal | None = None
|
|
99
|
+
self.error_type: str | None = None
|
|
100
|
+
self.sequence_index = trace.next_sequence()
|
|
101
|
+
|
|
102
|
+
# ------------------------------------------------------------------- user API
|
|
103
|
+
|
|
104
|
+
@never_raises()
|
|
105
|
+
def set_input(self, value: Any) -> None:
|
|
106
|
+
self.input = value
|
|
107
|
+
|
|
108
|
+
@never_raises()
|
|
109
|
+
def set_output(self, value: Any) -> None:
|
|
110
|
+
self.output = value
|
|
111
|
+
|
|
112
|
+
@never_raises()
|
|
113
|
+
def set_args(self, value: dict[str, Any]) -> None:
|
|
114
|
+
self.args = value
|
|
115
|
+
|
|
116
|
+
@never_raises()
|
|
117
|
+
def set_attribute(self, key: str, value: Any) -> None:
|
|
118
|
+
self.attributes[key] = value
|
|
119
|
+
|
|
120
|
+
@never_raises()
|
|
121
|
+
def set_attributes(self, **values: Any) -> None:
|
|
122
|
+
self.attributes.update(values)
|
|
123
|
+
|
|
124
|
+
@never_raises()
|
|
125
|
+
def set_model(
|
|
126
|
+
self,
|
|
127
|
+
model: str,
|
|
128
|
+
*,
|
|
129
|
+
provider: str | None = None,
|
|
130
|
+
prompt_tokens: int = 0,
|
|
131
|
+
completion_tokens: int = 0,
|
|
132
|
+
cost: Decimal | float | None = None,
|
|
133
|
+
) -> None:
|
|
134
|
+
self.model = model
|
|
135
|
+
self.provider = provider
|
|
136
|
+
self.tokens = TokenUsage(
|
|
137
|
+
prompt=prompt_tokens,
|
|
138
|
+
completion=completion_tokens,
|
|
139
|
+
total=prompt_tokens + completion_tokens,
|
|
140
|
+
)
|
|
141
|
+
if cost is not None:
|
|
142
|
+
# str() first: Decimal(float) inherits the float's imprecision, and cost
|
|
143
|
+
# totals that drift are worse than no cost at all.
|
|
144
|
+
self.cost = cost if isinstance(cost, Decimal) else Decimal(str(cost))
|
|
145
|
+
|
|
146
|
+
@never_raises()
|
|
147
|
+
def record_event(self, name: str, **attributes: Any) -> None:
|
|
148
|
+
self.events.append(SpanEvent(name=name, timestamp=_now(), attributes=attributes))
|
|
149
|
+
|
|
150
|
+
@never_raises()
|
|
151
|
+
def set_error(self, exc: BaseException) -> None:
|
|
152
|
+
self.status = Status.ERROR
|
|
153
|
+
self.error_type = type(exc).__name__
|
|
154
|
+
self.status_message = str(exc)[:1000]
|
|
155
|
+
|
|
156
|
+
@never_raises()
|
|
157
|
+
def end(self, *, status: Status | None = None) -> None:
|
|
158
|
+
if self._ended:
|
|
159
|
+
return
|
|
160
|
+
self._ended = True
|
|
161
|
+
self.ended_at = _now()
|
|
162
|
+
if status is not None:
|
|
163
|
+
self.status = status
|
|
164
|
+
self._trace.finish_span(self)
|
|
165
|
+
|
|
166
|
+
# ---------------------------------------------------------------- conversion
|
|
167
|
+
|
|
168
|
+
def snapshot(self, pipeline: RedactionPipeline) -> Span:
|
|
169
|
+
redactions = 0
|
|
170
|
+
payloads: dict[str, Any] = {}
|
|
171
|
+
|
|
172
|
+
for field in ("input", "output", "args"):
|
|
173
|
+
value = getattr(self, field)
|
|
174
|
+
if value is None:
|
|
175
|
+
continue
|
|
176
|
+
payloads[field] = pipeline.apply(value, path=field)
|
|
177
|
+
redactions += pipeline.count
|
|
178
|
+
|
|
179
|
+
attributes = pipeline.apply(self.attributes, path="attributes") or {}
|
|
180
|
+
redactions += pipeline.count
|
|
181
|
+
|
|
182
|
+
return Span(
|
|
183
|
+
span_id=self.span_id,
|
|
184
|
+
trace_id=self.trace_id,
|
|
185
|
+
parent_span_id=self.parent_span_id,
|
|
186
|
+
name=self.name,
|
|
187
|
+
span_type=self.span_type,
|
|
188
|
+
status=self.status,
|
|
189
|
+
status_message=self.status_message,
|
|
190
|
+
started_at=self.started_at,
|
|
191
|
+
ended_at=self.ended_at,
|
|
192
|
+
attributes=attributes,
|
|
193
|
+
input=payloads.get("input"),
|
|
194
|
+
output=payloads.get("output"),
|
|
195
|
+
events=self.events,
|
|
196
|
+
model=self.model,
|
|
197
|
+
provider=self.provider,
|
|
198
|
+
tokens=self.tokens,
|
|
199
|
+
cost=self.cost,
|
|
200
|
+
tool_name=self.tool_name,
|
|
201
|
+
tool_args=payloads.get("args"),
|
|
202
|
+
error_type=self.error_type,
|
|
203
|
+
sequence_index=self.sequence_index,
|
|
204
|
+
redaction_count=redactions,
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
def __repr__(self) -> str:
|
|
208
|
+
return f"<Span {self.name!r} {self.span_id} {self.status.value}>"
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
class TraceRecorder:
|
|
212
|
+
"""One workflow execution in progress."""
|
|
213
|
+
|
|
214
|
+
def __init__(
|
|
215
|
+
self,
|
|
216
|
+
name: str,
|
|
217
|
+
*,
|
|
218
|
+
config: Config,
|
|
219
|
+
trace_id: str | None = None,
|
|
220
|
+
parent_span_id: str | None = None,
|
|
221
|
+
sampled: bool = True,
|
|
222
|
+
) -> None:
|
|
223
|
+
self.trace_id = trace_id or new_trace_id()
|
|
224
|
+
self.name = name
|
|
225
|
+
self.config = config
|
|
226
|
+
self.sampled = sampled
|
|
227
|
+
self.root_parent_span_id = parent_span_id
|
|
228
|
+
self.started_at = _now()
|
|
229
|
+
self.ended_at: datetime | None = None
|
|
230
|
+
self.status = Status.OK
|
|
231
|
+
self.metadata: dict[str, Any] = {}
|
|
232
|
+
self.tags: dict[str, str] = {}
|
|
233
|
+
self.state: dict[str, Any] = {}
|
|
234
|
+
self.dropped_span_count = 0
|
|
235
|
+
|
|
236
|
+
self._spans: list[SpanRecorder] = []
|
|
237
|
+
self._open = 0
|
|
238
|
+
self._sequence = 0
|
|
239
|
+
self._lock = threading.Lock()
|
|
240
|
+
|
|
241
|
+
def next_sequence(self) -> int:
|
|
242
|
+
with self._lock:
|
|
243
|
+
self._sequence += 1
|
|
244
|
+
return self._sequence
|
|
245
|
+
|
|
246
|
+
def register(self, span: SpanRecorder) -> bool:
|
|
247
|
+
"""Accept a span unless the trace is already at its cap."""
|
|
248
|
+
with self._lock:
|
|
249
|
+
if len(self._spans) >= self.config.max_spans_per_trace:
|
|
250
|
+
self.dropped_span_count += 1
|
|
251
|
+
log_once(
|
|
252
|
+
"trace.span_cap",
|
|
253
|
+
f"trace {self.name!r} hit max_spans_per_trace "
|
|
254
|
+
f"({self.config.max_spans_per_trace}); further spans are dropped "
|
|
255
|
+
"and counted in dropped_span_count",
|
|
256
|
+
)
|
|
257
|
+
return False
|
|
258
|
+
self._spans.append(span)
|
|
259
|
+
self._open += 1
|
|
260
|
+
return True
|
|
261
|
+
|
|
262
|
+
def finish_span(self, _span: SpanRecorder) -> None:
|
|
263
|
+
with self._lock:
|
|
264
|
+
self._open = max(0, self._open - 1)
|
|
265
|
+
|
|
266
|
+
@never_raises()
|
|
267
|
+
def set_metadata(self, **values: Any) -> None:
|
|
268
|
+
self.metadata.update(values)
|
|
269
|
+
|
|
270
|
+
@never_raises()
|
|
271
|
+
def set_tags(self, **values: str) -> None:
|
|
272
|
+
self.tags.update({k: str(v) for k, v in values.items()})
|
|
273
|
+
|
|
274
|
+
@never_raises()
|
|
275
|
+
def set_state(self, **values: Any) -> None:
|
|
276
|
+
"""Explicit workflow state.
|
|
277
|
+
|
|
278
|
+
Exists so `final_state` and `conditional` policy rules have a defined data
|
|
279
|
+
source instead of scraping outputs for something that looks like a status.
|
|
280
|
+
"""
|
|
281
|
+
self.state.update(values)
|
|
282
|
+
|
|
283
|
+
@never_raises()
|
|
284
|
+
def end(self, *, status: Status | None = None) -> None:
|
|
285
|
+
if self.ended_at is not None:
|
|
286
|
+
return
|
|
287
|
+
self.ended_at = _now()
|
|
288
|
+
if status is not None:
|
|
289
|
+
self.status = status
|
|
290
|
+
elif any(s.status is Status.ERROR for s in self._spans):
|
|
291
|
+
self.status = Status.ERROR
|
|
292
|
+
|
|
293
|
+
@property
|
|
294
|
+
def open_span_count(self) -> int:
|
|
295
|
+
return self._open
|
|
296
|
+
|
|
297
|
+
def snapshot(self) -> Trace:
|
|
298
|
+
"""Freeze into the shared `Trace` type, applying redaction."""
|
|
299
|
+
pipeline = RedactionPipeline(
|
|
300
|
+
redactors=[*_configured_redactors(self.config)],
|
|
301
|
+
capture_mode=self.config.capture_mode,
|
|
302
|
+
max_field_bytes=self.config.max_field_bytes,
|
|
303
|
+
)
|
|
304
|
+
spans = [s.snapshot(pipeline) for s in list(self._spans)]
|
|
305
|
+
|
|
306
|
+
# Trace-level fields go through the same pipeline as span payloads. They are
|
|
307
|
+
# user-supplied values like any other, and a credential passed to
|
|
308
|
+
# `set_metadata` is exactly as much of a leak as one in a span input.
|
|
309
|
+
#
|
|
310
|
+
# `metadata_only` capture is the exception: it suppresses payloads, but
|
|
311
|
+
# metadata, tags and state *are* the metadata, so they survive with secrets
|
|
312
|
+
# still stripped.
|
|
313
|
+
keep_structure = pipeline.capture_mode is CaptureMode.METADATA_ONLY
|
|
314
|
+
scrub = _MetadataScrubber(self.config, keep_structure=keep_structure)
|
|
315
|
+
|
|
316
|
+
return Trace(
|
|
317
|
+
trace_id=self.trace_id,
|
|
318
|
+
name=self.name,
|
|
319
|
+
status=self.status,
|
|
320
|
+
started_at=self.started_at,
|
|
321
|
+
ended_at=self.ended_at,
|
|
322
|
+
spans=spans,
|
|
323
|
+
metadata=scrub(self.metadata, "metadata"),
|
|
324
|
+
tags={k: str(v) for k, v in scrub(self.tags, "tags").items()},
|
|
325
|
+
state=scrub(self.state, "state"),
|
|
326
|
+
environment=self.config.environment,
|
|
327
|
+
git_commit=self.config.git_commit,
|
|
328
|
+
dropped_span_count=self.dropped_span_count,
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
def __repr__(self) -> str:
|
|
332
|
+
return f"<Trace {self.name!r} {self.trace_id} spans={len(self._spans)}>"
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def _configured_redactors(config: Config) -> list[Any]:
|
|
336
|
+
from proofstep import redaction # noqa: PLC0415 — breaks an import cycle
|
|
337
|
+
|
|
338
|
+
# Always includes the secret redactors. `full` capture means full *payloads*,
|
|
339
|
+
# never full credentials, and returning an empty list here previously left
|
|
340
|
+
# trace-level metadata unredacted in that mode.
|
|
341
|
+
extra = list(redaction.default())
|
|
342
|
+
if config.redact_keys:
|
|
343
|
+
extra.append(redaction.keys(config.redact_keys))
|
|
344
|
+
return extra
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
class _MetadataScrubber:
|
|
348
|
+
"""Redacts trace-level dictionaries.
|
|
349
|
+
|
|
350
|
+
Uses a pipeline pinned to REDACTED rather than the project's capture mode, so
|
|
351
|
+
that `metadata_only` still keeps its metadata while `disabled` drops everything.
|
|
352
|
+
"""
|
|
353
|
+
|
|
354
|
+
def __init__(self, config: Config, *, keep_structure: bool) -> None:
|
|
355
|
+
mode = CaptureMode.REDACTED if keep_structure else config.capture_mode
|
|
356
|
+
self._pipeline = RedactionPipeline(
|
|
357
|
+
redactors=[*_configured_redactors(config)],
|
|
358
|
+
capture_mode=CaptureMode.REDACTED if mode.stores_payloads or keep_structure else mode,
|
|
359
|
+
max_field_bytes=config.max_field_bytes,
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
def __call__(self, value: dict[str, Any], path: str) -> dict[str, Any]:
|
|
363
|
+
if not value:
|
|
364
|
+
return {}
|
|
365
|
+
result = self._pipeline.apply(value, path=path)
|
|
366
|
+
return result if isinstance(result, dict) else {}
|
proofstep/redaction.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Redaction, re-exported from `proofstep_core`.
|
|
2
|
+
|
|
3
|
+
The pipeline used to live here, in the client SDK. That was the wrong home the moment the server
|
|
4
|
+
started using it too: redaction has to produce *identical* output on both sides — a field the SDK
|
|
5
|
+
strips and the server keeps is a secret that leaks — and the only way to guarantee that is one
|
|
6
|
+
implementation, not two that agree today. But a server importing its own client SDK is backwards,
|
|
7
|
+
and it pulled `httpx` into the API image to get a module that needs nothing but `re` and `math`.
|
|
8
|
+
|
|
9
|
+
So it now lives in `proofstep_core`, which both sides already depend on and which is a pure library
|
|
10
|
+
by contract (see `.importlinter`). This module stays as a re-export because
|
|
11
|
+
`from proofstep.redaction import RedactionPipeline` is a documented import that users have written.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from proofstep_core import redaction as _redaction
|
|
17
|
+
from proofstep_core.redaction import * # noqa: F403
|
|
18
|
+
|
|
19
|
+
# `import *` skips anything the source module did not name in `__all__`, and it does not carry the
|
|
20
|
+
# module's own `__all__` across. Deriving it here means this shim exports exactly what the real
|
|
21
|
+
# module exports, and keeps doing so when a name is added there.
|
|
22
|
+
__all__ = [name for name in vars(_redaction) if not name.startswith("_")]
|
proofstep/safety.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""The never-raise guarantee.
|
|
2
|
+
|
|
3
|
+
A telemetry library that can crash the host application is unusable, and this is
|
|
4
|
+
non-negotiable. Every public entry point is wrapped: an internal error is logged
|
|
5
|
+
once per window and the call returns a harmless value.
|
|
6
|
+
|
|
7
|
+
Logging is rate-limited for the same reason. A library that logs once per span
|
|
8
|
+
during an outage produces more damage than the outage — it fills the disk, floods
|
|
9
|
+
the aggregator, and buries the real error.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import functools
|
|
15
|
+
import logging
|
|
16
|
+
import os
|
|
17
|
+
import threading
|
|
18
|
+
import time
|
|
19
|
+
from collections.abc import Callable
|
|
20
|
+
from typing import Any, Literal, TypeVar
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger("proofstep")
|
|
23
|
+
|
|
24
|
+
T = TypeVar("T")
|
|
25
|
+
|
|
26
|
+
# One message per (site, window). 60s is long enough that a sustained failure is
|
|
27
|
+
# visible roughly once a minute rather than once a span.
|
|
28
|
+
_LOG_WINDOW_S = 60.0
|
|
29
|
+
_last_logged: dict[str, float] = {}
|
|
30
|
+
_lock = threading.Lock()
|
|
31
|
+
|
|
32
|
+
_STRICT = os.environ.get("PROOFSTEP_STRICT", "").strip().lower() in ("1", "true", "yes")
|
|
33
|
+
"""Re-raise internal errors instead of swallowing them.
|
|
34
|
+
|
|
35
|
+
For our own test suite only. Swallowing bugs is correct in production and
|
|
36
|
+
catastrophic in tests, where it would let the SDK silently record nothing while
|
|
37
|
+
every assertion about behaviour still passed.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def log_once(key: str, message: str, *, exc_info: bool = False) -> None:
|
|
42
|
+
"""Log at most once per key per window."""
|
|
43
|
+
now = time.monotonic()
|
|
44
|
+
with _lock:
|
|
45
|
+
last = _last_logged.get(key)
|
|
46
|
+
if last is not None and now - last < _LOG_WINDOW_S:
|
|
47
|
+
return
|
|
48
|
+
_last_logged[key] = now
|
|
49
|
+
logger.warning("proofstep: %s", message, exc_info=exc_info)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def reset_log_throttle() -> None:
|
|
53
|
+
"""Test helper."""
|
|
54
|
+
with _lock:
|
|
55
|
+
_last_logged.clear()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def never_raises(default: Any = None, *, key: str | None = None) -> Callable[..., Any]:
|
|
59
|
+
"""Decorator: swallow every exception and return `default`.
|
|
60
|
+
|
|
61
|
+
`BaseException` subclasses that are not `Exception` — KeyboardInterrupt,
|
|
62
|
+
SystemExit, and crucially `asyncio.CancelledError` — are deliberately not
|
|
63
|
+
caught. Swallowing cancellation would break the caller's control flow, which is
|
|
64
|
+
the opposite of staying out of the way.
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
def decorate(fn: Callable[..., Any]) -> Callable[..., Any]:
|
|
68
|
+
site = key or f"{fn.__module__}.{fn.__qualname__}"
|
|
69
|
+
|
|
70
|
+
@functools.wraps(fn)
|
|
71
|
+
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
72
|
+
try:
|
|
73
|
+
return fn(*args, **kwargs)
|
|
74
|
+
except Exception as exc:
|
|
75
|
+
if _STRICT:
|
|
76
|
+
raise
|
|
77
|
+
log_once(site, f"{site} failed: {type(exc).__name__}: {exc}", exc_info=True)
|
|
78
|
+
return default() if callable(default) else default
|
|
79
|
+
|
|
80
|
+
return wrapper
|
|
81
|
+
|
|
82
|
+
return decorate
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class _NoOp:
|
|
86
|
+
"""Returned when the SDK cannot produce a real object.
|
|
87
|
+
|
|
88
|
+
Absorbs every attribute access, call, and context-manager use, so user code
|
|
89
|
+
written against a working SDK keeps running against a broken one.
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
__slots__ = ()
|
|
93
|
+
|
|
94
|
+
def __getattr__(self, _name: str) -> _NoOp:
|
|
95
|
+
return self
|
|
96
|
+
|
|
97
|
+
def __call__(self, *_args: Any, **_kwargs: Any) -> _NoOp:
|
|
98
|
+
return self
|
|
99
|
+
|
|
100
|
+
def __enter__(self) -> _NoOp:
|
|
101
|
+
return self
|
|
102
|
+
|
|
103
|
+
def __exit__(self, *_exc: object) -> Literal[False]:
|
|
104
|
+
return False # never suppress the caller's exception
|
|
105
|
+
|
|
106
|
+
async def __aenter__(self) -> _NoOp:
|
|
107
|
+
return self
|
|
108
|
+
|
|
109
|
+
async def __aexit__(self, *_exc: object) -> Literal[False]:
|
|
110
|
+
return False
|
|
111
|
+
|
|
112
|
+
def __bool__(self) -> bool:
|
|
113
|
+
return False
|
|
114
|
+
|
|
115
|
+
def __repr__(self) -> str:
|
|
116
|
+
return "<proofstep disabled>"
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
NOOP = _NoOp()
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: proofstep
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Proofstep Python SDK — tracing and evaluation for AI applications and agents
|
|
5
|
+
Project-URL: Homepage, https://github.com/IlaKhan17/proofstep
|
|
6
|
+
Project-URL: Documentation, https://github.com/IlaKhan17/proofstep/tree/main/docs
|
|
7
|
+
Project-URL: Repository, https://github.com/IlaKhan17/proofstep
|
|
8
|
+
Project-URL: Issues, https://github.com/IlaKhan17/proofstep/issues
|
|
9
|
+
License-Expression: Apache-2.0
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
16
|
+
Classifier: Topic :: Software Development :: Testing
|
|
17
|
+
Classifier: Typing :: Typed
|
|
18
|
+
Requires-Python: >=3.11
|
|
19
|
+
Requires-Dist: httpx>=0.27
|
|
20
|
+
Requires-Dist: proofstep-core
|
|
21
|
+
Requires-Dist: proofstep-types
|
|
22
|
+
Requires-Dist: pydantic>=2.9
|
|
23
|
+
Provides-Extra: eval
|
|
24
|
+
Requires-Dist: proofstep-trajectory; extra == 'eval'
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
27
|
+
# proofstep
|
|
28
|
+
|
|
29
|
+
**Tracing SDK** — part of [Proofstep](https://github.com/IlaKhan17/proofstep), the CI gate for AI
|
|
30
|
+
agents that knows the difference between a regression and a bad day.
|
|
31
|
+
|
|
32
|
+
Instrument an AI application or agent, and send the trace to a Proofstep server.
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
import proofstep
|
|
36
|
+
|
|
37
|
+
proofstep.init(endpoint="https://proofstep.internal", api_key="ps_prod_…")
|
|
38
|
+
|
|
39
|
+
with proofstep.capture("outbound") as captured:
|
|
40
|
+
proofstep.set_state(unsubscribed=False)
|
|
41
|
+
with proofstep.start_span("gmail.send", span_type="tool", tool_name="gmail.send") as span:
|
|
42
|
+
span.set_args({"to": recipient, "thread_id": thread})
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
`set_args` is not decoration: trajectory policies match on `args.*`, so a check whose result never
|
|
46
|
+
reaches the trace cannot be audited later.
|
|
47
|
+
|
|
48
|
+
Secrets are redacted in this process, before export. Access tokens, refresh tokens, API keys,
|
|
49
|
+
passwords, session cookies, and `Authorization` headers are never intentionally stored.
|
|
50
|
+
|
|
51
|
+
Install the evaluation extras with `pip install "proofstep[eval]"`, or the CLI with
|
|
52
|
+
`pip install proofstep-cli`.
|
|
53
|
+
|
|
54
|
+
## Documentation
|
|
55
|
+
|
|
56
|
+
Full documentation lives in the [repository](https://github.com/IlaKhan17/proofstep/tree/main/docs).
|
|
57
|
+
|
|
58
|
+
Apache-2.0.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
proofstep/__init__.py,sha256=_vIQXcrEplF_NlQIfOwzZQXjsgsVbvQyj60ALs_tKjU,5621
|
|
2
|
+
proofstep/client.py,sha256=-QNYUsJeXGLUIK669zcF-A8dkdtfhUXqXHWGkn8Owlo,7208
|
|
3
|
+
proofstep/config.py,sha256=giJYU2OeYZfBPlm3O6Cywddmp0Uw_GPlzcsKBomgBxM,5401
|
|
4
|
+
proofstep/context.py,sha256=3aPEruyQImyf1z3hzMfLxod18V2Egsl93TUTqFtGMcI,2309
|
|
5
|
+
proofstep/decorators.py,sha256=pT5OuZUUlMrNZ8FmHkp70s3xkDrCF0HUmwgvs3zPuAc,5823
|
|
6
|
+
proofstep/exporter.py,sha256=YQKFgrxpGXM_j89jIMcGTs4MmoAmDHmWheUAOCInlcA,10348
|
|
7
|
+
proofstep/propagation.py,sha256=dim9s14-1kw6czGKw3eSS1ZnYERKaRns7eOdSDEEIkQ,1641
|
|
8
|
+
proofstep/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
9
|
+
proofstep/recorder.py,sha256=S4HW8zrfPTZVOrIul3JWgsmwIFftteQiE-HHTXaOKek,12115
|
|
10
|
+
proofstep/redaction.py,sha256=bsqW9SSN9FiKYG4bVahZ4zf01Jr5RTwsEJoEj7AtuIE,1296
|
|
11
|
+
proofstep/safety.py,sha256=gvOksXMWu_wRFxCvH4puyaCYIJQeTr4KcC10HzCuBlw,3641
|
|
12
|
+
proofstep-0.1.0.dist-info/METADATA,sha256=DCJ0DnMQCb9B3vFike71v8uYLCITREwcO1bMF9Ko5As,2293
|
|
13
|
+
proofstep-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
14
|
+
proofstep-0.1.0.dist-info/RECORD,,
|