robotrace-dev 0.1.0a2__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.
- robotrace/__init__.py +219 -0
- robotrace/_credentials.py +262 -0
- robotrace/_http.py +203 -0
- robotrace/_otel.py +153 -0
- robotrace/_version.py +9 -0
- robotrace/adapters/__init__.py +19 -0
- robotrace/adapters/ros2/__init__.py +62 -0
- robotrace/adapters/ros2/_classify.py +104 -0
- robotrace/adapters/ros2/_encode.py +717 -0
- robotrace/adapters/ros2/_scan.py +201 -0
- robotrace/adapters/ros2/_upload.py +211 -0
- robotrace/cli.py +509 -0
- robotrace/client.py +390 -0
- robotrace/episode.py +250 -0
- robotrace/errors.py +100 -0
- robotrace_dev-0.1.0a2.dist-info/METADATA +265 -0
- robotrace_dev-0.1.0a2.dist-info/RECORD +20 -0
- robotrace_dev-0.1.0a2.dist-info/WHEEL +4 -0
- robotrace_dev-0.1.0a2.dist-info/entry_points.txt +2 -0
- robotrace_dev-0.1.0a2.dist-info/licenses/LICENSE +21 -0
robotrace/__init__.py
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
"""RoboTrace — observability and evals for AI robots.
|
|
2
|
+
|
|
3
|
+
The public API in 30 seconds:
|
|
4
|
+
|
|
5
|
+
import robotrace as rt
|
|
6
|
+
|
|
7
|
+
# 1. Quickstart — one call per run.
|
|
8
|
+
rt.init(api_key="rt_…", base_url="https://app.robotrace.dev")
|
|
9
|
+
rt.log_episode(
|
|
10
|
+
name="pick_and_place v3 morning warmup",
|
|
11
|
+
policy_version="pap-v3.2.1",
|
|
12
|
+
env_version="halcyon-cell-rev4",
|
|
13
|
+
git_sha="abc1234",
|
|
14
|
+
seed=8124,
|
|
15
|
+
video="/tmp/run.mp4",
|
|
16
|
+
sensors="/tmp/sensors.bin",
|
|
17
|
+
actions="/tmp/actions.parquet",
|
|
18
|
+
duration_s=47.2,
|
|
19
|
+
fps=30,
|
|
20
|
+
metadata={"task": "pick_and_place"},
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
# 2. Streaming — explicit control of the lifecycle.
|
|
24
|
+
with rt.start_episode(name="…", policy_version="…") as ep:
|
|
25
|
+
ep.upload_video("/tmp/run.mp4")
|
|
26
|
+
# auto-finalize: ready on clean exit, failed on exception.
|
|
27
|
+
|
|
28
|
+
# 3. Multiple deployments at once — explicit Client.
|
|
29
|
+
with rt.Client(api_key="…", base_url="…") as client:
|
|
30
|
+
client.log_episode(...)
|
|
31
|
+
|
|
32
|
+
`log_episode` is the **sacred** signature per AGENTS.md — once 1.0
|
|
33
|
+
ships, breakages require a major bump and at least one minor of
|
|
34
|
+
deprecation warnings.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
from __future__ import annotations
|
|
38
|
+
|
|
39
|
+
from collections.abc import Mapping, Sequence
|
|
40
|
+
from pathlib import Path
|
|
41
|
+
from typing import Any
|
|
42
|
+
|
|
43
|
+
from ._version import __version__
|
|
44
|
+
from .client import (
|
|
45
|
+
ENV_API_KEY,
|
|
46
|
+
ENV_BASE_URL,
|
|
47
|
+
Client,
|
|
48
|
+
EpisodeSource,
|
|
49
|
+
)
|
|
50
|
+
from .episode import (
|
|
51
|
+
ArtifactKind,
|
|
52
|
+
Episode,
|
|
53
|
+
EpisodeFinalStatus,
|
|
54
|
+
UploadUrl,
|
|
55
|
+
)
|
|
56
|
+
from .errors import (
|
|
57
|
+
APIError,
|
|
58
|
+
AuthError,
|
|
59
|
+
ConfigurationError,
|
|
60
|
+
ConflictError,
|
|
61
|
+
NotFoundError,
|
|
62
|
+
RobotraceError,
|
|
63
|
+
ServerError,
|
|
64
|
+
TransportError,
|
|
65
|
+
ValidationError,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
__all__ = [
|
|
69
|
+
"__version__",
|
|
70
|
+
# Public types
|
|
71
|
+
"Client",
|
|
72
|
+
"Episode",
|
|
73
|
+
"UploadUrl",
|
|
74
|
+
"ArtifactKind",
|
|
75
|
+
"EpisodeSource",
|
|
76
|
+
"EpisodeFinalStatus",
|
|
77
|
+
# Errors
|
|
78
|
+
"RobotraceError",
|
|
79
|
+
"ConfigurationError",
|
|
80
|
+
"TransportError",
|
|
81
|
+
"APIError",
|
|
82
|
+
"AuthError",
|
|
83
|
+
"NotFoundError",
|
|
84
|
+
"ConflictError",
|
|
85
|
+
"ValidationError",
|
|
86
|
+
"ServerError",
|
|
87
|
+
# Top-level convenience (backed by the default client)
|
|
88
|
+
"init",
|
|
89
|
+
"close",
|
|
90
|
+
"start_episode",
|
|
91
|
+
"log_episode",
|
|
92
|
+
# Env-var names exposed for tooling that wants to validate them
|
|
93
|
+
"ENV_API_KEY",
|
|
94
|
+
"ENV_BASE_URL",
|
|
95
|
+
]
|
|
96
|
+
|
|
97
|
+
# ── module-level "default client" plumbing ──────────────────────────
|
|
98
|
+
#
|
|
99
|
+
# Mirrors what `requests.get(...)` does — convenient for scripts, but
|
|
100
|
+
# we explicitly support and document `Client(...)` for tests and any
|
|
101
|
+
# multi-deployment setup. The default client is constructed lazily on
|
|
102
|
+
# first use, so importing `robotrace` never hits the network or
|
|
103
|
+
# requires env vars.
|
|
104
|
+
|
|
105
|
+
_default_client: Client | None = None
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def init(
|
|
109
|
+
*,
|
|
110
|
+
api_key: str | None = None,
|
|
111
|
+
base_url: str | None = None,
|
|
112
|
+
timeout: float | None = None,
|
|
113
|
+
) -> None:
|
|
114
|
+
"""Configure the module-level default Client.
|
|
115
|
+
|
|
116
|
+
Calling this twice rebuilds the client; the previous one (if any)
|
|
117
|
+
is closed first. Safe to call from process startup.
|
|
118
|
+
"""
|
|
119
|
+
global _default_client
|
|
120
|
+
if _default_client is not None:
|
|
121
|
+
try:
|
|
122
|
+
_default_client.close()
|
|
123
|
+
except Exception:
|
|
124
|
+
pass
|
|
125
|
+
_default_client = Client(api_key=api_key, base_url=base_url, timeout=timeout)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def close() -> None:
|
|
129
|
+
"""Close the module-level default client. Safe to call twice."""
|
|
130
|
+
global _default_client
|
|
131
|
+
if _default_client is not None:
|
|
132
|
+
try:
|
|
133
|
+
_default_client.close()
|
|
134
|
+
finally:
|
|
135
|
+
_default_client = None
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _ensure_default_client() -> Client:
|
|
139
|
+
"""Lazy-construct the default client from env vars on first use.
|
|
140
|
+
|
|
141
|
+
Lets users skip the explicit `init(...)` call when they've set
|
|
142
|
+
`ROBOTRACE_API_KEY` / `ROBOTRACE_BASE_URL` (the common case for
|
|
143
|
+
CI-driven episode logging from a robot rig).
|
|
144
|
+
"""
|
|
145
|
+
global _default_client
|
|
146
|
+
if _default_client is None:
|
|
147
|
+
_default_client = Client()
|
|
148
|
+
return _default_client
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def start_episode(
|
|
152
|
+
*,
|
|
153
|
+
name: str | None = None,
|
|
154
|
+
source: EpisodeSource = "real",
|
|
155
|
+
robot: str | None = None,
|
|
156
|
+
policy_version: str | None = None,
|
|
157
|
+
env_version: str | None = None,
|
|
158
|
+
git_sha: str | None = None,
|
|
159
|
+
seed: int | None = None,
|
|
160
|
+
fps: float | None = None,
|
|
161
|
+
metadata: Mapping[str, Any] | None = None,
|
|
162
|
+
artifacts: Sequence[ArtifactKind] = ("video", "sensors", "actions"),
|
|
163
|
+
) -> Episode:
|
|
164
|
+
"""Open a new run on the configured deployment. See `Client.start_episode`."""
|
|
165
|
+
return _ensure_default_client().start_episode(
|
|
166
|
+
name=name,
|
|
167
|
+
source=source,
|
|
168
|
+
robot=robot,
|
|
169
|
+
policy_version=policy_version,
|
|
170
|
+
env_version=env_version,
|
|
171
|
+
git_sha=git_sha,
|
|
172
|
+
seed=seed,
|
|
173
|
+
fps=fps,
|
|
174
|
+
metadata=metadata,
|
|
175
|
+
artifacts=artifacts,
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def log_episode(
|
|
180
|
+
*,
|
|
181
|
+
name: str | None = None,
|
|
182
|
+
source: EpisodeSource = "real",
|
|
183
|
+
robot: str | None = None,
|
|
184
|
+
policy_version: str | None = None,
|
|
185
|
+
env_version: str | None = None,
|
|
186
|
+
git_sha: str | None = None,
|
|
187
|
+
seed: int | None = None,
|
|
188
|
+
video: str | Path | None = None,
|
|
189
|
+
sensors: str | Path | None = None,
|
|
190
|
+
actions: str | Path | None = None,
|
|
191
|
+
duration_s: float | None = None,
|
|
192
|
+
fps: float | None = None,
|
|
193
|
+
metadata: Mapping[str, Any] | None = None,
|
|
194
|
+
status: EpisodeFinalStatus = "ready",
|
|
195
|
+
) -> Episode:
|
|
196
|
+
"""Log a complete episode in one call. See `Client.log_episode`.
|
|
197
|
+
|
|
198
|
+
This is the **sacred** signature per AGENTS.md. Don't change it
|
|
199
|
+
without bumping a major version and shipping deprecation warnings
|
|
200
|
+
for at least one minor first.
|
|
201
|
+
"""
|
|
202
|
+
return _ensure_default_client().log_episode(
|
|
203
|
+
name=name,
|
|
204
|
+
source=source,
|
|
205
|
+
robot=robot,
|
|
206
|
+
policy_version=policy_version,
|
|
207
|
+
env_version=env_version,
|
|
208
|
+
git_sha=git_sha,
|
|
209
|
+
seed=seed,
|
|
210
|
+
video=video,
|
|
211
|
+
sensors=sensors,
|
|
212
|
+
actions=actions,
|
|
213
|
+
duration_s=duration_s,
|
|
214
|
+
fps=fps,
|
|
215
|
+
metadata=metadata,
|
|
216
|
+
status=status,
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
"""On-disk credentials store for the `robotrace` CLI.
|
|
2
|
+
|
|
3
|
+
The CLI's `login` command writes a small TOML file at
|
|
4
|
+
``~/.robotrace/credentials`` that the Python SDK auto-loads when
|
|
5
|
+
neither an explicit `api_key=` kwarg nor the `ROBOTRACE_API_KEY`
|
|
6
|
+
environment variable is set. The same file backs `whoami` and
|
|
7
|
+
`logout`.
|
|
8
|
+
|
|
9
|
+
Format
|
|
10
|
+
------
|
|
11
|
+
|
|
12
|
+
::
|
|
13
|
+
|
|
14
|
+
[default]
|
|
15
|
+
api_key = "rt_…"
|
|
16
|
+
base_url = "https://app.robotrace.dev"
|
|
17
|
+
client_id = "<uuid>"
|
|
18
|
+
user_email = "art@robotrace.dev"
|
|
19
|
+
written_at = "2026-05-04T20:08:14Z"
|
|
20
|
+
|
|
21
|
+
We support **profiles** in case a single workstation talks to more
|
|
22
|
+
than one deployment (production + a staging cell). The MVP only
|
|
23
|
+
reads/writes ``[default]``; the file format leaves room to extend
|
|
24
|
+
without breaking older SDKs that ignore unknown profiles.
|
|
25
|
+
|
|
26
|
+
Security
|
|
27
|
+
--------
|
|
28
|
+
|
|
29
|
+
The file holds a long-lived API key, so the writer enforces
|
|
30
|
+
``chmod 0600`` after writing. We never log the key value. Reads
|
|
31
|
+
fail loudly if the file is world-readable on systems that ship a
|
|
32
|
+
strict ``UMASK`` policy — the user can re-run ``robotrace login``
|
|
33
|
+
and the file will be re-created with the right perms.
|
|
34
|
+
|
|
35
|
+
Cross-platform notes
|
|
36
|
+
--------------------
|
|
37
|
+
|
|
38
|
+
* On POSIX we ``os.chmod`` to 0600 after a fresh write.
|
|
39
|
+
* On Windows ``os.chmod`` is largely a no-op for the read/write/
|
|
40
|
+
execute bits we care about, so we settle for storing in the
|
|
41
|
+
user's profile directory and rely on filesystem ACLs there.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
from __future__ import annotations
|
|
45
|
+
|
|
46
|
+
import json
|
|
47
|
+
import os
|
|
48
|
+
import sys
|
|
49
|
+
from dataclasses import dataclass
|
|
50
|
+
from datetime import datetime, timezone
|
|
51
|
+
from pathlib import Path
|
|
52
|
+
|
|
53
|
+
# tomllib only landed in 3.11; we still support 3.10 per pyproject.
|
|
54
|
+
if sys.version_info >= (3, 11):
|
|
55
|
+
import tomllib as _tomllib # noqa: PLC0415
|
|
56
|
+
else: # pragma: no cover — exercised on 3.10
|
|
57
|
+
_tomllib = None # type: ignore[assignment]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
CREDENTIALS_DIR_NAME = ".robotrace"
|
|
61
|
+
CREDENTIALS_FILE_NAME = "credentials"
|
|
62
|
+
DEFAULT_PROFILE = "default"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass
|
|
66
|
+
class StoredCredentials:
|
|
67
|
+
"""One profile's worth of credentials."""
|
|
68
|
+
|
|
69
|
+
api_key: str
|
|
70
|
+
base_url: str
|
|
71
|
+
client_id: str | None = None
|
|
72
|
+
user_email: str | None = None
|
|
73
|
+
written_at: str | None = None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def credentials_path() -> Path:
|
|
77
|
+
"""Resolve the path to the credentials file.
|
|
78
|
+
|
|
79
|
+
Uses ``$ROBOTRACE_HOME`` when set (handy for tests and CI), then
|
|
80
|
+
falls back to ``~/.robotrace`` on every platform — matches the
|
|
81
|
+
convention popular among devtools (``~/.aws``, ``~/.docker``,
|
|
82
|
+
``~/.kube``).
|
|
83
|
+
"""
|
|
84
|
+
override = os.environ.get("ROBOTRACE_HOME")
|
|
85
|
+
if override:
|
|
86
|
+
return Path(override) / CREDENTIALS_FILE_NAME
|
|
87
|
+
return Path.home() / CREDENTIALS_DIR_NAME / CREDENTIALS_FILE_NAME
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def write_credentials(creds: StoredCredentials, *, profile: str = DEFAULT_PROFILE) -> Path:
|
|
91
|
+
"""Persist `creds` under `profile` in the credentials file.
|
|
92
|
+
|
|
93
|
+
Returns the absolute path written. Creates the parent dir with
|
|
94
|
+
mode 0700, then writes the file with mode 0600. Raises on any
|
|
95
|
+
filesystem error — login is the last step of the flow, so a
|
|
96
|
+
failure to persist is worth surfacing loudly.
|
|
97
|
+
"""
|
|
98
|
+
path = credentials_path()
|
|
99
|
+
path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
100
|
+
|
|
101
|
+
existing = _read_all_profiles(path)
|
|
102
|
+
existing[profile] = {
|
|
103
|
+
"api_key": creds.api_key,
|
|
104
|
+
"base_url": creds.base_url,
|
|
105
|
+
"client_id": creds.client_id,
|
|
106
|
+
"user_email": creds.user_email,
|
|
107
|
+
"written_at": creds.written_at or _now_iso(),
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
_atomic_write_toml(path, existing)
|
|
111
|
+
try:
|
|
112
|
+
os.chmod(path, 0o600)
|
|
113
|
+
except OSError:
|
|
114
|
+
# On Windows / odd filesystems chmod can fail; the dir
|
|
115
|
+
# permission and user-profile location are our backstop.
|
|
116
|
+
pass
|
|
117
|
+
return path
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def read_credentials(*, profile: str = DEFAULT_PROFILE) -> StoredCredentials | None:
|
|
121
|
+
"""Load `profile` from the credentials file, or `None` if absent.
|
|
122
|
+
|
|
123
|
+
Returns `None` for any of: missing dir, missing file, profile
|
|
124
|
+
not present, malformed file. The SDK falls back to env-var auth
|
|
125
|
+
in those cases — we never want a corrupt creds file to surface
|
|
126
|
+
as a confusing "API key not provided" error.
|
|
127
|
+
"""
|
|
128
|
+
path = credentials_path()
|
|
129
|
+
if not path.is_file():
|
|
130
|
+
return None
|
|
131
|
+
try:
|
|
132
|
+
all_profiles = _read_all_profiles(path)
|
|
133
|
+
except Exception:
|
|
134
|
+
return None
|
|
135
|
+
raw = all_profiles.get(profile)
|
|
136
|
+
if not isinstance(raw, dict):
|
|
137
|
+
return None
|
|
138
|
+
api_key = raw.get("api_key")
|
|
139
|
+
base_url = raw.get("base_url")
|
|
140
|
+
if not isinstance(api_key, str) or not isinstance(base_url, str):
|
|
141
|
+
return None
|
|
142
|
+
if not api_key or not base_url:
|
|
143
|
+
return None
|
|
144
|
+
client_id = raw.get("client_id")
|
|
145
|
+
user_email = raw.get("user_email")
|
|
146
|
+
written_at = raw.get("written_at")
|
|
147
|
+
return StoredCredentials(
|
|
148
|
+
api_key=api_key,
|
|
149
|
+
base_url=base_url,
|
|
150
|
+
client_id=client_id if isinstance(client_id, str) else None,
|
|
151
|
+
user_email=user_email if isinstance(user_email, str) else None,
|
|
152
|
+
written_at=written_at if isinstance(written_at, str) else None,
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def delete_credentials(*, profile: str = DEFAULT_PROFILE) -> bool:
|
|
157
|
+
"""Remove `profile` from the credentials file. Returns True if
|
|
158
|
+
something was removed, False otherwise.
|
|
159
|
+
|
|
160
|
+
If the resulting file has no profiles left, the file (and its
|
|
161
|
+
parent dir, if empty) are deleted to leave a clean filesystem.
|
|
162
|
+
"""
|
|
163
|
+
path = credentials_path()
|
|
164
|
+
if not path.is_file():
|
|
165
|
+
return False
|
|
166
|
+
try:
|
|
167
|
+
existing = _read_all_profiles(path)
|
|
168
|
+
except Exception:
|
|
169
|
+
return False
|
|
170
|
+
if profile not in existing:
|
|
171
|
+
return False
|
|
172
|
+
existing.pop(profile)
|
|
173
|
+
if existing:
|
|
174
|
+
_atomic_write_toml(path, existing)
|
|
175
|
+
try:
|
|
176
|
+
os.chmod(path, 0o600)
|
|
177
|
+
except OSError:
|
|
178
|
+
pass
|
|
179
|
+
else:
|
|
180
|
+
try:
|
|
181
|
+
path.unlink()
|
|
182
|
+
except OSError:
|
|
183
|
+
return False
|
|
184
|
+
try:
|
|
185
|
+
path.parent.rmdir()
|
|
186
|
+
except OSError:
|
|
187
|
+
# Dir not empty (other tools' creds in there) — fine.
|
|
188
|
+
pass
|
|
189
|
+
return True
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
# ── internals ────────────────────────────────────────────────────────
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _now_iso() -> str:
|
|
196
|
+
return datetime.now(tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _read_all_profiles(path: Path) -> dict[str, object]:
|
|
200
|
+
"""Parse the whole credentials file into a {profile: {...}} dict.
|
|
201
|
+
|
|
202
|
+
Tolerant of either canonical TOML (which `_atomic_write_toml`
|
|
203
|
+
produces) or a fallback JSON-encoded body that older SDKs
|
|
204
|
+
might have written. Returns ``{}`` on parse errors so the
|
|
205
|
+
caller can treat it as a fresh write.
|
|
206
|
+
"""
|
|
207
|
+
if _tomllib is not None:
|
|
208
|
+
try:
|
|
209
|
+
with path.open("rb") as fh:
|
|
210
|
+
return dict(_tomllib.load(fh))
|
|
211
|
+
except Exception:
|
|
212
|
+
# fall through to JSON
|
|
213
|
+
pass
|
|
214
|
+
|
|
215
|
+
try:
|
|
216
|
+
text = path.read_text(encoding="utf-8")
|
|
217
|
+
except OSError:
|
|
218
|
+
return {}
|
|
219
|
+
|
|
220
|
+
# Last-resort JSON parser for environments without tomllib that
|
|
221
|
+
# somehow ended up with a JSON-formatted creds file.
|
|
222
|
+
try:
|
|
223
|
+
loaded = json.loads(text)
|
|
224
|
+
except Exception:
|
|
225
|
+
return {}
|
|
226
|
+
return dict(loaded) if isinstance(loaded, dict) else {}
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _atomic_write_toml(path: Path, data: dict[str, object]) -> None:
|
|
230
|
+
"""Write `data` to `path` as TOML, atomically.
|
|
231
|
+
|
|
232
|
+
We don't take a hard dep on a TOML *writer* (the stdlib gained
|
|
233
|
+
one in 3.11 only as ``tomllib`` for *reading*). Hand-rolling the
|
|
234
|
+
minimal subset we use — ``[section]`` headers and ``key = "str"``
|
|
235
|
+
pairs — avoids a third-party dep on the SDK install path.
|
|
236
|
+
"""
|
|
237
|
+
lines: list[str] = ["# robotrace credentials\n# managed by `robotrace login` — do not commit.\n\n"]
|
|
238
|
+
# Stable ordering keeps diffs readable when the user opens the
|
|
239
|
+
# file out of curiosity.
|
|
240
|
+
for profile in sorted(data.keys()):
|
|
241
|
+
section = data[profile]
|
|
242
|
+
if not isinstance(section, dict):
|
|
243
|
+
continue
|
|
244
|
+
lines.append(f"[{profile}]\n")
|
|
245
|
+
for key in sorted(section.keys()):
|
|
246
|
+
value = section.get(key)
|
|
247
|
+
if value is None:
|
|
248
|
+
continue
|
|
249
|
+
if not isinstance(value, str):
|
|
250
|
+
# Force-stringify; the schema is all-strings today.
|
|
251
|
+
value = str(value)
|
|
252
|
+
lines.append(f'{key} = "{_escape_toml(value)}"\n')
|
|
253
|
+
lines.append("\n")
|
|
254
|
+
|
|
255
|
+
tmp = path.with_suffix(path.suffix + ".tmp")
|
|
256
|
+
tmp.write_text("".join(lines), encoding="utf-8")
|
|
257
|
+
os.replace(tmp, path)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _escape_toml(value: str) -> str:
|
|
261
|
+
"""Escape the bare-minimum characters that break a basic TOML string."""
|
|
262
|
+
return value.replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n")
|
robotrace/_http.py
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
"""Internal HTTP wrapper.
|
|
2
|
+
|
|
3
|
+
Centralizes:
|
|
4
|
+
• auth header construction
|
|
5
|
+
• base_url normalization
|
|
6
|
+
• status-code → exception mapping
|
|
7
|
+
• redaction of the API key in error messages
|
|
8
|
+
|
|
9
|
+
Public API is intentionally minimal — call sites use only `request()`,
|
|
10
|
+
`upload_file()`, and the dataclass shapes. Avoids spreading httpx
|
|
11
|
+
specifics through `client.py` / `episode.py`.
|
|
12
|
+
|
|
13
|
+
NEVER log the value of the `Authorization` header or any request body
|
|
14
|
+
to the ingest endpoint. Both can carry secrets per AGENTS.md.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from collections.abc import Mapping
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
import httpx
|
|
24
|
+
|
|
25
|
+
from . import _version
|
|
26
|
+
from .errors import (
|
|
27
|
+
APIError,
|
|
28
|
+
AuthError,
|
|
29
|
+
ConflictError,
|
|
30
|
+
NotFoundError,
|
|
31
|
+
ServerError,
|
|
32
|
+
TransportError,
|
|
33
|
+
ValidationError,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
USER_AGENT = f"robotrace-python/{_version.__version__}"
|
|
37
|
+
|
|
38
|
+
# Default request timeout. Generous on read because the create call
|
|
39
|
+
# may block briefly on R2 signing; the upload PUT to R2 has its own
|
|
40
|
+
# (longer) timeout in `upload_file`.
|
|
41
|
+
DEFAULT_TIMEOUT = httpx.Timeout(connect=10.0, read=30.0, write=30.0, pool=10.0)
|
|
42
|
+
|
|
43
|
+
# Object-storage uploads can run minutes for multi-GB videos. We let
|
|
44
|
+
# httpx stream the body so memory stays flat regardless of file size,
|
|
45
|
+
# and bump the read/write timeout accordingly.
|
|
46
|
+
UPLOAD_TIMEOUT = httpx.Timeout(connect=15.0, read=600.0, write=600.0, pool=15.0)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class HTTPClient:
|
|
50
|
+
"""Thin wrapper around httpx.Client with RoboTrace defaults."""
|
|
51
|
+
|
|
52
|
+
def __init__(
|
|
53
|
+
self,
|
|
54
|
+
*,
|
|
55
|
+
api_key: str,
|
|
56
|
+
base_url: str,
|
|
57
|
+
timeout: httpx.Timeout | float | None = None,
|
|
58
|
+
transport: httpx.BaseTransport | None = None,
|
|
59
|
+
) -> None:
|
|
60
|
+
self._api_key = api_key
|
|
61
|
+
# Trim trailing slash so we can join paths with a leading slash
|
|
62
|
+
# without ending up with `//api/...`.
|
|
63
|
+
self._base_url = base_url.rstrip("/")
|
|
64
|
+
self._client = httpx.Client(
|
|
65
|
+
base_url=self._base_url,
|
|
66
|
+
timeout=timeout if timeout is not None else DEFAULT_TIMEOUT,
|
|
67
|
+
headers={
|
|
68
|
+
"User-Agent": USER_AGENT,
|
|
69
|
+
"Authorization": f"Bearer {api_key}",
|
|
70
|
+
"Accept": "application/json",
|
|
71
|
+
},
|
|
72
|
+
# `transport` is a hook for tests (httpx.MockTransport).
|
|
73
|
+
# Production callers leave it None and let httpx pick.
|
|
74
|
+
transport=transport,
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def base_url(self) -> str:
|
|
79
|
+
return self._base_url
|
|
80
|
+
|
|
81
|
+
def close(self) -> None:
|
|
82
|
+
self._client.close()
|
|
83
|
+
|
|
84
|
+
# ── core request ────────────────────────────────────────────────
|
|
85
|
+
|
|
86
|
+
def request(
|
|
87
|
+
self,
|
|
88
|
+
method: str,
|
|
89
|
+
path: str,
|
|
90
|
+
*,
|
|
91
|
+
json: Mapping[str, Any] | None = None,
|
|
92
|
+
) -> dict[str, Any]:
|
|
93
|
+
"""Send a JSON request and return the parsed JSON response.
|
|
94
|
+
|
|
95
|
+
Maps HTTP errors to the typed exception hierarchy. The raised
|
|
96
|
+
exception's `response_body` always carries the parsed body
|
|
97
|
+
(or raw text) so callers can introspect server-supplied
|
|
98
|
+
details without re-parsing.
|
|
99
|
+
"""
|
|
100
|
+
try:
|
|
101
|
+
response = self._client.request(method, path, json=json)
|
|
102
|
+
except httpx.TimeoutException as exc:
|
|
103
|
+
raise TransportError(f"timeout calling {path}: {exc}") from exc
|
|
104
|
+
except httpx.HTTPError as exc:
|
|
105
|
+
raise TransportError(f"transport error calling {path}: {exc}") from exc
|
|
106
|
+
|
|
107
|
+
return self._parse_response(response, path)
|
|
108
|
+
|
|
109
|
+
# ── streaming upload to a signed PUT URL ────────────────────────
|
|
110
|
+
|
|
111
|
+
def upload_file(
|
|
112
|
+
self,
|
|
113
|
+
url: str,
|
|
114
|
+
path: str | Path,
|
|
115
|
+
*,
|
|
116
|
+
content_type: str,
|
|
117
|
+
) -> int:
|
|
118
|
+
"""PUT `path` to `url` with `content_type`. Returns bytes uploaded.
|
|
119
|
+
|
|
120
|
+
Streams the file from disk so we don't blow up on multi-GB
|
|
121
|
+
videos. Uses a fresh httpx client (no auth header — the
|
|
122
|
+
signed URL carries the credentials) and a long timeout.
|
|
123
|
+
"""
|
|
124
|
+
file_path = Path(path)
|
|
125
|
+
if not file_path.is_file():
|
|
126
|
+
raise FileNotFoundError(f"artifact not found: {file_path}")
|
|
127
|
+
size = file_path.stat().st_size
|
|
128
|
+
|
|
129
|
+
try:
|
|
130
|
+
with file_path.open("rb") as fh, httpx.Client(
|
|
131
|
+
timeout=UPLOAD_TIMEOUT,
|
|
132
|
+
# Don't inherit our auth header here — the signed URL
|
|
133
|
+
# already carries the auth as query params.
|
|
134
|
+
) as client:
|
|
135
|
+
response = client.put(
|
|
136
|
+
url,
|
|
137
|
+
content=fh,
|
|
138
|
+
headers={
|
|
139
|
+
"Content-Type": content_type,
|
|
140
|
+
"Content-Length": str(size),
|
|
141
|
+
"User-Agent": USER_AGENT,
|
|
142
|
+
},
|
|
143
|
+
)
|
|
144
|
+
except httpx.TimeoutException as exc:
|
|
145
|
+
raise TransportError(f"upload timeout for {file_path.name}: {exc}") from exc
|
|
146
|
+
except httpx.HTTPError as exc:
|
|
147
|
+
raise TransportError(f"upload transport error for {file_path.name}: {exc}") from exc
|
|
148
|
+
|
|
149
|
+
if response.status_code >= 400:
|
|
150
|
+
# Object storage error bodies are XML, not JSON. Surface
|
|
151
|
+
# the raw text in the exception so the user can debug
|
|
152
|
+
# bucket / CORS / signature mismatches.
|
|
153
|
+
raise APIError(
|
|
154
|
+
f"upload failed for {file_path.name} ({response.status_code})",
|
|
155
|
+
status_code=response.status_code,
|
|
156
|
+
response_body=response.text[:1000],
|
|
157
|
+
)
|
|
158
|
+
return size
|
|
159
|
+
|
|
160
|
+
# ── internals ───────────────────────────────────────────────────
|
|
161
|
+
|
|
162
|
+
def _parse_response(self, response: httpx.Response, path: str) -> dict[str, Any]:
|
|
163
|
+
# Try JSON first; fall back to raw text so 5xx HTML pages
|
|
164
|
+
# don't crash error reporting.
|
|
165
|
+
try:
|
|
166
|
+
body: object = response.json()
|
|
167
|
+
except ValueError:
|
|
168
|
+
body = response.text
|
|
169
|
+
|
|
170
|
+
if response.is_success:
|
|
171
|
+
if isinstance(body, dict):
|
|
172
|
+
return body
|
|
173
|
+
# The ingest endpoints always return JSON objects; anything
|
|
174
|
+
# else is a server contract violation.
|
|
175
|
+
raise ServerError(
|
|
176
|
+
f"unexpected non-JSON success body from {path}",
|
|
177
|
+
status_code=response.status_code,
|
|
178
|
+
response_body=body,
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
message = self._error_message(body, response.status_code)
|
|
182
|
+
cls = _STATUS_TO_ERROR.get(response.status_code, APIError)
|
|
183
|
+
# 5xx falls through to ServerError. 401/404/409/4xx route to
|
|
184
|
+
# their typed subclasses for ergonomic catch blocks.
|
|
185
|
+
if response.status_code >= 500:
|
|
186
|
+
cls = ServerError
|
|
187
|
+
raise cls(message, status_code=response.status_code, response_body=body)
|
|
188
|
+
|
|
189
|
+
@staticmethod
|
|
190
|
+
def _error_message(body: object, status_code: int) -> str:
|
|
191
|
+
if isinstance(body, dict):
|
|
192
|
+
err = body.get("error")
|
|
193
|
+
if isinstance(err, str) and err:
|
|
194
|
+
return err
|
|
195
|
+
return f"HTTP {status_code}"
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
_STATUS_TO_ERROR: dict[int, type[APIError]] = {
|
|
199
|
+
400: ValidationError,
|
|
200
|
+
401: AuthError,
|
|
201
|
+
404: NotFoundError,
|
|
202
|
+
409: ConflictError,
|
|
203
|
+
}
|