runtrace-ai 0.1.2__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.
- runtrace/__init__.py +4 -0
- runtrace/cli.py +140 -0
- runtrace/client.py +260 -0
- runtrace/credentials.py +85 -0
- runtrace_ai-0.1.2.dist-info/METADATA +235 -0
- runtrace_ai-0.1.2.dist-info/RECORD +20 -0
- runtrace_ai-0.1.2.dist-info/WHEEL +4 -0
- runtrace_ai-0.1.2.dist-info/entry_points.txt +3 -0
- runtrace_ai-0.1.2.dist-info/licenses/LICENSE +661 -0
- runtrace_api/__init__.py +2 -0
- runtrace_api/auth.py +481 -0
- runtrace_api/config.py +34 -0
- runtrace_api/database.py +21 -0
- runtrace_api/embeddings.py +82 -0
- runtrace_api/main.py +1152 -0
- runtrace_api/models.py +341 -0
- runtrace_api/schemas.py +217 -0
- runtrace_api/seed.py +145 -0
- runtrace_mcp/__init__.py +2 -0
- runtrace_mcp/server.py +117 -0
runtrace/__init__.py
ADDED
runtrace/cli.py
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
import shutil
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
from typing import Annotated
|
|
9
|
+
|
|
10
|
+
import typer
|
|
11
|
+
|
|
12
|
+
from .client import RunTrace
|
|
13
|
+
from .credentials import resolve_connection, save_credentials
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
app = typer.Typer(no_args_is_help=True, help="Track experiments and query RunTrace memory.")
|
|
17
|
+
integrations_app = typer.Typer(no_args_is_help=True, help="Install RunTrace in supported agent CLIs.")
|
|
18
|
+
app.add_typer(integrations_app, name="integrations")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def client(base_url: str | None, api_token: str | None) -> RunTrace:
|
|
22
|
+
resolved_base_url, resolved_api_token = resolve_connection(base_url, api_token)
|
|
23
|
+
return RunTrace(base_url=resolved_base_url, api_token=resolved_api_token, strict=True)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@app.command()
|
|
27
|
+
def auth(
|
|
28
|
+
api_key: Annotated[str, typer.Argument(help="Agent API key, or '-' to read it from stdin.")],
|
|
29
|
+
base_url: str | None = typer.Option(None, envvar="RUNTRACE_BASE_URL", help="RunTrace API URL."),
|
|
30
|
+
) -> None:
|
|
31
|
+
"""Authenticate the CLI and MCP tool with an agent API key."""
|
|
32
|
+
if api_key == "-":
|
|
33
|
+
api_key = sys.stdin.readline().strip()
|
|
34
|
+
if not api_key:
|
|
35
|
+
raise typer.BadParameter("API key cannot be empty")
|
|
36
|
+
|
|
37
|
+
resolved_base_url, _ = resolve_connection(base_url, api_key)
|
|
38
|
+
status = RunTrace(
|
|
39
|
+
base_url=resolved_base_url,
|
|
40
|
+
api_token=api_key,
|
|
41
|
+
strict=True,
|
|
42
|
+
).request("GET", "/api/v1/auth/status")
|
|
43
|
+
if not status.get("authenticated"):
|
|
44
|
+
raise typer.BadParameter("RunTrace rejected this API key")
|
|
45
|
+
|
|
46
|
+
path = save_credentials(resolved_base_url, api_key)
|
|
47
|
+
typer.echo(f"Authenticated with {resolved_base_url}. Credentials saved to {path}")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@app.command()
|
|
51
|
+
def search(
|
|
52
|
+
project: str,
|
|
53
|
+
query: str,
|
|
54
|
+
base_url: str | None = typer.Option(None, envvar="RUNTRACE_BASE_URL"),
|
|
55
|
+
api_token: str | None = typer.Option(None, envvar="RUNTRACE_API_TOKEN", hidden=True),
|
|
56
|
+
limit: int = 10,
|
|
57
|
+
) -> None:
|
|
58
|
+
result = client(base_url, api_token).search(project, query, limit)
|
|
59
|
+
typer.echo(json.dumps(result, indent=2, default=str))
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@app.command("context")
|
|
63
|
+
def context_command(
|
|
64
|
+
project: str,
|
|
65
|
+
base_url: str | None = typer.Option(None, envvar="RUNTRACE_BASE_URL"),
|
|
66
|
+
api_token: str | None = typer.Option(None, envvar="RUNTRACE_API_TOKEN", hidden=True),
|
|
67
|
+
) -> None:
|
|
68
|
+
result = client(base_url, api_token).request("GET", f"/api/v1/projects/{project}/context")
|
|
69
|
+
typer.echo(json.dumps(result, indent=2, default=str))
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@app.command(
|
|
73
|
+
context_settings={"allow_extra_args": True, "ignore_unknown_options": True},
|
|
74
|
+
help="Run a command and track structured RUNTRACE_METRIC/RUNTRACE_EVENT output.",
|
|
75
|
+
)
|
|
76
|
+
def exec(
|
|
77
|
+
ctx: typer.Context,
|
|
78
|
+
project: str = typer.Option(...),
|
|
79
|
+
name: str = typer.Option(...),
|
|
80
|
+
hypothesis: str = typer.Option(...),
|
|
81
|
+
reasoning: str = typer.Option(""),
|
|
82
|
+
base_url: str | None = typer.Option(None, envvar="RUNTRACE_BASE_URL"),
|
|
83
|
+
api_token: str | None = typer.Option(None, envvar="RUNTRACE_API_TOKEN", hidden=True),
|
|
84
|
+
) -> None:
|
|
85
|
+
command = list(ctx.args)
|
|
86
|
+
if command and command[0] == "--":
|
|
87
|
+
command = command[1:]
|
|
88
|
+
if not command:
|
|
89
|
+
raise typer.BadParameter("Provide a command after --")
|
|
90
|
+
rt = client(base_url, api_token)
|
|
91
|
+
with rt.run(project, name, hypothesis, reasoning, command=" ".join(command)) as tracked:
|
|
92
|
+
process = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1)
|
|
93
|
+
assert process.stdout
|
|
94
|
+
metric_pattern = re.compile(r"^RUNTRACE_METRIC\s+(\S+)=([+-]?[\d.]+)(?:\s+step=(\d+))?")
|
|
95
|
+
event_pattern = re.compile(r'^RUNTRACE_EVENT(?:\s+level=(\w+))?\s+message=["\']?(.*?)["\']?$')
|
|
96
|
+
for line in process.stdout:
|
|
97
|
+
sys.stdout.write(line)
|
|
98
|
+
if match := metric_pattern.match(line.strip()):
|
|
99
|
+
tracked.log_metric(match.group(1), float(match.group(2)), int(match.group(3)) if match.group(3) else None)
|
|
100
|
+
elif match := event_pattern.match(line.strip()):
|
|
101
|
+
tracked.log_event(match.group(2), match.group(1) or "info")
|
|
102
|
+
code = process.wait()
|
|
103
|
+
if code == 0:
|
|
104
|
+
tracked.finish("undecided", result_summary="Command completed successfully")
|
|
105
|
+
else:
|
|
106
|
+
tracked.abort(f"Command exited {code}")
|
|
107
|
+
raise typer.Exit(code)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@integrations_app.command("install")
|
|
111
|
+
def install_integration(
|
|
112
|
+
host: Annotated[str, typer.Argument(help="Agent CLI to configure: codex or claude")],
|
|
113
|
+
ref: str = typer.Option("master", help="RunTrace Git ref to install."),
|
|
114
|
+
dry_run: bool = typer.Option(False, help="Print commands without changing host configuration."),
|
|
115
|
+
) -> None:
|
|
116
|
+
"""Install the RunTrace plugin from its public repository marketplace."""
|
|
117
|
+
host = host.lower()
|
|
118
|
+
if host not in {"codex", "claude"}:
|
|
119
|
+
raise typer.BadParameter("Host must be 'codex' or 'claude'")
|
|
120
|
+
if not shutil.which(host):
|
|
121
|
+
raise typer.BadParameter(f"{host} is not installed or is not on PATH")
|
|
122
|
+
if host == "codex":
|
|
123
|
+
commands = [
|
|
124
|
+
["codex", "plugin", "marketplace", "add", "vano04/RunTrace", "--ref", ref],
|
|
125
|
+
["codex", "plugin", "add", "runtrace@runtrace"],
|
|
126
|
+
]
|
|
127
|
+
else:
|
|
128
|
+
commands = [
|
|
129
|
+
["claude", "plugin", "marketplace", "add", "vano04/RunTrace"],
|
|
130
|
+
["claude", "plugin", "install", "runtrace@runtrace", "--scope", "user"],
|
|
131
|
+
]
|
|
132
|
+
for command in commands:
|
|
133
|
+
typer.echo("$ " + " ".join(command))
|
|
134
|
+
if not dry_run:
|
|
135
|
+
subprocess.run(command, check=True)
|
|
136
|
+
typer.echo("RunTrace plugin installed. Run 'runtrace auth API_KEY' to authenticate it.")
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
if __name__ == "__main__":
|
|
140
|
+
app()
|
runtrace/client.py
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import atexit
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import platform
|
|
7
|
+
import socket
|
|
8
|
+
import subprocess
|
|
9
|
+
import sys
|
|
10
|
+
import threading
|
|
11
|
+
import time
|
|
12
|
+
import uuid
|
|
13
|
+
import warnings
|
|
14
|
+
from collections import deque
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
import httpx
|
|
19
|
+
import psutil
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _git(args: list[str], cwd: str) -> str | None:
|
|
23
|
+
try:
|
|
24
|
+
return subprocess.check_output(["git", *args], cwd=cwd, text=True, stderr=subprocess.DEVNULL, timeout=2).strip()
|
|
25
|
+
except (OSError, subprocess.SubprocessError):
|
|
26
|
+
return None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _metadata(cwd: str) -> dict[str, Any]:
|
|
30
|
+
return {
|
|
31
|
+
"host_metadata": {
|
|
32
|
+
"hostname": socket.gethostname(),
|
|
33
|
+
"platform": platform.platform(),
|
|
34
|
+
"cpu_count": psutil.cpu_count(),
|
|
35
|
+
"memory_bytes": psutil.virtual_memory().total,
|
|
36
|
+
},
|
|
37
|
+
"environment_metadata": {
|
|
38
|
+
"python": sys.version.split()[0],
|
|
39
|
+
"executable": sys.executable,
|
|
40
|
+
"argv": sys.argv,
|
|
41
|
+
},
|
|
42
|
+
"git_commit": _git(["rev-parse", "HEAD"], cwd),
|
|
43
|
+
"git_branch": _git(["branch", "--show-current"], cwd),
|
|
44
|
+
"git_dirty": bool(_git(["status", "--porcelain"], cwd)),
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class RunTrace:
|
|
49
|
+
def __init__(
|
|
50
|
+
self,
|
|
51
|
+
base_url: str = "http://localhost:8000",
|
|
52
|
+
api_token: str | None = None,
|
|
53
|
+
strict: bool = False,
|
|
54
|
+
timeout: float = 5.0,
|
|
55
|
+
*,
|
|
56
|
+
api_key: str | None = None,
|
|
57
|
+
):
|
|
58
|
+
self.base_url = base_url.rstrip("/")
|
|
59
|
+
self.api_token = api_token or api_key
|
|
60
|
+
self.strict = strict
|
|
61
|
+
self.client = httpx.Client(
|
|
62
|
+
base_url=self.base_url,
|
|
63
|
+
timeout=timeout,
|
|
64
|
+
headers={"Authorization": f"Bearer {self.api_token}"} if self.api_token else {},
|
|
65
|
+
)
|
|
66
|
+
self._buffer: deque[tuple[str, str, dict[str, Any], str]] = deque(maxlen=2000)
|
|
67
|
+
self._lock = threading.Lock()
|
|
68
|
+
atexit.register(self.flush)
|
|
69
|
+
|
|
70
|
+
def request(self, method: str, path: str, payload: dict[str, Any] | None = None, *, buffer: bool = False, request_id: str | None = None) -> Any:
|
|
71
|
+
request_id = request_id or str(uuid.uuid4())
|
|
72
|
+
try:
|
|
73
|
+
response = self.client.request(method, path, json=payload, headers={"X-Request-ID": request_id})
|
|
74
|
+
response.raise_for_status()
|
|
75
|
+
return response.json() if response.content else None
|
|
76
|
+
except (httpx.HTTPError, OSError):
|
|
77
|
+
if buffer:
|
|
78
|
+
with self._lock:
|
|
79
|
+
self._buffer.append((method, path, payload or {}, request_id))
|
|
80
|
+
warnings.warn(f"RunTrace is unavailable; buffered {method} {path}", RuntimeWarning, stacklevel=2)
|
|
81
|
+
return None
|
|
82
|
+
if self.strict:
|
|
83
|
+
raise
|
|
84
|
+
return None
|
|
85
|
+
|
|
86
|
+
def flush(self) -> int:
|
|
87
|
+
sent = 0
|
|
88
|
+
with self._lock:
|
|
89
|
+
pending = list(self._buffer)
|
|
90
|
+
for method, path, payload, request_id in pending:
|
|
91
|
+
try:
|
|
92
|
+
response = self.client.request(method, path, json=payload, headers={"X-Request-ID": request_id})
|
|
93
|
+
response.raise_for_status()
|
|
94
|
+
except (httpx.HTTPError, OSError):
|
|
95
|
+
break
|
|
96
|
+
with self._lock:
|
|
97
|
+
if self._buffer and self._buffer[0][3] == request_id:
|
|
98
|
+
self._buffer.popleft()
|
|
99
|
+
sent += 1
|
|
100
|
+
return sent
|
|
101
|
+
|
|
102
|
+
def search(self, project: str, query: str, limit: int = 10, include_archived: bool = False) -> dict[str, Any] | None:
|
|
103
|
+
return self.request("POST", "/api/v1/search", {"project": project, "query": query, "limit": limit, "include_archived": include_archived})
|
|
104
|
+
|
|
105
|
+
def create_project(self, name: str, slug: str, description: str = "") -> dict[str, Any] | None:
|
|
106
|
+
return self.request("POST", "/api/v1/projects", {"name": name, "slug": slug, "description": description})
|
|
107
|
+
|
|
108
|
+
def run(self, project: str, name: str, hypothesis: str = "", reasoning: str = "", tags: list[str] | None = None, **kwargs: Any) -> "Run":
|
|
109
|
+
return Run(self, project, name, hypothesis, reasoning, tags or [], kwargs)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class Run:
|
|
113
|
+
def __init__(self, client: RunTrace, project: str, name: str, hypothesis: str, reasoning: str, tags: list[str], options: dict[str, Any]):
|
|
114
|
+
self.client = client
|
|
115
|
+
self.project = project
|
|
116
|
+
self.name = name
|
|
117
|
+
self.hypothesis = hypothesis
|
|
118
|
+
self.reasoning = reasoning
|
|
119
|
+
self.tags = tags
|
|
120
|
+
self.options = options
|
|
121
|
+
self.id: str | None = None
|
|
122
|
+
self.display_id: str | None = None
|
|
123
|
+
self._finished = False
|
|
124
|
+
|
|
125
|
+
def __enter__(self) -> "Run":
|
|
126
|
+
cwd = self.options.pop("working_directory", os.getcwd())
|
|
127
|
+
configuration = dict(self.options.pop("configuration", {}))
|
|
128
|
+
configuration["tags"] = self.tags
|
|
129
|
+
payload = {
|
|
130
|
+
"name": self.name,
|
|
131
|
+
"hypothesis": self.hypothesis,
|
|
132
|
+
"reasoning": self.reasoning,
|
|
133
|
+
"working_directory": cwd,
|
|
134
|
+
"command": " ".join(sys.argv),
|
|
135
|
+
"configuration": configuration,
|
|
136
|
+
**_metadata(cwd),
|
|
137
|
+
**self.options,
|
|
138
|
+
}
|
|
139
|
+
created = self.client.request("POST", f"/api/v1/projects/{self.project}/runs", payload)
|
|
140
|
+
if not created:
|
|
141
|
+
if self.client.strict:
|
|
142
|
+
raise RuntimeError("RunTrace server unavailable")
|
|
143
|
+
return self
|
|
144
|
+
self.id = created["id"]
|
|
145
|
+
self.display_id = created["display_id"]
|
|
146
|
+
return self
|
|
147
|
+
|
|
148
|
+
def __exit__(self, exc_type, exc, traceback) -> bool:
|
|
149
|
+
if exc is not None:
|
|
150
|
+
self.abort(str(exc))
|
|
151
|
+
elif not self._finished:
|
|
152
|
+
self.finish("undecided")
|
|
153
|
+
return False
|
|
154
|
+
|
|
155
|
+
def _send(self, path: str, payload: dict[str, Any], *, buffer: bool = True) -> Any:
|
|
156
|
+
if not self.id:
|
|
157
|
+
return None
|
|
158
|
+
return self.client.request("POST", f"/api/v1/runs/{self.id}/{path}", payload, buffer=buffer)
|
|
159
|
+
|
|
160
|
+
def log_metric(self, name: str, value: float, step: int | None = None, timestamp: str | None = None) -> None:
|
|
161
|
+
self.log_metrics({name: value}, step=step, timestamp=timestamp)
|
|
162
|
+
|
|
163
|
+
def log_metrics(self, values: dict[str, float], step: int | None = None, timestamp: str | None = None) -> None:
|
|
164
|
+
self._send("metrics", {"metrics": [{"name": name, "value": value, "step": step, "timestamp": timestamp} for name, value in values.items()]})
|
|
165
|
+
|
|
166
|
+
def log_param(self, name: str, value: Any) -> None:
|
|
167
|
+
self.log_params({name: value})
|
|
168
|
+
|
|
169
|
+
def log_params(self, values: dict[str, Any]) -> None:
|
|
170
|
+
self._send("parameters", {"parameters": values})
|
|
171
|
+
|
|
172
|
+
def log_event(self, message: str, level: str = "info", event_type: str | None = None, metadata: dict[str, Any] | None = None) -> None:
|
|
173
|
+
self._send("events", {"message": message, "level": level, "event_type": event_type, "metadata": metadata or {}})
|
|
174
|
+
|
|
175
|
+
def log_reasoning(self, text: str, stage: str = "during") -> None:
|
|
176
|
+
self.log_event(text, event_type=f"reasoning.{stage}")
|
|
177
|
+
|
|
178
|
+
def set_tags(self, tags: list[str]) -> None:
|
|
179
|
+
self.tags = tags
|
|
180
|
+
self.log_param("tags", tags)
|
|
181
|
+
|
|
182
|
+
def link_run(self, run_id: str, relationship: str) -> None:
|
|
183
|
+
self.log_event(f"Linked {run_id} as {relationship}", event_type="run.relationship", metadata={"run_id": run_id, "relationship": relationship})
|
|
184
|
+
|
|
185
|
+
def _upload_artifact(self, name: str, content: Any, content_type: str, metadata: dict[str, Any] | None = None, kind: str = "artifact") -> dict[str, Any] | None:
|
|
186
|
+
if not self.id:
|
|
187
|
+
return None
|
|
188
|
+
try:
|
|
189
|
+
artifact_metadata = {"kind": kind, **(metadata or {})}
|
|
190
|
+
response = self.client.client.post(
|
|
191
|
+
f"/api/v1/runs/{self.id}/artifacts",
|
|
192
|
+
files={"file": (name, content, content_type)},
|
|
193
|
+
data={"metadata": json.dumps(artifact_metadata)},
|
|
194
|
+
)
|
|
195
|
+
response.raise_for_status()
|
|
196
|
+
return response.json()
|
|
197
|
+
except (OSError, httpx.HTTPError):
|
|
198
|
+
if self.client.strict:
|
|
199
|
+
raise
|
|
200
|
+
warnings.warn(f"RunTrace could not upload artifact {name}", RuntimeWarning, stacklevel=2)
|
|
201
|
+
return None
|
|
202
|
+
|
|
203
|
+
def log_artifact(self, path: str, name: str | None = None, metadata: dict[str, Any] | None = None, kind: str = "artifact") -> dict[str, Any] | None:
|
|
204
|
+
artifact_path = Path(path)
|
|
205
|
+
try:
|
|
206
|
+
with artifact_path.open("rb") as handle:
|
|
207
|
+
return self._upload_artifact(name or artifact_path.name, handle, "application/octet-stream", metadata, kind)
|
|
208
|
+
except OSError:
|
|
209
|
+
if self.client.strict:
|
|
210
|
+
raise
|
|
211
|
+
warnings.warn(f"RunTrace could not upload artifact {artifact_path}", RuntimeWarning, stacklevel=2)
|
|
212
|
+
return None
|
|
213
|
+
|
|
214
|
+
def log_text(self, name: str, content: str, kind: str = "log", metadata: dict[str, Any] | None = None) -> dict[str, Any] | None:
|
|
215
|
+
"""Save previewable text output such as stdout, stderr, reports, or logs."""
|
|
216
|
+
return self._upload_artifact(name, content.encode("utf-8"), "text/plain", metadata, kind)
|
|
217
|
+
|
|
218
|
+
def log_config(self, values: dict[str, Any], name: str = "config.json", metadata: dict[str, Any] | None = None) -> dict[str, Any] | None:
|
|
219
|
+
"""Save configuration as both queryable parameters and a versioned JSON artifact."""
|
|
220
|
+
self.log_params(values)
|
|
221
|
+
return self._upload_artifact(name, json.dumps(values, indent=2, sort_keys=True).encode("utf-8"), "application/json", metadata, "config")
|
|
222
|
+
|
|
223
|
+
def finish(self, outcome: str, result_summary: str | None = None, conclusion: str | None = None) -> None:
|
|
224
|
+
disposition = {"success": "kept", "partial_success": "kept", "failure": "discarded", "inconclusive": "undecided"}.get(outcome, outcome)
|
|
225
|
+
self._send("finish", {"disposition": disposition, "result_summary": result_summary or "", "conclusion": conclusion or ""}, buffer=False)
|
|
226
|
+
self._finished = True
|
|
227
|
+
|
|
228
|
+
def abort(self, reason: str | None = None) -> None:
|
|
229
|
+
self._send("crash", {"error_summary": reason or "Run aborted"}, buffer=False)
|
|
230
|
+
self._finished = True
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
_default = RunTrace(
|
|
234
|
+
base_url=os.getenv("RUNTRACE_BASE_URL", "http://localhost:8000"),
|
|
235
|
+
api_token=os.getenv("RUNTRACE_API_TOKEN") or os.getenv("RUNTRACE_API_KEY"),
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def configure(
|
|
240
|
+
base_url: str = "http://localhost:8000",
|
|
241
|
+
api_token: str | None = None,
|
|
242
|
+
strict: bool = False,
|
|
243
|
+
*,
|
|
244
|
+
api_key: str | None = None,
|
|
245
|
+
) -> RunTrace:
|
|
246
|
+
global _default
|
|
247
|
+
_default = RunTrace(base_url=base_url, api_token=api_token or api_key, strict=strict)
|
|
248
|
+
return _default
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def run(project: str, hypothesis: str, reasoning: str = "", name: str | None = None, tags: list[str] | None = None, **kwargs: Any) -> Run:
|
|
252
|
+
return _default.run(project, name or hypothesis[:80], hypothesis, reasoning, tags, **kwargs)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def search(project: str, query: str, limit: int = 10) -> dict[str, Any] | None:
|
|
256
|
+
return _default.search(project, query, limit)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def create_project(name: str, slug: str, description: str = "") -> dict[str, Any] | None:
|
|
260
|
+
return _default.create_project(name, slug, description)
|
runtrace/credentials.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import tempfile
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
DEFAULT_BASE_URL = "http://localhost:8000"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def credentials_path() -> Path:
|
|
14
|
+
override = os.getenv("RUNTRACE_CREDENTIALS_FILE")
|
|
15
|
+
if override:
|
|
16
|
+
return Path(override).expanduser()
|
|
17
|
+
config_home = Path(os.getenv("XDG_CONFIG_HOME", Path.home() / ".config"))
|
|
18
|
+
return config_home / "runtrace" / "credentials.json"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def load_credentials() -> dict[str, str]:
|
|
22
|
+
try:
|
|
23
|
+
payload: Any = json.loads(credentials_path().read_text())
|
|
24
|
+
except (OSError, json.JSONDecodeError):
|
|
25
|
+
return {}
|
|
26
|
+
if not isinstance(payload, dict):
|
|
27
|
+
return {}
|
|
28
|
+
return {
|
|
29
|
+
key: value
|
|
30
|
+
for key in ("base_url", "api_token")
|
|
31
|
+
if isinstance((value := payload.get(key)), str) and value
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def resolve_connection(
|
|
36
|
+
base_url: str | None = None,
|
|
37
|
+
api_token: str | None = None,
|
|
38
|
+
) -> tuple[str, str | None]:
|
|
39
|
+
saved = load_credentials()
|
|
40
|
+
resolved_base_url = (
|
|
41
|
+
base_url
|
|
42
|
+
or os.getenv("RUNTRACE_BASE_URL")
|
|
43
|
+
or saved.get("base_url")
|
|
44
|
+
or DEFAULT_BASE_URL
|
|
45
|
+
)
|
|
46
|
+
resolved_api_token = (
|
|
47
|
+
api_token
|
|
48
|
+
or os.getenv("RUNTRACE_API_TOKEN")
|
|
49
|
+
or os.getenv("RUNTRACE_API_KEY")
|
|
50
|
+
or saved.get("api_token")
|
|
51
|
+
)
|
|
52
|
+
return resolved_base_url.rstrip("/"), resolved_api_token
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def save_credentials(base_url: str, api_token: str) -> Path:
|
|
56
|
+
path = credentials_path()
|
|
57
|
+
path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
58
|
+
try:
|
|
59
|
+
path.parent.chmod(0o700)
|
|
60
|
+
except OSError:
|
|
61
|
+
pass
|
|
62
|
+
|
|
63
|
+
temporary_name: str | None = None
|
|
64
|
+
try:
|
|
65
|
+
with tempfile.NamedTemporaryFile(
|
|
66
|
+
"w",
|
|
67
|
+
encoding="utf-8",
|
|
68
|
+
dir=path.parent,
|
|
69
|
+
prefix=".credentials-",
|
|
70
|
+
delete=False,
|
|
71
|
+
) as temporary:
|
|
72
|
+
temporary_name = temporary.name
|
|
73
|
+
json.dump({"base_url": base_url.rstrip("/"), "api_token": api_token}, temporary)
|
|
74
|
+
temporary.write("\n")
|
|
75
|
+
temporary.flush()
|
|
76
|
+
os.fsync(temporary.fileno())
|
|
77
|
+
os.chmod(temporary_name, 0o600)
|
|
78
|
+
os.replace(temporary_name, path)
|
|
79
|
+
finally:
|
|
80
|
+
if temporary_name:
|
|
81
|
+
try:
|
|
82
|
+
Path(temporary_name).unlink()
|
|
83
|
+
except FileNotFoundError:
|
|
84
|
+
pass
|
|
85
|
+
return path
|