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 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()