buro 0.0.1__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.
- buro/__init__.py +201 -0
- buro/_compat.py +37 -0
- buro/buffer.py +44 -0
- buro/cli.py +130 -0
- buro/client.py +189 -0
- buro/code_capture.py +201 -0
- buro/code_snapshot.py +335 -0
- buro/code_snapshot_uploader.py +159 -0
- buro/config.py +41 -0
- buro/credentials.py +42 -0
- buro/errors.py +42 -0
- buro/log_capture.py +168 -0
- buro/media.py +145 -0
- buro/run.py +414 -0
- buro/settings.py +69 -0
- buro/slug_ref.py +33 -0
- buro/system_metrics.py +96 -0
- buro/wal.py +35 -0
- buro-0.0.1.dist-info/METADATA +81 -0
- buro-0.0.1.dist-info/RECORD +22 -0
- buro-0.0.1.dist-info/WHEEL +4 -0
- buro-0.0.1.dist-info/entry_points.txt +2 -0
buro/__init__.py
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
"""Buro — an experiment tracker and lab journal made for humans and human-agent AI research."""
|
|
2
|
+
|
|
3
|
+
__version__ = "0.0.1"
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
|
|
7
|
+
from buro.config import Config
|
|
8
|
+
from buro.media import Audio, Image, Video
|
|
9
|
+
from buro.run import Run
|
|
10
|
+
from buro.settings import BuroSettings, configure, get_settings, resolve_api_key, resolve_api_url
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger("buro")
|
|
13
|
+
|
|
14
|
+
# Module-level state
|
|
15
|
+
_active_run: Run | None = None
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _title_case_slug(slug: str) -> str:
|
|
19
|
+
"""Default display name from a slug. 'grpo-baseline' → 'Grpo Baseline'."""
|
|
20
|
+
return " ".join(part.capitalize() for part in slug.split("-"))
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _public_url(api_url: str, ref: str) -> str:
|
|
24
|
+
"""Best-effort canonical URL for the project, used in the INFO log."""
|
|
25
|
+
base = api_url.rstrip("/")
|
|
26
|
+
# Strip /api/v1 if present so we land on the web app, not the API.
|
|
27
|
+
for suffix in ("/api/v1", "/api"):
|
|
28
|
+
if base.endswith(suffix):
|
|
29
|
+
base = base[: -len(suffix)]
|
|
30
|
+
break
|
|
31
|
+
# Web routes live at /:teamSlug/:projectSlug (and /me/:projectSlug for
|
|
32
|
+
# personal projects), NOT /p/:ref — the /p/ prefix was a stale guess
|
|
33
|
+
# from when the route shape wasn't pinned down. ref already has the
|
|
34
|
+
# right shape ("slug" or "team/slug"); leave it as-is.
|
|
35
|
+
return f"{base}/{ref}"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def setup(api_url: str | None = None, api_key: str | None = None,
|
|
39
|
+
project: str | None = None, **kwargs) -> None:
|
|
40
|
+
"""Configure SDK settings."""
|
|
41
|
+
updates = {}
|
|
42
|
+
if api_url is not None: updates["api_url"] = api_url
|
|
43
|
+
if api_key is not None: updates["api_key"] = api_key
|
|
44
|
+
if project is not None: updates["project"] = project
|
|
45
|
+
updates.update(kwargs)
|
|
46
|
+
configure(**updates)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def init(project: str | None = None, config: dict | None = None,
|
|
50
|
+
name: str | None = None, tags: list[str] | None = None,
|
|
51
|
+
group: str | None = None, job_type: str | None = None) -> Run:
|
|
52
|
+
"""Initialize a new run.
|
|
53
|
+
|
|
54
|
+
`project` is a slug ref: 'project-slug' (personal) or 'team-slug/project-slug'
|
|
55
|
+
(team). UUIDs are not accepted. The project is resolved against the server;
|
|
56
|
+
if it doesn't exist, it is auto-created.
|
|
57
|
+
"""
|
|
58
|
+
global _active_run
|
|
59
|
+
|
|
60
|
+
# Resolve ambient credentials (env / ~/.buro) into the singleton so
|
|
61
|
+
# both the resolver client below and Run() see the same values.
|
|
62
|
+
configure(api_url=resolve_api_url(), api_key=resolve_api_key())
|
|
63
|
+
|
|
64
|
+
s = get_settings()
|
|
65
|
+
ref = project or s.project
|
|
66
|
+
if not ref:
|
|
67
|
+
raise ValueError("project ref must be specified via init(project=) or setup(project=)")
|
|
68
|
+
|
|
69
|
+
# Lazy imports to avoid circulars and keep error surface small.
|
|
70
|
+
from buro.client import BuroClient
|
|
71
|
+
from buro.errors import (
|
|
72
|
+
InvalidProjectRefError,
|
|
73
|
+
ProjectNotFoundError,
|
|
74
|
+
TeamAccessDeniedError,
|
|
75
|
+
TeamNotFoundError,
|
|
76
|
+
)
|
|
77
|
+
from buro.slug_ref import parse_ref
|
|
78
|
+
|
|
79
|
+
# Validate format up front — never make a network call for malformed refs.
|
|
80
|
+
try:
|
|
81
|
+
team_slug, project_slug = parse_ref(ref)
|
|
82
|
+
except ValueError as e:
|
|
83
|
+
raise InvalidProjectRefError(ref, str(e)) from e
|
|
84
|
+
|
|
85
|
+
client = BuroClient(api_url=s.api_url, api_key=s.api_key)
|
|
86
|
+
try:
|
|
87
|
+
project_uuid = _resolve_or_create(
|
|
88
|
+
client, ref, team_slug, project_slug, api_url=s.api_url
|
|
89
|
+
)
|
|
90
|
+
finally:
|
|
91
|
+
client.close()
|
|
92
|
+
|
|
93
|
+
run = Run(settings=s, project_id=project_uuid, name=name, config=config,
|
|
94
|
+
tags=tags, group=group, job_type=job_type)
|
|
95
|
+
_active_run = run
|
|
96
|
+
return run
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _resolve_or_create(client, ref, team_slug, project_slug, *, api_url) -> str:
|
|
100
|
+
"""Resolve a project ref; auto-create on 404. Returns the project UUID."""
|
|
101
|
+
from buro.errors import (
|
|
102
|
+
InvalidProjectRefError,
|
|
103
|
+
ProjectNotFoundError,
|
|
104
|
+
TeamAccessDeniedError,
|
|
105
|
+
TeamNotFoundError,
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
status, body = client.resolve_project(ref)
|
|
109
|
+
if status == 200:
|
|
110
|
+
return body["id"]
|
|
111
|
+
if status == 403:
|
|
112
|
+
raise TeamAccessDeniedError(ref, body.get("detail", ""))
|
|
113
|
+
if status == 400:
|
|
114
|
+
raise InvalidProjectRefError(ref, body.get("detail", ""))
|
|
115
|
+
if status != 404:
|
|
116
|
+
raise ProjectNotFoundError(ref, f"unexpected resolver status {status}: {body}")
|
|
117
|
+
|
|
118
|
+
# 404 → auto-create.
|
|
119
|
+
create_status, create_body = client.create_project(
|
|
120
|
+
slug=project_slug,
|
|
121
|
+
name=_title_case_slug(project_slug),
|
|
122
|
+
team_slug=team_slug,
|
|
123
|
+
)
|
|
124
|
+
if create_status == 201:
|
|
125
|
+
logger.info("buro: created new project %r (%s)", ref, _public_url(api_url, ref))
|
|
126
|
+
return create_body["id"]
|
|
127
|
+
if create_status == 404:
|
|
128
|
+
# Server returns 404 when the team itself is missing (team form).
|
|
129
|
+
# For personal form a 404 here would be weird; treat as ProjectNotFound.
|
|
130
|
+
if team_slug is not None:
|
|
131
|
+
raise TeamNotFoundError(ref, create_body.get("detail", ""))
|
|
132
|
+
raise ProjectNotFoundError(ref, create_body.get("detail", ""))
|
|
133
|
+
if create_status == 403:
|
|
134
|
+
raise TeamAccessDeniedError(ref, create_body.get("detail", ""))
|
|
135
|
+
if create_status == 409:
|
|
136
|
+
# Race: someone else created the same slug between our resolve and create.
|
|
137
|
+
# Try resolving once more.
|
|
138
|
+
status2, body2 = client.resolve_project(ref)
|
|
139
|
+
if status2 == 200:
|
|
140
|
+
return body2["id"]
|
|
141
|
+
raise ProjectNotFoundError(
|
|
142
|
+
ref,
|
|
143
|
+
f"slug contention: create returned 409, retry resolve returned {status2}",
|
|
144
|
+
)
|
|
145
|
+
raise ProjectNotFoundError(ref, f"unexpected create status {create_status}: {create_body}")
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def log(metrics: dict, step: int | None = None) -> None:
|
|
149
|
+
"""Log metrics to the active run."""
|
|
150
|
+
if _active_run is None:
|
|
151
|
+
raise RuntimeError("No active run. Call buro.init() first.")
|
|
152
|
+
_active_run.log(metrics, step=step)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def finish() -> None:
|
|
156
|
+
"""Finish the active run."""
|
|
157
|
+
global _active_run
|
|
158
|
+
if _active_run is None:
|
|
159
|
+
return
|
|
160
|
+
_active_run.finish()
|
|
161
|
+
_active_run = None
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
class _ConfigProxy:
|
|
165
|
+
def __getattr__(self, name):
|
|
166
|
+
if _active_run is None: raise RuntimeError("No active run.")
|
|
167
|
+
return getattr(_active_run.config, name)
|
|
168
|
+
def __setattr__(self, name, value):
|
|
169
|
+
if _active_run is None: raise RuntimeError("No active run.")
|
|
170
|
+
setattr(_active_run.config, name, value)
|
|
171
|
+
def __getitem__(self, key):
|
|
172
|
+
if _active_run is None: raise RuntimeError("No active run.")
|
|
173
|
+
return _active_run.config[key]
|
|
174
|
+
def __setitem__(self, key, value):
|
|
175
|
+
if _active_run is None: raise RuntimeError("No active run.")
|
|
176
|
+
_active_run.config[key] = value
|
|
177
|
+
def update(self, d: dict):
|
|
178
|
+
if _active_run is None: raise RuntimeError("No active run.")
|
|
179
|
+
_active_run.config.update(d)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
class _SummaryProxy:
|
|
183
|
+
def __getattr__(self, name):
|
|
184
|
+
if _active_run is None: raise RuntimeError("No active run.")
|
|
185
|
+
return getattr(_active_run.summary, name)
|
|
186
|
+
def __setattr__(self, name, value):
|
|
187
|
+
if _active_run is None: raise RuntimeError("No active run.")
|
|
188
|
+
setattr(_active_run.summary, name, value)
|
|
189
|
+
def __getitem__(self, key):
|
|
190
|
+
if _active_run is None: raise RuntimeError("No active run.")
|
|
191
|
+
return _active_run.summary[key]
|
|
192
|
+
def __setitem__(self, key, value):
|
|
193
|
+
if _active_run is None: raise RuntimeError("No active run.")
|
|
194
|
+
_active_run.summary[key] = value
|
|
195
|
+
def as_dict(self):
|
|
196
|
+
if _active_run is None: raise RuntimeError("No active run.")
|
|
197
|
+
return _active_run.summary.as_dict()
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
config = _ConfigProxy()
|
|
201
|
+
summary = _SummaryProxy()
|
buro/_compat.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""wandb-API compatibility helpers — see sdk/docs/wandb-compatibility.md.
|
|
2
|
+
|
|
3
|
+
L2 (accepted-ignored): emit_once — INFO, dedup per (process, api, param)
|
|
4
|
+
L3 (degraded): warn — WARNING, every call
|
|
5
|
+
L4 (unsupported): unsupported — returns NotImplementedError
|
|
6
|
+
"""
|
|
7
|
+
import logging
|
|
8
|
+
from threading import Lock
|
|
9
|
+
|
|
10
|
+
_logger = logging.getLogger("buro.compat")
|
|
11
|
+
_seen: set[tuple[str, str, str]] = set()
|
|
12
|
+
_lock = Lock()
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def emit_once(level: str, api: str, param: str, message: str) -> None:
|
|
16
|
+
"""L2 — INFO once per (process, api, param). Dedup is per-process."""
|
|
17
|
+
key = (level, api, param)
|
|
18
|
+
with _lock:
|
|
19
|
+
if key in _seen:
|
|
20
|
+
return
|
|
21
|
+
_seen.add(key)
|
|
22
|
+
_logger.info(message)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def warn(level: str, api: str, param: str, message: str) -> None:
|
|
26
|
+
"""L3 — WARNING every call. No dedup."""
|
|
27
|
+
# level/api/param accepted for call-site symmetry with emit_once;
|
|
28
|
+
# reserved for future structured logging (e.g., emitting JSON events).
|
|
29
|
+
_logger.warning(message)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def unsupported(api: str, param: str = "") -> NotImplementedError:
|
|
33
|
+
"""L4 — caller raises with the returned error for migration guidance."""
|
|
34
|
+
return NotImplementedError(
|
|
35
|
+
f"buro: {api}({param}) is not supported. "
|
|
36
|
+
f"See sdk/docs/wandb-compatibility.md for the migration path."
|
|
37
|
+
)
|
buro/buffer.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import threading
|
|
2
|
+
from datetime import datetime, timezone
|
|
3
|
+
from typing import Callable
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class MetricBuffer:
|
|
7
|
+
"""Thread-safe metric buffer with configurable flush."""
|
|
8
|
+
|
|
9
|
+
def __init__(self, flush_callback: Callable[[list[dict]], None] | None = None,
|
|
10
|
+
flush_batch_size: int = 1000):
|
|
11
|
+
self._buffer: list[dict] = []
|
|
12
|
+
self._lock = threading.Lock()
|
|
13
|
+
self._flush_callback = flush_callback
|
|
14
|
+
self._flush_batch_size = flush_batch_size
|
|
15
|
+
|
|
16
|
+
def add(self, metric_name: str, step: int, value: float) -> None:
|
|
17
|
+
point = {
|
|
18
|
+
"metric_name": metric_name,
|
|
19
|
+
"step": step,
|
|
20
|
+
"value": value,
|
|
21
|
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
22
|
+
}
|
|
23
|
+
should_flush = False
|
|
24
|
+
with self._lock:
|
|
25
|
+
self._buffer.append(point)
|
|
26
|
+
if self._flush_callback and len(self._buffer) >= self._flush_batch_size:
|
|
27
|
+
should_flush = True
|
|
28
|
+
if should_flush:
|
|
29
|
+
self.flush()
|
|
30
|
+
|
|
31
|
+
def drain(self) -> list[dict]:
|
|
32
|
+
with self._lock:
|
|
33
|
+
items = self._buffer
|
|
34
|
+
self._buffer = []
|
|
35
|
+
return items
|
|
36
|
+
|
|
37
|
+
def flush(self) -> None:
|
|
38
|
+
items = self.drain()
|
|
39
|
+
if items and self._flush_callback:
|
|
40
|
+
self._flush_callback(items)
|
|
41
|
+
|
|
42
|
+
def pending_count(self) -> int:
|
|
43
|
+
with self._lock:
|
|
44
|
+
return len(self._buffer)
|
buro/cli.py
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""The `buro` command-line interface.
|
|
2
|
+
|
|
3
|
+
Commands:
|
|
4
|
+
buro login — browser device-flow login; stores ~/.buro/credentials
|
|
5
|
+
buro logout — remove the stored credentials
|
|
6
|
+
buro whoami — print the email for the stored credentials
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import time
|
|
10
|
+
import webbrowser
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
13
|
+
import typer
|
|
14
|
+
|
|
15
|
+
from buro import credentials
|
|
16
|
+
from buro.settings import DEFAULT_API_URL
|
|
17
|
+
|
|
18
|
+
API_PREFIX = "/api/v1"
|
|
19
|
+
|
|
20
|
+
app = typer.Typer(help="Buro CLI", no_args_is_help=True) # pragma: no mutate (cosmetic CLI config)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _base(api_url: str) -> str:
|
|
24
|
+
return api_url.rstrip("/") + API_PREFIX # pragma: no mutate (rstrip char-set is equivalent for our urls)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _fetch_email(api_url: str, api_key: str) -> str:
|
|
28
|
+
with httpx.Client(
|
|
29
|
+
base_url=_base(api_url),
|
|
30
|
+
headers={"Authorization": f"Bearer {api_key}"},
|
|
31
|
+
timeout=30.0, # pragma: no mutate (timeout value not behaviorally observable)
|
|
32
|
+
) as http:
|
|
33
|
+
resp = http.get("/auth/me")
|
|
34
|
+
resp.raise_for_status()
|
|
35
|
+
return resp.json()["email"]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _poll_for_key(http: httpx.Client, device_code: str, interval: int) -> str:
|
|
39
|
+
"""Poll device/token until terminal. Returns the api_key on approval;
|
|
40
|
+
raises typer.Exit(1) on denied/expired."""
|
|
41
|
+
while True:
|
|
42
|
+
time.sleep(interval)
|
|
43
|
+
resp = http.post("/auth/device/token", json={"device_code": device_code})
|
|
44
|
+
resp.raise_for_status()
|
|
45
|
+
body = resp.json()
|
|
46
|
+
status = body["status"]
|
|
47
|
+
if status == "approved":
|
|
48
|
+
return body["api_key"]
|
|
49
|
+
if status == "pending":
|
|
50
|
+
continue
|
|
51
|
+
if status == "slow_down":
|
|
52
|
+
interval += 5
|
|
53
|
+
continue
|
|
54
|
+
if status == "denied":
|
|
55
|
+
typer.echo("Authorization was denied.")
|
|
56
|
+
raise typer.Exit(code=1)
|
|
57
|
+
# expired or anything unexpected
|
|
58
|
+
typer.echo("The login request expired. Run `buro login` again.")
|
|
59
|
+
raise typer.Exit(code=1)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@app.command()
|
|
63
|
+
def login(
|
|
64
|
+
api_url: str = typer.Option(
|
|
65
|
+
DEFAULT_API_URL, "--api-url", help="Buro server URL." # pragma: no mutate (help text)
|
|
66
|
+
),
|
|
67
|
+
):
|
|
68
|
+
"""Log in via the browser and store an API key."""
|
|
69
|
+
try:
|
|
70
|
+
with httpx.Client(base_url=_base(api_url), timeout=30.0) as http: # pragma: no mutate (timeout value not behaviorally observable)
|
|
71
|
+
resp = http.post("/auth/device/code")
|
|
72
|
+
resp.raise_for_status()
|
|
73
|
+
data = resp.json()
|
|
74
|
+
complete = data["verification_uri_complete"]
|
|
75
|
+
typer.echo(f"Your verification code: {data['user_code']}")
|
|
76
|
+
if webbrowser.open(complete):
|
|
77
|
+
typer.echo(f"Opened your browser to {complete}")
|
|
78
|
+
else:
|
|
79
|
+
typer.echo(f"Open this URL to authorize: {complete}")
|
|
80
|
+
typer.echo("Waiting for approval...")
|
|
81
|
+
api_key = _poll_for_key(http, data["device_code"], data["interval"])
|
|
82
|
+
except httpx.HTTPError as exc:
|
|
83
|
+
typer.echo(f"Could not reach the Buro server: {exc}")
|
|
84
|
+
raise typer.Exit(code=1)
|
|
85
|
+
|
|
86
|
+
credentials.save(api_url=api_url, api_key=api_key)
|
|
87
|
+
# The key is already saved; a failure fetching the display name should
|
|
88
|
+
# not fail an otherwise-successful login.
|
|
89
|
+
try:
|
|
90
|
+
email = _fetch_email(api_url, api_key)
|
|
91
|
+
except httpx.HTTPError:
|
|
92
|
+
typer.echo("Logged in. (Could not fetch your account details.)")
|
|
93
|
+
return
|
|
94
|
+
typer.echo(f"Logged in as {email}")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@app.command()
|
|
98
|
+
def logout():
|
|
99
|
+
"""Remove the stored credentials."""
|
|
100
|
+
if credentials.clear():
|
|
101
|
+
typer.echo("Logged out.")
|
|
102
|
+
else:
|
|
103
|
+
typer.echo("Not logged in.")
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@app.command()
|
|
107
|
+
def whoami(
|
|
108
|
+
api_url: str = typer.Option(None, "--api-url", help="Override the stored server URL."), # pragma: no mutate (help text)
|
|
109
|
+
):
|
|
110
|
+
"""Print the email for the stored credentials."""
|
|
111
|
+
creds = credentials.load()
|
|
112
|
+
api_key = creds.get("api_key")
|
|
113
|
+
if not api_key:
|
|
114
|
+
typer.echo("Not logged in.")
|
|
115
|
+
raise typer.Exit(code=1)
|
|
116
|
+
url = api_url or creds.get("api_url") or DEFAULT_API_URL
|
|
117
|
+
try:
|
|
118
|
+
email = _fetch_email(url, api_key)
|
|
119
|
+
except httpx.HTTPError as exc:
|
|
120
|
+
typer.echo(f"Could not reach the Buro server: {exc}")
|
|
121
|
+
raise typer.Exit(code=1)
|
|
122
|
+
typer.echo(email)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def main() -> None:
|
|
126
|
+
app()
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
if __name__ == "__main__": # pragma: no mutate
|
|
130
|
+
main()
|
buro/client.py
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import httpx
|
|
3
|
+
|
|
4
|
+
_API_PREFIX = "/api/v1"
|
|
5
|
+
|
|
6
|
+
logger = logging.getLogger(__name__)
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class BuroHTTPError(Exception):
|
|
10
|
+
"""Raised when the server returns an unexpected status that the SDK
|
|
11
|
+
can't recover from (5xx, network errors). 4xx codes the SDK knows how to
|
|
12
|
+
handle (400/403/404 on resolve/create) are returned as (status, body)
|
|
13
|
+
tuples instead — see resolve_project / create_project.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class BuroClient:
|
|
18
|
+
"""Thin synchronous HTTP client for the Buro server API."""
|
|
19
|
+
|
|
20
|
+
def __init__(self, api_url: str, api_key: str):
|
|
21
|
+
self._base = api_url.rstrip("/") + _API_PREFIX
|
|
22
|
+
self._http = httpx.Client(
|
|
23
|
+
base_url=self._base,
|
|
24
|
+
headers={"Authorization": f"Bearer {api_key}"},
|
|
25
|
+
timeout=30.0,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
def close(self):
|
|
29
|
+
self._http.close()
|
|
30
|
+
|
|
31
|
+
def create_run(self, project_id: str, name: str | None = None, config: dict | None = None,
|
|
32
|
+
tags: list[str] | None = None, group: str | None = None,
|
|
33
|
+
job_type: str | None = None, system_info: dict | None = None) -> dict:
|
|
34
|
+
payload = {"project_id": project_id}
|
|
35
|
+
if name is not None: payload["name"] = name
|
|
36
|
+
if config is not None: payload["config"] = config
|
|
37
|
+
if tags is not None: payload["tags"] = tags
|
|
38
|
+
if group is not None: payload["group"] = group
|
|
39
|
+
if job_type is not None: payload["job_type"] = job_type
|
|
40
|
+
if system_info is not None: payload["system_info"] = system_info
|
|
41
|
+
resp = self._http.post("/runs", json=payload)
|
|
42
|
+
resp.raise_for_status()
|
|
43
|
+
return resp.json()
|
|
44
|
+
|
|
45
|
+
def update_run(self, run_id: str, config: dict | None = None, summary: dict | None = None,
|
|
46
|
+
tags: list[str] | None = None, name: str | None = None) -> dict:
|
|
47
|
+
payload: dict = {}
|
|
48
|
+
if config is not None: payload["config"] = config
|
|
49
|
+
if summary is not None: payload["summary"] = summary
|
|
50
|
+
if tags is not None: payload["tags"] = tags
|
|
51
|
+
if name is not None: payload["name"] = name
|
|
52
|
+
resp = self._http.patch(f"/runs/{run_id}", json=payload)
|
|
53
|
+
resp.raise_for_status()
|
|
54
|
+
return resp.json()
|
|
55
|
+
|
|
56
|
+
def finish_run(self, run_id: str) -> dict:
|
|
57
|
+
resp = self._http.post(f"/runs/{run_id}/finish")
|
|
58
|
+
resp.raise_for_status()
|
|
59
|
+
return resp.json()
|
|
60
|
+
|
|
61
|
+
def report_exit(
|
|
62
|
+
self,
|
|
63
|
+
run_id: str,
|
|
64
|
+
reason: str,
|
|
65
|
+
detail: dict | None = None,
|
|
66
|
+
) -> None:
|
|
67
|
+
"""POST /runs/{run_id}/exit — SDK-attested terminal state.
|
|
68
|
+
|
|
69
|
+
Best-effort: ALL failures (transport errors AND HTTP status errors
|
|
70
|
+
from the server) are logged and swallowed. This is called from
|
|
71
|
+
atexit / signal handlers where raising would mask the original
|
|
72
|
+
cause of process termination and could hang shutdown. HTTP and
|
|
73
|
+
transport errors get distinct log lines so they can be told apart
|
|
74
|
+
when grepping logs — a 5xx is a server bug; a transport error is
|
|
75
|
+
a connectivity issue.
|
|
76
|
+
"""
|
|
77
|
+
try:
|
|
78
|
+
resp = self._http.post(
|
|
79
|
+
f"/runs/{run_id}/exit",
|
|
80
|
+
json={"reason": reason, "detail": detail},
|
|
81
|
+
)
|
|
82
|
+
resp.raise_for_status()
|
|
83
|
+
except httpx.HTTPStatusError as exc:
|
|
84
|
+
logger.warning(
|
|
85
|
+
"Failed to report run exit (reason=%s): server returned HTTP %d", # pragma: no mutate
|
|
86
|
+
reason,
|
|
87
|
+
exc.response.status_code,
|
|
88
|
+
)
|
|
89
|
+
except Exception:
|
|
90
|
+
logger.warning(
|
|
91
|
+
"Failed to report run exit (reason=%s); server crash detector " # pragma: no mutate
|
|
92
|
+
"will fall back to terminal_reason=unknown after the 5-min " # pragma: no mutate
|
|
93
|
+
"heartbeat timeout.", # pragma: no mutate — log strings not asserted exactly; caplog uses substring match
|
|
94
|
+
reason,
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
def heartbeat(self, run_id: str) -> None:
|
|
98
|
+
resp = self._http.post(f"/runs/{run_id}/heartbeat")
|
|
99
|
+
resp.raise_for_status()
|
|
100
|
+
|
|
101
|
+
def send_metrics(self, run_id: str, metrics: list[dict]) -> dict:
|
|
102
|
+
resp = self._http.post(f"/runs/{run_id}/metrics", json={"metrics": metrics})
|
|
103
|
+
resp.raise_for_status()
|
|
104
|
+
return resp.json()
|
|
105
|
+
|
|
106
|
+
def create_media(
|
|
107
|
+
self,
|
|
108
|
+
run_id: str,
|
|
109
|
+
metric_name: str,
|
|
110
|
+
step: int,
|
|
111
|
+
file_ext: str,
|
|
112
|
+
file_size: int,
|
|
113
|
+
caption: str | None = None,
|
|
114
|
+
sidecar: dict | None = None,
|
|
115
|
+
) -> dict:
|
|
116
|
+
"""POST /runs/{run_id}/media — creates a media_records row + returns presigned upload URL."""
|
|
117
|
+
body: dict = {
|
|
118
|
+
"metric_name": metric_name,
|
|
119
|
+
"step": step,
|
|
120
|
+
"file_ext": file_ext,
|
|
121
|
+
"file_size": file_size,
|
|
122
|
+
}
|
|
123
|
+
if caption is not None:
|
|
124
|
+
body["caption"] = caption
|
|
125
|
+
if sidecar:
|
|
126
|
+
body["sidecar"] = sidecar
|
|
127
|
+
resp = self._http.post(f"/runs/{run_id}/media", json=body)
|
|
128
|
+
resp.raise_for_status()
|
|
129
|
+
return resp.json()
|
|
130
|
+
|
|
131
|
+
def post_logs(self, run_id: str, batch: dict) -> None:
|
|
132
|
+
"""POST a batch of log lines for a run. Accepts 200/202; raises on other non-2xx."""
|
|
133
|
+
resp = self._http.post(f"/runs/{run_id}/logs", json=batch)
|
|
134
|
+
if resp.status_code not in (200, 202):
|
|
135
|
+
resp.raise_for_status()
|
|
136
|
+
|
|
137
|
+
def upload_media(self, upload_url: str, data: bytes, content_type: str) -> None:
|
|
138
|
+
resp = httpx.put(upload_url, content=data, headers={"Content-Type": content_type})
|
|
139
|
+
resp.raise_for_status()
|
|
140
|
+
|
|
141
|
+
def resolve_project(self, ref: str) -> tuple[int, dict]:
|
|
142
|
+
"""Look up a project by ref. Returns (status, body). 200 means resolved;
|
|
143
|
+
400/403/404 mean the caller decides how to react (typically auto-create
|
|
144
|
+
on 404 for personal/team form). 5xx raises BuroHTTPError."""
|
|
145
|
+
r = self._http.get("/projects/resolve", params={"ref": ref})
|
|
146
|
+
if r.status_code >= 500:
|
|
147
|
+
raise BuroHTTPError(f"resolver 5xx: {r.text}")
|
|
148
|
+
return r.status_code, r.json()
|
|
149
|
+
|
|
150
|
+
def find_missing_code_blobs(self, run_id: str, sha256s: list[str]) -> dict:
|
|
151
|
+
"""POST /runs/{id}/code-manifest:find-missing"""
|
|
152
|
+
resp = self._http.post(
|
|
153
|
+
f"/runs/{run_id}/code-manifest:find-missing",
|
|
154
|
+
json={"sha256s": sha256s},
|
|
155
|
+
)
|
|
156
|
+
resp.raise_for_status()
|
|
157
|
+
return resp.json()
|
|
158
|
+
|
|
159
|
+
def finalize_code_manifest(
|
|
160
|
+
self,
|
|
161
|
+
run_id: str,
|
|
162
|
+
version: int,
|
|
163
|
+
complete: bool,
|
|
164
|
+
files: list[dict],
|
|
165
|
+
) -> dict:
|
|
166
|
+
"""POST /runs/{id}/code-manifest:finalize"""
|
|
167
|
+
resp = self._http.post(
|
|
168
|
+
f"/runs/{run_id}/code-manifest:finalize",
|
|
169
|
+
json={"version": version, "complete": complete, "files": files},
|
|
170
|
+
)
|
|
171
|
+
resp.raise_for_status()
|
|
172
|
+
return resp.json()
|
|
173
|
+
|
|
174
|
+
def create_project(
|
|
175
|
+
self, *, slug: str, name: str, team_slug: str | None = None,
|
|
176
|
+
description: str | None = None,
|
|
177
|
+
) -> tuple[int, dict]:
|
|
178
|
+
"""Create a project with the given slug. team_slug=None → personal;
|
|
179
|
+
team_slug=<slug> → team-scoped. Returns (status, body). 201 = created;
|
|
180
|
+
404 = team unknown; 403 = team exists but not a member; 409 = slug
|
|
181
|
+
collision. 5xx raises BuroHTTPError."""
|
|
182
|
+
scope: dict = {"type": "team", "team_slug": team_slug} if team_slug else {"type": "user"}
|
|
183
|
+
body = {"scope": scope, "slug": slug, "name": name}
|
|
184
|
+
if description is not None:
|
|
185
|
+
body["description"] = description
|
|
186
|
+
r = self._http.post("/projects", json=body)
|
|
187
|
+
if r.status_code >= 500:
|
|
188
|
+
raise BuroHTTPError(f"create-project 5xx: {r.text}")
|
|
189
|
+
return r.status_code, r.json()
|