proofstep 0.1.0__tar.gz

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.
@@ -0,0 +1,57 @@
1
+
2
+ __pycache__/
3
+ .coverage
4
+ .coverage.*
5
+ .docker-data/
6
+ .DS_Store
7
+ .e2e-api.log
8
+ .env
9
+ .env.*
10
+ .env.prod
11
+ # Exceptions, and they must come *after* the patterns above: git takes the last matching rule, so a
12
+ # negation written earlier in the file is silently overridden. That is not a hypothetical — the `!`
13
+ # line used to sit at the top, `.env.*` re-ignored it, and `.env.prod.example` was never committed.
14
+ # `scripts/init_secrets.sh` reads that file, so the first documented step of self-hosting failed for
15
+ # anyone who cloned the repository. It was caught by CI running the same step.
16
+ !.env.example
17
+ !.env.prod.example
18
+ .hypothesis/
19
+ .idea/
20
+ .mypy_cache/
21
+ .next/
22
+ .proofstep/
23
+ .pytest_cache/
24
+ .ruff_cache/
25
+ .turbo/
26
+ .venv/
27
+ .vscode/
28
+ *.egg-info/
29
+ *.key
30
+ *.pem
31
+ *.proofstep.local.yaml
32
+ *.py[cod]
33
+ *.swp
34
+ *.tsbuildinfo
35
+ # Database dumps. Never committed: they contain every tenant's data, and a backup in a git history
36
+ # Docker volumes
37
+ # Editors / OS
38
+ # is a backup with no access control and no retention.
39
+ # Node
40
+ # Proofstep local state
41
+ # Python
42
+ # Reports a local run drops in the working tree. The name comes from the suite, so the pattern has
43
+ # Secrets and local config — never commit these
44
+ # The e2e stack's server log, written next to the repo so a CI failure can print it.
45
+ # to cover all of them rather than the default filename only.
46
+ ~/.proofstep/
47
+ backups/
48
+ build/
49
+ coverage.xml
50
+ credentials.json
51
+ dist/
52
+ htmlcov/
53
+ node_modules/
54
+ out/
55
+ proofstep-*.json
56
+ secrets/
57
+ venv/
@@ -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,32 @@
1
+ # proofstep
2
+
3
+ **Tracing SDK** — part of [Proofstep](https://github.com/IlaKhan17/proofstep), the CI gate for AI
4
+ agents that knows the difference between a regression and a bad day.
5
+
6
+ Instrument an AI application or agent, and send the trace to a Proofstep server.
7
+
8
+ ```python
9
+ import proofstep
10
+
11
+ proofstep.init(endpoint="https://proofstep.internal", api_key="ps_prod_…")
12
+
13
+ with proofstep.capture("outbound") as captured:
14
+ proofstep.set_state(unsubscribed=False)
15
+ with proofstep.start_span("gmail.send", span_type="tool", tool_name="gmail.send") as span:
16
+ span.set_args({"to": recipient, "thread_id": thread})
17
+ ```
18
+
19
+ `set_args` is not decoration: trajectory policies match on `args.*`, so a check whose result never
20
+ reaches the trace cannot be audited later.
21
+
22
+ Secrets are redacted in this process, before export. Access tokens, refresh tokens, API keys,
23
+ passwords, session cookies, and `Authorization` headers are never intentionally stored.
24
+
25
+ Install the evaluation extras with `pip install "proofstep[eval]"`, or the CLI with
26
+ `pip install proofstep-cli`.
27
+
28
+ ## Documentation
29
+
30
+ Full documentation lives in the [repository](https://github.com/IlaKhan17/proofstep/tree/main/docs).
31
+
32
+ Apache-2.0.
@@ -0,0 +1,52 @@
1
+ [project]
2
+ name = "proofstep"
3
+ version = "0.1.0"
4
+ description = "Proofstep Python SDK — tracing and evaluation for AI applications and agents"
5
+ readme = "README.md"
6
+ # Deliberately wider than the rest of the workspace: this installs into *users'*
7
+ # applications, and forcing an interpreter upgrade to adopt a tracing library
8
+ # is a non-starter. See ADR-002.
9
+ requires-python = ">=3.11"
10
+ license = "Apache-2.0"
11
+ classifiers = [
12
+ "Development Status :: 4 - Beta",
13
+ "Intended Audience :: Developers",
14
+ "License :: OSI Approved :: Apache Software License",
15
+ "Programming Language :: Python :: 3",
16
+ "Programming Language :: Python :: 3.12",
17
+ "Topic :: Software Development :: Testing",
18
+ "Topic :: Software Development :: Quality Assurance",
19
+ "Typing :: Typed",
20
+ ]
21
+
22
+ dependencies = [
23
+ "proofstep-types",
24
+ # For the redaction pipeline, which the server shares — see proofstep/redaction.py.
25
+ "proofstep-core",
26
+ "httpx>=0.27",
27
+ "pydantic>=2.9",
28
+ ]
29
+
30
+ [project.optional-dependencies]
31
+ # `proofstep-core` used to be here; it is a hard dependency now (redaction lives in it), so the
32
+ # extra is down to the trajectory engine. Kept named `eval` so `pip install proofstep[eval]`
33
+ # keeps working for anyone who already wrote it.
34
+ eval = ["proofstep-trajectory"]
35
+
36
+ [project.urls]
37
+ Homepage = "https://github.com/IlaKhan17/proofstep"
38
+ Documentation = "https://github.com/IlaKhan17/proofstep/tree/main/docs"
39
+ Repository = "https://github.com/IlaKhan17/proofstep"
40
+ Issues = "https://github.com/IlaKhan17/proofstep/issues"
41
+
42
+ [build-system]
43
+ requires = ["hatchling"]
44
+ build-backend = "hatchling.build"
45
+
46
+ [tool.hatch.build.targets.wheel]
47
+ packages = ["src/proofstep"]
48
+
49
+ [tool.uv.sources]
50
+ proofstep-types = { workspace = true }
51
+ proofstep-core = { workspace = true }
52
+ proofstep-trajectory = { workspace = true }
@@ -0,0 +1,192 @@
1
+ """Proofstep Python SDK — tracing for AI applications and tool-using agents.
2
+
3
+ import proofstep
4
+
5
+ proofstep.init(project="my-app")
6
+
7
+ @proofstep.trace("generate_outreach")
8
+ async def generate_outreach(prospect_id: str) -> Email:
9
+ ...
10
+
11
+ @proofstep.tool("gmail.send")
12
+ async def send_email(to: str, subject: str, body: str) -> str:
13
+ ...
14
+
15
+ Two guarantees hold everywhere in this package:
16
+
17
+ - **It never raises into your application.** Every public entry point is wrapped;
18
+ internal failures are logged once per window and return a no-op.
19
+ - **It never blocks your application.** Export is a non-blocking enqueue onto a
20
+ bounded buffer drained by a background thread. When the buffer is full it drops
21
+ the oldest trace and counts it, because visible loss beats an invisible stall.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import contextlib
27
+ from collections.abc import Iterator
28
+ from importlib import metadata as _metadata
29
+ from typing import Any
30
+
31
+ from proofstep import redaction
32
+ from proofstep.client import Captured, Client, sampled
33
+ from proofstep.config import Config
34
+ from proofstep.context import current_span, current_trace, propagate
35
+ from proofstep.decorators import make_span, make_tool, make_trace
36
+ from proofstep.propagation import extract, inject
37
+ from proofstep.recorder import SpanRecorder, TraceRecorder
38
+ from proofstep.safety import NOOP
39
+ from proofstep_types import CaptureMode, SpanType, Status, Trace
40
+
41
+ # Read from the installed distribution rather than written here twice. A hand-maintained
42
+ # copy drifts the first time a release bumps one and not the other — which it already did,
43
+ # reporting 0.1.0.dev0 from a 0.1.0 wheel.
44
+ __version__ = _metadata.version("proofstep")
45
+
46
+ _client: Client | None = None
47
+
48
+
49
+ def init(**settings: Any) -> Client:
50
+ """Configure the SDK. Safe to call more than once; the last call wins."""
51
+ global _client # noqa: PLW0603 — one process-wide client is the intended shape
52
+ _client = Client(Config.from_env(**settings))
53
+ return _client
54
+
55
+
56
+ def get_client() -> Client:
57
+ """The active client, created from the environment on first use.
58
+
59
+ Implicit initialization is deliberate: an unconfigured import must still work,
60
+ so that adding a decorator never breaks a script that has not called `init`.
61
+ """
62
+ global _client # noqa: PLW0603
63
+ if _client is None:
64
+ _client = Client(Config.from_env())
65
+ return _client
66
+
67
+
68
+ def configure(**settings: Any) -> None:
69
+ """Update settings on the existing client without replacing it."""
70
+ client = get_client()
71
+ for key, value in settings.items():
72
+ if not hasattr(client.config, key):
73
+ msg = f"unknown Proofstep setting {key!r}"
74
+ raise TypeError(msg)
75
+ setattr(client.config, key, value)
76
+
77
+
78
+ def reset() -> None:
79
+ """Drop the active client. For tests."""
80
+ global _client # noqa: PLW0603
81
+ if _client is not None:
82
+ _client.shutdown(0.1)
83
+ _client = None
84
+
85
+
86
+ trace = make_trace(get_client)
87
+ span = make_span(get_client)
88
+ tool = make_tool(span)
89
+
90
+
91
+ @contextlib.contextmanager
92
+ def start_trace(name: str, **kwargs: Any) -> Iterator[Any]:
93
+ """Context-manager form of `@trace`."""
94
+ with get_client().trace(name, **kwargs) as recorder:
95
+ yield recorder
96
+
97
+
98
+ @contextlib.contextmanager
99
+ def start_span(name: str, **kwargs: Any) -> Iterator[Any]:
100
+ """Context-manager form of `@span`."""
101
+ with get_client().span(name, **kwargs) as recorder:
102
+ yield recorder
103
+
104
+
105
+ @contextlib.contextmanager
106
+ def capture(name: str = "task", **kwargs: Any) -> Iterator[list[Trace]]:
107
+ """Record a trace and hand it back rather than only exporting it.
108
+
109
+ This is how an instrumented task feeds the local evaluation engine: the captured
110
+ `Trace` is what trajectory policies are evaluated against.
111
+
112
+ with proofstep.capture("classify") as captured:
113
+ result = await classify(example.input)
114
+ return proofstep.Captured(output=result, trace=captured[0])
115
+ """
116
+ sink: list[Trace] = []
117
+ with get_client().trace(name, **kwargs) as recorder:
118
+ yield sink
119
+ if recorder is not NOOP:
120
+ sink.append(recorder.snapshot())
121
+
122
+
123
+ def set_metadata(**values: Any) -> None:
124
+ if (active := current_trace()) is not None:
125
+ active.set_metadata(**values)
126
+
127
+
128
+ def set_tags(**values: str) -> None:
129
+ if (active := current_trace()) is not None:
130
+ active.set_tags(**values)
131
+
132
+
133
+ def set_state(**values: Any) -> None:
134
+ """Record explicit workflow state for `final_state` policy rules."""
135
+ if (active := current_trace()) is not None:
136
+ active.set_state(**values)
137
+
138
+
139
+ def record_event(name: str, **attributes: Any) -> None:
140
+ if (active := current_span()) is not None:
141
+ active.record_event(name, **attributes)
142
+
143
+
144
+ def set_attributes(**values: Any) -> None:
145
+ if (active := current_span()) is not None:
146
+ active.set_attributes(**values)
147
+
148
+
149
+ def flush(timeout: float | None = None) -> bool:
150
+ return bool(get_client().flush(timeout))
151
+
152
+
153
+ def shutdown(timeout: float | None = None) -> None:
154
+ get_client().shutdown(timeout)
155
+
156
+
157
+ __all__ = [
158
+ "NOOP",
159
+ "CaptureMode",
160
+ "Captured",
161
+ "Client",
162
+ "Config",
163
+ "SpanRecorder",
164
+ "SpanType",
165
+ "Status",
166
+ "Trace",
167
+ "TraceRecorder",
168
+ "capture",
169
+ "configure",
170
+ "current_span",
171
+ "current_trace",
172
+ "extract",
173
+ "flush",
174
+ "get_client",
175
+ "init",
176
+ "inject",
177
+ "propagate",
178
+ "record_event",
179
+ "redaction",
180
+ "reset",
181
+ "sampled",
182
+ "set_attributes",
183
+ "set_metadata",
184
+ "set_state",
185
+ "set_tags",
186
+ "shutdown",
187
+ "span",
188
+ "start_span",
189
+ "start_trace",
190
+ "tool",
191
+ "trace",
192
+ ]
@@ -0,0 +1,209 @@
1
+ """The client: owns configuration, sampling, the exporter, and span creation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import atexit
6
+ import contextlib
7
+ from collections.abc import Iterator
8
+ from dataclasses import dataclass
9
+ from typing import Any
10
+
11
+ from proofstep import context as ctx
12
+ from proofstep.config import Config
13
+ from proofstep.exporter import Exporter
14
+ from proofstep.recorder import SpanRecorder, TraceRecorder, new_trace_id
15
+ from proofstep.safety import NOOP, log_once, never_raises
16
+ from proofstep_types import SpanType, Status, Trace
17
+
18
+
19
+ @dataclass(frozen=True, slots=True)
20
+ class Captured:
21
+ """A task's return value paired with the trace it produced.
22
+
23
+ Shaped to match what the evaluation runner already looks for (`.output` and
24
+ `.trace`), so an instrumented task drops into a suite with no adapter and no
25
+ dependency from the engine back to the SDK.
26
+ """
27
+
28
+ output: Any
29
+ trace: Trace
30
+
31
+
32
+ def sampled(trace_id: str, rate: float) -> bool:
33
+ """Deterministic head sampling on the trace id.
34
+
35
+ Hash-mod rather than a coin flip per span, so a sampled trace is captured
36
+ *whole*. A half-recorded trajectory is worse than none: the policy engine would
37
+ read the gaps as evidence.
38
+ """
39
+ if rate >= 1.0:
40
+ return True
41
+ if rate <= 0.0:
42
+ return False
43
+ return (int(trace_id[:8], 16) / 0xFFFFFFFF) < rate
44
+
45
+
46
+ class Client:
47
+ def __init__(
48
+ self, config: Config | None = None, *, transport: Any = None, **overrides: object
49
+ ) -> None:
50
+ self.config = config or Config.from_env(**overrides)
51
+ self.exporter = Exporter(self.config, transport=transport)
52
+ self._atexit_registered = False
53
+
54
+ # ------------------------------------------------------------------- tracing
55
+
56
+ @never_raises(default=NOOP)
57
+ def start_trace(
58
+ self,
59
+ name: str,
60
+ *,
61
+ trace_id: str | None = None,
62
+ parent_span_id: str | None = None,
63
+ metadata: dict[str, Any] | None = None,
64
+ ) -> TraceRecorder | Any:
65
+ if not self.config.records:
66
+ return NOOP
67
+ identifier = trace_id or new_trace_id()
68
+ recorder = TraceRecorder(
69
+ name,
70
+ config=self.config,
71
+ trace_id=identifier,
72
+ parent_span_id=parent_span_id,
73
+ sampled=sampled(identifier, self.config.sample_rate),
74
+ )
75
+ if metadata:
76
+ recorder.set_metadata(**metadata)
77
+ self._register_atexit()
78
+ return recorder
79
+
80
+ @never_raises(default=NOOP)
81
+ def start_span(
82
+ self,
83
+ name: str,
84
+ *,
85
+ span_type: SpanType | str = SpanType.CUSTOM,
86
+ tool_name: str | None = None,
87
+ trace: TraceRecorder | None = None,
88
+ parent: SpanRecorder | None = None,
89
+ ) -> SpanRecorder | Any:
90
+ if not self.config.records:
91
+ return NOOP
92
+
93
+ active_trace = trace or ctx.current_trace()
94
+ if active_trace is None:
95
+ # An orphan span is more confusing than a synthetic root, and losing it
96
+ # entirely is worse than both. Create a trace so the span has somewhere
97
+ # to live, then emit it when the span closes.
98
+ #
99
+ # The common cause is a raw ThreadPoolExecutor: Python does not copy
100
+ # contextvars across threads, so the worker sees no active trace. Say so,
101
+ # because the fix (`proofstep.propagate`) is not discoverable otherwise.
102
+ log_once(
103
+ "client.orphan_span",
104
+ f"span {name!r} was created with no active trace and has been recorded "
105
+ "as its own trace. If this is a thread, wrap the callable with "
106
+ "proofstep.propagate() to keep it attached to its parent.",
107
+ )
108
+ active_trace = self.start_trace(name)
109
+ if active_trace is NOOP:
110
+ return NOOP
111
+ ctx.set_trace(active_trace)
112
+
113
+ active_parent = parent or ctx.current_span()
114
+ recorder = SpanRecorder(
115
+ name,
116
+ trace=active_trace,
117
+ span_type=SpanType(span_type),
118
+ parent_span_id=active_parent.span_id if active_parent else None,
119
+ tool_name=tool_name,
120
+ depth=(active_parent.depth + 1) if active_parent else 0,
121
+ )
122
+ if not active_trace.register(recorder):
123
+ return NOOP
124
+ return recorder
125
+
126
+ @contextlib.contextmanager
127
+ def trace(self, name: str, **kwargs: Any) -> Iterator[Any]:
128
+ recorder = self.start_trace(name, **kwargs)
129
+ if recorder is NOOP:
130
+ yield NOOP
131
+ return
132
+
133
+ token = ctx.set_trace(recorder)
134
+ try:
135
+ yield recorder
136
+ except BaseException as exc:
137
+ recorder.status = Status.ERROR
138
+ recorder.set_metadata(error=f"{type(exc).__name__}: {exc}"[:500])
139
+ raise
140
+ finally:
141
+ recorder.end()
142
+ ctx.reset_trace(token)
143
+ self.emit(recorder)
144
+
145
+ @contextlib.contextmanager
146
+ def span(self, name: str, **kwargs: Any) -> Iterator[Any]:
147
+ had_trace = ctx.current_trace() is not None
148
+ recorder = self.start_span(name, **kwargs)
149
+ if recorder is NOOP:
150
+ yield NOOP
151
+ return
152
+
153
+ token = ctx.set_span(recorder)
154
+ try:
155
+ yield recorder
156
+ except BaseException as exc:
157
+ # Record and re-raise, untouched. The traceback the user sees must be
158
+ # exactly the one their code produced.
159
+ if isinstance(exc, Exception):
160
+ recorder.set_error(exc)
161
+ else:
162
+ recorder.status = Status.ERROR
163
+ raise
164
+ finally:
165
+ recorder.end()
166
+ ctx.reset_span(token)
167
+ if not had_trace:
168
+ self._close_implicit_trace()
169
+
170
+ def _close_implicit_trace(self) -> None:
171
+ """Emit a trace `start_span` created on the caller's behalf.
172
+
173
+ Without this the span is recorded into a trace nobody ever ends, and the
174
+ data vanishes silently — the exact failure mode the SDK promises to avoid.
175
+ """
176
+ implicit = ctx.current_trace()
177
+ if implicit is None:
178
+ return
179
+ implicit.end()
180
+ self.emit(implicit)
181
+ ctx.set_trace(None)
182
+
183
+ # -------------------------------------------------------------------- export
184
+
185
+ @never_raises()
186
+ def emit(self, recorder: TraceRecorder) -> None:
187
+ """Finish a trace: snapshot it and hand it to the exporter."""
188
+ if not self.config.records:
189
+ return
190
+ keep = recorder.sampled or (
191
+ self.config.always_sample_on_error and recorder.status is Status.ERROR
192
+ )
193
+ if not keep:
194
+ return
195
+ self.exporter.submit(recorder.snapshot())
196
+
197
+ @never_raises(default=False)
198
+ def flush(self, timeout: float | None = None) -> bool:
199
+ return bool(self.exporter.flush(timeout))
200
+
201
+ @never_raises()
202
+ def shutdown(self, timeout: float | None = None) -> None:
203
+ self.exporter.shutdown(timeout)
204
+
205
+ def _register_atexit(self) -> None:
206
+ if self._atexit_registered:
207
+ return
208
+ self._atexit_registered = True
209
+ atexit.register(self.shutdown, self.config.shutdown_timeout_s)