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/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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any