sepurux 0.2.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.
sepurux-0.2.0/PKG-INFO ADDED
@@ -0,0 +1,123 @@
1
+ Metadata-Version: 2.4
2
+ Name: sepurux
3
+ Version: 0.2.0
4
+ Summary: Python SDK for Sepurux trace recording and uploads
5
+ Author: Sepurux
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/felixkwasisarpong/Sepurux
8
+ Requires-Python: >=3.10
9
+ Description-Content-Type: text/markdown
10
+ Requires-Dist: httpx<1,>=0.27
11
+
12
+ # Sepurux Python SDK
13
+
14
+ Python SDK for recording Sepurux traces and uploading them to a Sepurux API.
15
+
16
+ ## Install
17
+
18
+ ```bash
19
+ pip install -e sdk
20
+ ```
21
+
22
+ ## Quick start
23
+
24
+ ```python
25
+ from sepurux import SepuruxClient, sepurux_trace
26
+
27
+ client = SepuruxClient(
28
+ base_url="http://localhost:8000",
29
+ api_key="sepurux-dev-key",
30
+ project_id="22222222-2222-2222-2222-222222222222",
31
+ )
32
+
33
+ with sepurux_trace("example_task", {"user_id": "u-123"}) as rec:
34
+ rec.model_step("plan", {"goal": "create issue"}, output={"ok": True})
35
+ rec.tool_call("jira.create_issue(commit)", {"summary": "SDK test"})
36
+ rec.tool_result("jira.create_issue(commit)", {"issue_id": "OPS-123"})
37
+
38
+ trace_id = client.upload_trace(rec.to_trace())
39
+ print("trace_id:", trace_id)
40
+ ```
41
+
42
+ ## API
43
+
44
+ ### `SepuruxClient`
45
+
46
+ ```python
47
+ SepuruxClient(base_url, api_key=None, project_id=None, timeout=30, sdk_header=None)
48
+ ```
49
+
50
+ The SDK automatically sends `X-Sepurux-SDK: py/<version>` on requests.
51
+ Use `sdk_header` only if you need to override it manually.
52
+
53
+ Methods:
54
+ - `upload_trace(trace: dict) -> str`
55
+ - `create_campaign(name, mutation_set, eval_set, mutation_pack_id=None) -> str`
56
+ - `start_run(trace_id, campaign_id, thresholds=None) -> str`
57
+ - `get_run(run_id) -> dict`
58
+
59
+ ### `TraceBuilder`
60
+
61
+ `TraceBuilder` outputs backend-compatible traces:
62
+
63
+ ```json
64
+ {
65
+ "trace_version": "0.1",
66
+ "source": "sdk",
67
+ "task": {"name": "...", "input": {}},
68
+ "events": []
69
+ }
70
+ ```
71
+
72
+ ### Recorder
73
+
74
+ Use a context manager:
75
+
76
+ ```python
77
+ from sepurux import sepurux_trace
78
+
79
+ with sepurux_trace("task_name", {"input": "value"}) as rec:
80
+ rec.model_step("name", {"foo": "bar"}, output={"ok": True})
81
+ rec.tool_call("tool.name", {"arg": 1})
82
+ rec.tool_result("tool.name", {"result": "ok"})
83
+ rec.error(message="something happened", tool="tool.name")
84
+ ```
85
+
86
+ ### Decorator
87
+
88
+ ```python
89
+ from sepurux import record_trace
90
+
91
+ @record_trace(client=client, campaign_id="<campaign_id>")
92
+ def run_business_logic(x: int) -> int:
93
+ return x * 2
94
+ ```
95
+
96
+ The decorator preserves the function return value and attempts to upload/start runs in the background path without interrupting normal execution.
97
+
98
+ ## Example script
99
+
100
+ See `examples/sdk_demo.py`.
101
+
102
+ ```bash
103
+ python sdk/examples/sdk_demo.py
104
+ ```
105
+
106
+ Optional environment variables:
107
+ - `SEPURUX_API_BASE_URL` (default `http://localhost:8000`)
108
+ - `SEPURUX_UI_BASE_URL` (default `http://localhost:3000`)
109
+ - `SEPURUX_API_KEY`
110
+ - `SEPURUX_PROJECT_ID`
111
+ - `SEPURUX_CAMPAIGN_ID` (if provided, script starts a run)
112
+
113
+ ## Publish to PyPI (CI)
114
+
115
+ This repository includes `.github/workflows/sdk-publish-pypi.yml` to publish the SDK directly to PyPI.
116
+
117
+ Setup:
118
+ - Add `PYPI_API_TOKEN` in GitHub repository secrets.
119
+ - Ensure the package version in `sdk/pyproject.toml` is new.
120
+
121
+ Release:
122
+ - Push a tag like `sdk-v0.2.0` to trigger publish.
123
+ - Or run the workflow manually from GitHub Actions.
@@ -0,0 +1,112 @@
1
+ # Sepurux Python SDK
2
+
3
+ Python SDK for recording Sepurux traces and uploading them to a Sepurux API.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pip install -e sdk
9
+ ```
10
+
11
+ ## Quick start
12
+
13
+ ```python
14
+ from sepurux import SepuruxClient, sepurux_trace
15
+
16
+ client = SepuruxClient(
17
+ base_url="http://localhost:8000",
18
+ api_key="sepurux-dev-key",
19
+ project_id="22222222-2222-2222-2222-222222222222",
20
+ )
21
+
22
+ with sepurux_trace("example_task", {"user_id": "u-123"}) as rec:
23
+ rec.model_step("plan", {"goal": "create issue"}, output={"ok": True})
24
+ rec.tool_call("jira.create_issue(commit)", {"summary": "SDK test"})
25
+ rec.tool_result("jira.create_issue(commit)", {"issue_id": "OPS-123"})
26
+
27
+ trace_id = client.upload_trace(rec.to_trace())
28
+ print("trace_id:", trace_id)
29
+ ```
30
+
31
+ ## API
32
+
33
+ ### `SepuruxClient`
34
+
35
+ ```python
36
+ SepuruxClient(base_url, api_key=None, project_id=None, timeout=30, sdk_header=None)
37
+ ```
38
+
39
+ The SDK automatically sends `X-Sepurux-SDK: py/<version>` on requests.
40
+ Use `sdk_header` only if you need to override it manually.
41
+
42
+ Methods:
43
+ - `upload_trace(trace: dict) -> str`
44
+ - `create_campaign(name, mutation_set, eval_set, mutation_pack_id=None) -> str`
45
+ - `start_run(trace_id, campaign_id, thresholds=None) -> str`
46
+ - `get_run(run_id) -> dict`
47
+
48
+ ### `TraceBuilder`
49
+
50
+ `TraceBuilder` outputs backend-compatible traces:
51
+
52
+ ```json
53
+ {
54
+ "trace_version": "0.1",
55
+ "source": "sdk",
56
+ "task": {"name": "...", "input": {}},
57
+ "events": []
58
+ }
59
+ ```
60
+
61
+ ### Recorder
62
+
63
+ Use a context manager:
64
+
65
+ ```python
66
+ from sepurux import sepurux_trace
67
+
68
+ with sepurux_trace("task_name", {"input": "value"}) as rec:
69
+ rec.model_step("name", {"foo": "bar"}, output={"ok": True})
70
+ rec.tool_call("tool.name", {"arg": 1})
71
+ rec.tool_result("tool.name", {"result": "ok"})
72
+ rec.error(message="something happened", tool="tool.name")
73
+ ```
74
+
75
+ ### Decorator
76
+
77
+ ```python
78
+ from sepurux import record_trace
79
+
80
+ @record_trace(client=client, campaign_id="<campaign_id>")
81
+ def run_business_logic(x: int) -> int:
82
+ return x * 2
83
+ ```
84
+
85
+ The decorator preserves the function return value and attempts to upload/start runs in the background path without interrupting normal execution.
86
+
87
+ ## Example script
88
+
89
+ See `examples/sdk_demo.py`.
90
+
91
+ ```bash
92
+ python sdk/examples/sdk_demo.py
93
+ ```
94
+
95
+ Optional environment variables:
96
+ - `SEPURUX_API_BASE_URL` (default `http://localhost:8000`)
97
+ - `SEPURUX_UI_BASE_URL` (default `http://localhost:3000`)
98
+ - `SEPURUX_API_KEY`
99
+ - `SEPURUX_PROJECT_ID`
100
+ - `SEPURUX_CAMPAIGN_ID` (if provided, script starts a run)
101
+
102
+ ## Publish to PyPI (CI)
103
+
104
+ This repository includes `.github/workflows/sdk-publish-pypi.yml` to publish the SDK directly to PyPI.
105
+
106
+ Setup:
107
+ - Add `PYPI_API_TOKEN` in GitHub repository secrets.
108
+ - Ensure the package version in `sdk/pyproject.toml` is new.
109
+
110
+ Release:
111
+ - Push a tag like `sdk-v0.2.0` to trigger publish.
112
+ - Or run the workflow manually from GitHub Actions.
@@ -0,0 +1,27 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "sepurux"
7
+ version = "0.2.0"
8
+ description = "Python SDK for Sepurux trace recording and uploads"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = { text = "MIT" }
12
+ authors = [
13
+ { name = "Sepurux" }
14
+ ]
15
+ dependencies = [
16
+ "httpx>=0.27,<1",
17
+ ]
18
+
19
+ [project.urls]
20
+ Homepage = "https://github.com/felixkwasisarpong/Sepurux"
21
+
22
+ [tool.setuptools]
23
+ include-package-data = true
24
+
25
+ [tool.setuptools.packages.find]
26
+ where = ["."]
27
+ include = ["sepurux*"]
@@ -0,0 +1,13 @@
1
+ """Sepurux Python SDK."""
2
+
3
+ from .client import SepuruxClient
4
+ from .recorder import TraceRecorder, record_trace, sepurux_trace
5
+ from .trace import TraceBuilder
6
+
7
+ __all__ = [
8
+ "SepuruxClient",
9
+ "TraceBuilder",
10
+ "TraceRecorder",
11
+ "sepurux_trace",
12
+ "record_trace",
13
+ ]
@@ -0,0 +1,173 @@
1
+ from __future__ import annotations
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+ from typing import Any
5
+
6
+ import httpx
7
+
8
+ from .types import RunStatus, TracePayload
9
+
10
+ SDK_PACKAGE_NAME = "sepurux"
11
+
12
+
13
+ class SepuruxClient:
14
+ def __init__(
15
+ self,
16
+ base_url: str,
17
+ api_key: str | None = None,
18
+ project_id: str | None = None,
19
+ timeout: float = 30,
20
+ sdk_header: str | None = None,
21
+ ) -> None:
22
+ normalized = base_url.strip().rstrip("/")
23
+ if not normalized:
24
+ raise ValueError("base_url must be a non-empty URL")
25
+
26
+ self.base_url = normalized
27
+ self.api_key = api_key
28
+ self.project_id = project_id
29
+ self.timeout = timeout
30
+ self.sdk_header = (sdk_header or _default_sdk_header()).strip() or _default_sdk_header()
31
+
32
+ self._client = httpx.Client(
33
+ base_url=self.base_url,
34
+ timeout=timeout,
35
+ headers={"Accept": "application/json", "X-Sepurux-SDK": self.sdk_header},
36
+ )
37
+
38
+ def close(self) -> None:
39
+ self._client.close()
40
+
41
+ def upload_trace(self, trace: dict[str, Any] | TracePayload) -> str:
42
+ if not isinstance(trace, dict):
43
+ raise TypeError("trace must be a dictionary")
44
+
45
+ response = self._client.post(
46
+ "/v1/traces",
47
+ headers=self._request_headers(),
48
+ json=trace,
49
+ )
50
+ response.raise_for_status()
51
+
52
+ payload = _parse_json_response(response)
53
+ trace_id = payload.get("trace_id")
54
+ if not isinstance(trace_id, str) or not trace_id:
55
+ raise ValueError("Trace upload succeeded but trace_id was missing")
56
+ return trace_id
57
+
58
+ def create_campaign(
59
+ self,
60
+ *,
61
+ name: str,
62
+ mutation_set: Any,
63
+ eval_set: Any,
64
+ mutation_pack_id: str | None = None,
65
+ ) -> str:
66
+ payload: dict[str, Any] = {
67
+ "name": name,
68
+ "mutation_set": mutation_set,
69
+ "eval_set": eval_set,
70
+ }
71
+ if mutation_pack_id:
72
+ payload["mutation_pack_id"] = mutation_pack_id
73
+
74
+ response = self._client.post(
75
+ "/v1/campaigns",
76
+ headers=self._request_headers(),
77
+ json=payload,
78
+ )
79
+ response.raise_for_status()
80
+
81
+ data = _parse_json_response(response)
82
+ campaign_id = data.get("campaign_id")
83
+ if not isinstance(campaign_id, str) or not campaign_id:
84
+ raise ValueError("Campaign creation succeeded but campaign_id was missing")
85
+ return campaign_id
86
+
87
+ def start_run(
88
+ self,
89
+ *,
90
+ trace_id: str,
91
+ campaign_id: str,
92
+ thresholds: dict[str, Any] | None = None,
93
+ ) -> str:
94
+ run_payload = {
95
+ "trace_id": trace_id,
96
+ "campaign_id": campaign_id,
97
+ }
98
+
99
+ # Prefer standard /v1/runs. If unavailable, fallback to CI endpoint.
100
+ response = self._client.post(
101
+ "/v1/runs",
102
+ headers=self._request_headers(),
103
+ json=run_payload,
104
+ )
105
+
106
+ if response.status_code in {404, 405}:
107
+ ci_payload: dict[str, Any] = {
108
+ "trace_id": trace_id,
109
+ "campaign_id": campaign_id,
110
+ "thresholds": thresholds
111
+ or {
112
+ "min_pass_rate": 0.9,
113
+ "max_unsafe": 0,
114
+ },
115
+ }
116
+ response = self._client.post(
117
+ "/v1/ci/runs",
118
+ headers=self._request_headers(),
119
+ json=ci_payload,
120
+ )
121
+
122
+ response.raise_for_status()
123
+ payload = _parse_json_response(response)
124
+ run_id = payload.get("run_id")
125
+ if not isinstance(run_id, str) or not run_id:
126
+ raise ValueError("Run creation succeeded but run_id was missing")
127
+ return run_id
128
+
129
+ def get_run(self, run_id: str) -> RunStatus:
130
+ response = self._client.get(
131
+ f"/v1/runs/{run_id}",
132
+ headers=self._request_headers(),
133
+ )
134
+ response.raise_for_status()
135
+
136
+ payload = _parse_json_response(response)
137
+ summary = payload.get("summary")
138
+ return {
139
+ "run_id": str(payload.get("run_id") or run_id),
140
+ "status": str(payload.get("status") or "unknown"),
141
+ "summary": summary if isinstance(summary, dict) else None,
142
+ }
143
+
144
+ def __enter__(self) -> SepuruxClient:
145
+ return self
146
+
147
+ def __exit__(self, exc_type: object, exc: object, tb: object) -> None:
148
+ del exc_type, exc, tb
149
+ self.close()
150
+
151
+ def _request_headers(self) -> dict[str, str]:
152
+ headers: dict[str, str] = {}
153
+ if self.api_key:
154
+ headers["X-API-Key"] = self.api_key
155
+ if self.project_id:
156
+ headers["X-Project-Id"] = self.project_id
157
+ headers["X-Sepurux-SDK"] = self.sdk_header
158
+ return headers
159
+
160
+
161
+ def _parse_json_response(response: httpx.Response) -> dict[str, Any]:
162
+ data = response.json()
163
+ if not isinstance(data, dict):
164
+ raise ValueError("Expected JSON object response")
165
+ return data
166
+
167
+
168
+ def _default_sdk_header() -> str:
169
+ try:
170
+ sdk_version = version(SDK_PACKAGE_NAME)
171
+ except PackageNotFoundError:
172
+ sdk_version = "dev"
173
+ return f"py/{sdk_version}"
@@ -0,0 +1,114 @@
1
+ from __future__ import annotations
2
+
3
+ import functools
4
+ import inspect
5
+ from collections.abc import Callable, Iterator
6
+ from contextlib import contextmanager
7
+ from typing import TYPE_CHECKING, Any, ParamSpec, TypeVar
8
+
9
+ from .trace import TraceBuilder
10
+ from .types import TracePayload
11
+
12
+ if TYPE_CHECKING:
13
+ from .client import SepuruxClient
14
+
15
+ P = ParamSpec("P")
16
+ R = TypeVar("R")
17
+
18
+
19
+ class TraceRecorder:
20
+ """Friendly recorder wrapper around TraceBuilder."""
21
+
22
+ def __init__(
23
+ self,
24
+ task_name: str,
25
+ task_input: dict[str, Any] | None = None,
26
+ ) -> None:
27
+ self._builder = TraceBuilder(task_name=task_name, task_input=task_input)
28
+
29
+ def model_step(self, name: str, input: Any, output: Any | None = None) -> None:
30
+ self._builder.add_model_step(name=name, input=input, output=output)
31
+
32
+ def tool_call(self, tool: str, args: dict[str, Any]) -> None:
33
+ self._builder.add_tool_call(tool=tool, args=args)
34
+
35
+ def tool_result(self, tool: str, result: Any) -> None:
36
+ self._builder.add_tool_result(tool=tool, result=result)
37
+
38
+ def error(self, tool: str | None = None, message: str = "") -> None:
39
+ self._builder.add_error(message=message, tool=tool)
40
+
41
+ def to_trace(self) -> TracePayload:
42
+ return self._builder.to_trace()
43
+
44
+
45
+ @contextmanager
46
+ def sepurux_trace(
47
+ task_name: str,
48
+ task_input: dict[str, Any] | None = None,
49
+ ) -> Iterator[TraceRecorder]:
50
+ recorder = TraceRecorder(task_name=task_name, task_input=task_input)
51
+ yield recorder
52
+
53
+
54
+ def record_trace(
55
+ *,
56
+ client: SepuruxClient,
57
+ campaign_id: str | None = None,
58
+ task_name: str | None = None,
59
+ ) -> Callable[[Callable[P, R]], Callable[P, R]]:
60
+ """Decorator that records function traces and optionally starts a run."""
61
+
62
+ def decorator(func: Callable[P, R]) -> Callable[P, R]:
63
+ @functools.wraps(func)
64
+ def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
65
+ trace_task_name = task_name or func.__name__
66
+ task_input = {
67
+ "function": func.__name__,
68
+ "args": _safe_json_value(list(args)),
69
+ "kwargs": _safe_json_value(dict(kwargs)),
70
+ }
71
+
72
+ with sepurux_trace(trace_task_name, task_input=task_input) as recorder:
73
+ recorder.model_step(
74
+ "function.call",
75
+ {"qualname": getattr(func, "__qualname__", func.__name__)},
76
+ )
77
+ try:
78
+ result = func(*args, **kwargs)
79
+ except Exception as exc:
80
+ recorder.error(tool=func.__name__, message=str(exc))
81
+ raise
82
+ recorder.model_step(
83
+ "function.return", {"ok": True}, output=_safe_json_value(result)
84
+ )
85
+
86
+ trace = recorder.to_trace()
87
+
88
+ # SDK telemetry should not break business logic.
89
+ try:
90
+ trace_id = client.upload_trace(trace)
91
+ if campaign_id:
92
+ client.start_run(trace_id=trace_id, campaign_id=campaign_id)
93
+ except Exception:
94
+ pass
95
+
96
+ return result
97
+
98
+ return wrapper
99
+
100
+ return decorator
101
+
102
+
103
+ def _safe_json_value(value: Any) -> Any:
104
+ if value is None or isinstance(value, str | int | float | bool):
105
+ return value
106
+ if isinstance(value, dict):
107
+ return {str(k): _safe_json_value(v) for k, v in value.items()}
108
+ if isinstance(value, list | tuple | set):
109
+ return [_safe_json_value(v) for v in value]
110
+
111
+ if inspect.isfunction(value) or inspect.ismethod(value):
112
+ return getattr(value, "__name__", "callable")
113
+
114
+ return repr(value)
@@ -0,0 +1,94 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import UTC, datetime
4
+ from typing import Any
5
+
6
+ from .types import TraceEvent, TracePayload
7
+
8
+
9
+ class TraceBuilder:
10
+ """Builds Sepurux trace payloads in the backend-compatible schema."""
11
+
12
+ def __init__(
13
+ self,
14
+ task_name: str,
15
+ task_input: dict[str, Any] | None = None,
16
+ *,
17
+ source: str = "sdk",
18
+ trace_version: str = "0.1",
19
+ ) -> None:
20
+ if not task_name.strip():
21
+ raise ValueError("task_name must be a non-empty string")
22
+ if not source.strip():
23
+ raise ValueError("source must be a non-empty string")
24
+ if not trace_version.strip():
25
+ raise ValueError("trace_version must be a non-empty string")
26
+
27
+ self._trace_version = trace_version
28
+ self._source = source
29
+ self._task_name = task_name
30
+ self._task_input = task_input or {}
31
+ self._events: list[TraceEvent] = []
32
+
33
+ def add_model_step(
34
+ self,
35
+ name: str,
36
+ input: Any,
37
+ output: Any | None = None,
38
+ ) -> TraceBuilder:
39
+ event: TraceEvent = {
40
+ "type": "model_step",
41
+ "timestamp": _utc_now_iso(),
42
+ "name": name,
43
+ "input": input,
44
+ }
45
+ if output is not None:
46
+ event["output"] = output
47
+ self._events.append(event)
48
+ return self
49
+
50
+ def add_tool_call(self, tool: str, args: dict[str, Any]) -> TraceBuilder:
51
+ event: TraceEvent = {
52
+ "type": "tool_call",
53
+ "timestamp": _utc_now_iso(),
54
+ "tool": tool,
55
+ "args": args,
56
+ }
57
+ self._events.append(event)
58
+ return self
59
+
60
+ def add_tool_result(self, tool: str, result: Any) -> TraceBuilder:
61
+ event: TraceEvent = {
62
+ "type": "tool_result",
63
+ "timestamp": _utc_now_iso(),
64
+ "tool": tool,
65
+ "result": result,
66
+ }
67
+ self._events.append(event)
68
+ return self
69
+
70
+ def add_error(self, message: str, tool: str | None = None) -> TraceBuilder:
71
+ event: TraceEvent = {
72
+ "type": "error",
73
+ "timestamp": _utc_now_iso(),
74
+ "message": message,
75
+ }
76
+ if tool:
77
+ event["tool"] = tool
78
+ self._events.append(event)
79
+ return self
80
+
81
+ def to_trace(self) -> TracePayload:
82
+ return {
83
+ "trace_version": self._trace_version,
84
+ "source": self._source,
85
+ "task": {
86
+ "name": self._task_name,
87
+ "input": self._task_input,
88
+ },
89
+ "events": list(self._events),
90
+ }
91
+
92
+
93
+ def _utc_now_iso() -> str:
94
+ return datetime.now(UTC).isoformat()
@@ -0,0 +1,24 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, TypedDict
4
+
5
+
6
+ class TaskPayload(TypedDict):
7
+ name: str
8
+ input: dict[str, Any]
9
+
10
+
11
+ TraceEvent = dict[str, Any]
12
+
13
+
14
+ class TracePayload(TypedDict):
15
+ trace_version: str
16
+ source: str
17
+ task: TaskPayload
18
+ events: list[TraceEvent]
19
+
20
+
21
+ class RunStatus(TypedDict, total=False):
22
+ run_id: str
23
+ status: str
24
+ summary: dict[str, Any] | None
@@ -0,0 +1,123 @@
1
+ Metadata-Version: 2.4
2
+ Name: sepurux
3
+ Version: 0.2.0
4
+ Summary: Python SDK for Sepurux trace recording and uploads
5
+ Author: Sepurux
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/felixkwasisarpong/Sepurux
8
+ Requires-Python: >=3.10
9
+ Description-Content-Type: text/markdown
10
+ Requires-Dist: httpx<1,>=0.27
11
+
12
+ # Sepurux Python SDK
13
+
14
+ Python SDK for recording Sepurux traces and uploading them to a Sepurux API.
15
+
16
+ ## Install
17
+
18
+ ```bash
19
+ pip install -e sdk
20
+ ```
21
+
22
+ ## Quick start
23
+
24
+ ```python
25
+ from sepurux import SepuruxClient, sepurux_trace
26
+
27
+ client = SepuruxClient(
28
+ base_url="http://localhost:8000",
29
+ api_key="sepurux-dev-key",
30
+ project_id="22222222-2222-2222-2222-222222222222",
31
+ )
32
+
33
+ with sepurux_trace("example_task", {"user_id": "u-123"}) as rec:
34
+ rec.model_step("plan", {"goal": "create issue"}, output={"ok": True})
35
+ rec.tool_call("jira.create_issue(commit)", {"summary": "SDK test"})
36
+ rec.tool_result("jira.create_issue(commit)", {"issue_id": "OPS-123"})
37
+
38
+ trace_id = client.upload_trace(rec.to_trace())
39
+ print("trace_id:", trace_id)
40
+ ```
41
+
42
+ ## API
43
+
44
+ ### `SepuruxClient`
45
+
46
+ ```python
47
+ SepuruxClient(base_url, api_key=None, project_id=None, timeout=30, sdk_header=None)
48
+ ```
49
+
50
+ The SDK automatically sends `X-Sepurux-SDK: py/<version>` on requests.
51
+ Use `sdk_header` only if you need to override it manually.
52
+
53
+ Methods:
54
+ - `upload_trace(trace: dict) -> str`
55
+ - `create_campaign(name, mutation_set, eval_set, mutation_pack_id=None) -> str`
56
+ - `start_run(trace_id, campaign_id, thresholds=None) -> str`
57
+ - `get_run(run_id) -> dict`
58
+
59
+ ### `TraceBuilder`
60
+
61
+ `TraceBuilder` outputs backend-compatible traces:
62
+
63
+ ```json
64
+ {
65
+ "trace_version": "0.1",
66
+ "source": "sdk",
67
+ "task": {"name": "...", "input": {}},
68
+ "events": []
69
+ }
70
+ ```
71
+
72
+ ### Recorder
73
+
74
+ Use a context manager:
75
+
76
+ ```python
77
+ from sepurux import sepurux_trace
78
+
79
+ with sepurux_trace("task_name", {"input": "value"}) as rec:
80
+ rec.model_step("name", {"foo": "bar"}, output={"ok": True})
81
+ rec.tool_call("tool.name", {"arg": 1})
82
+ rec.tool_result("tool.name", {"result": "ok"})
83
+ rec.error(message="something happened", tool="tool.name")
84
+ ```
85
+
86
+ ### Decorator
87
+
88
+ ```python
89
+ from sepurux import record_trace
90
+
91
+ @record_trace(client=client, campaign_id="<campaign_id>")
92
+ def run_business_logic(x: int) -> int:
93
+ return x * 2
94
+ ```
95
+
96
+ The decorator preserves the function return value and attempts to upload/start runs in the background path without interrupting normal execution.
97
+
98
+ ## Example script
99
+
100
+ See `examples/sdk_demo.py`.
101
+
102
+ ```bash
103
+ python sdk/examples/sdk_demo.py
104
+ ```
105
+
106
+ Optional environment variables:
107
+ - `SEPURUX_API_BASE_URL` (default `http://localhost:8000`)
108
+ - `SEPURUX_UI_BASE_URL` (default `http://localhost:3000`)
109
+ - `SEPURUX_API_KEY`
110
+ - `SEPURUX_PROJECT_ID`
111
+ - `SEPURUX_CAMPAIGN_ID` (if provided, script starts a run)
112
+
113
+ ## Publish to PyPI (CI)
114
+
115
+ This repository includes `.github/workflows/sdk-publish-pypi.yml` to publish the SDK directly to PyPI.
116
+
117
+ Setup:
118
+ - Add `PYPI_API_TOKEN` in GitHub repository secrets.
119
+ - Ensure the package version in `sdk/pyproject.toml` is new.
120
+
121
+ Release:
122
+ - Push a tag like `sdk-v0.2.0` to trigger publish.
123
+ - Or run the workflow manually from GitHub Actions.
@@ -0,0 +1,14 @@
1
+ README.md
2
+ pyproject.toml
3
+ sepurux/__init__.py
4
+ sepurux/client.py
5
+ sepurux/recorder.py
6
+ sepurux/trace.py
7
+ sepurux/types.py
8
+ sepurux.egg-info/PKG-INFO
9
+ sepurux.egg-info/SOURCES.txt
10
+ sepurux.egg-info/dependency_links.txt
11
+ sepurux.egg-info/requires.txt
12
+ sepurux.egg-info/top_level.txt
13
+ tests/test_client.py
14
+ tests/test_trace_builder.py
@@ -0,0 +1 @@
1
+ httpx<1,>=0.27
@@ -0,0 +1 @@
1
+ sepurux
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,81 @@
1
+ from __future__ import annotations
2
+
3
+ import httpx
4
+ from sepurux import SepuruxClient
5
+
6
+
7
+ class _RequestCapture:
8
+ def __init__(self) -> None:
9
+ self.requests: list[httpx.Request] = []
10
+
11
+ def __call__(self, request: httpx.Request) -> httpx.Response:
12
+ self.requests.append(request)
13
+ if request.url.path == "/v1/traces":
14
+ return httpx.Response(201, json={"trace_id": "trace-123"})
15
+ return httpx.Response(404, json={"detail": "not found"})
16
+
17
+
18
+ def test_upload_trace_uses_traces_endpoint_and_headers() -> None:
19
+ capture = _RequestCapture()
20
+ transport = httpx.MockTransport(capture)
21
+
22
+ client = SepuruxClient(
23
+ base_url="http://localhost:8000",
24
+ api_key="key-1",
25
+ project_id="project-1",
26
+ )
27
+ client._client = httpx.Client(
28
+ base_url=client.base_url,
29
+ transport=transport,
30
+ timeout=30,
31
+ headers={"Accept": "application/json", "X-Sepurux-SDK": client.sdk_header},
32
+ )
33
+
34
+ trace_id = client.upload_trace(
35
+ {
36
+ "trace_version": "0.1",
37
+ "source": "sdk",
38
+ "task": {"name": "task", "input": {}},
39
+ "events": [],
40
+ }
41
+ )
42
+
43
+ assert trace_id == "trace-123"
44
+ assert len(capture.requests) == 1
45
+ request = capture.requests[0]
46
+ assert request.url.path == "/v1/traces"
47
+ assert request.headers.get("X-API-Key") == "key-1"
48
+ assert request.headers.get("X-Project-Id") == "project-1"
49
+ assert request.headers.get("X-Sepurux-SDK", "").startswith("py/")
50
+
51
+
52
+ def test_upload_trace_uses_explicit_sdk_header_override() -> None:
53
+ capture = _RequestCapture()
54
+ transport = httpx.MockTransport(capture)
55
+
56
+ client = SepuruxClient(
57
+ base_url="http://localhost:8000",
58
+ api_key="key-1",
59
+ project_id="project-1",
60
+ sdk_header="py/9.9.9",
61
+ )
62
+ client._client = httpx.Client(
63
+ base_url=client.base_url,
64
+ transport=transport,
65
+ timeout=30,
66
+ headers={"Accept": "application/json", "X-Sepurux-SDK": client.sdk_header},
67
+ )
68
+
69
+ trace_id = client.upload_trace(
70
+ {
71
+ "trace_version": "0.1",
72
+ "source": "sdk",
73
+ "task": {"name": "task", "input": {}},
74
+ "events": [],
75
+ }
76
+ )
77
+
78
+ assert trace_id == "trace-123"
79
+ assert len(capture.requests) == 1
80
+ request = capture.requests[0]
81
+ assert request.headers.get("X-Sepurux-SDK") == "py/9.9.9"
@@ -0,0 +1,20 @@
1
+ from __future__ import annotations
2
+
3
+ from sepurux import TraceBuilder
4
+
5
+
6
+ def test_trace_builder_schema_shape() -> None:
7
+ builder = TraceBuilder("demo_task", {"prompt": "hello"})
8
+ builder.add_model_step("plan", {"goal": "create issue"}, output={"ok": True})
9
+ builder.add_tool_call("jira.create_issue(commit)", {"summary": "test"})
10
+ builder.add_tool_result("jira.create_issue(commit)", {"issue_id": "OPS-123"})
11
+
12
+ trace = builder.to_trace()
13
+
14
+ assert trace["trace_version"] == "0.1"
15
+ assert trace["source"] == "sdk"
16
+ assert trace["task"] == {"name": "demo_task", "input": {"prompt": "hello"}}
17
+ assert len(trace["events"]) == 3
18
+ assert trace["events"][0]["type"] == "model_step"
19
+ assert trace["events"][1]["type"] == "tool_call"
20
+ assert trace["events"][2]["type"] == "tool_result"