robotrace-dev 0.1.0a2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,50 @@
1
+ # dependencies
2
+ node_modules/
3
+ .pnp
4
+ .pnp.*
5
+ .yarn/*
6
+ !.yarn/patches
7
+ !.yarn/plugins
8
+ !.yarn/releases
9
+ !.yarn/versions
10
+
11
+ # python
12
+ .venv/
13
+ venv/
14
+ __pycache__/
15
+ *.py[cod]
16
+ *$py.class
17
+ *.egg-info/
18
+ dist/
19
+ build/
20
+ .tox/
21
+ .pytest_cache/
22
+ .mypy_cache/
23
+ .ruff_cache/
24
+
25
+ # next
26
+ .next/
27
+ out/
28
+ *.tsbuildinfo
29
+ next-env.d.ts
30
+
31
+ # env
32
+ .env
33
+ .env.local
34
+ .env.*.local
35
+
36
+ # misc
37
+ .DS_Store
38
+ *.pem
39
+ .vercel
40
+ .npm-cache/
41
+ *.log
42
+ npm-debug.log*
43
+ yarn-debug.log*
44
+ yarn-error.log*
45
+ .pnpm-debug.log*
46
+
47
+ # editor
48
+ .vscode/
49
+ .idea/
50
+ .cursor/.ai/
@@ -0,0 +1,120 @@
1
+ # Changelog
2
+
3
+ All notable changes to the `robotrace-dev` Python SDK
4
+ (import name: `robotrace`).
5
+
6
+ The SDK follows [Semantic Versioning](https://semver.org/). Pre-1.0
7
+ releases (`0.x`) may make breaking changes between minor versions.
8
+ Once we cut `1.0.0`, the [`log_episode`](./README.md#log_episode-the-sacred-call)
9
+ signature is **locked** per AGENTS.md — breakages require a major
10
+ bump and at least one minor of `DeprecationWarning` first.
11
+
12
+ ## [Unreleased]
13
+
14
+ ## [0.1.0a2] — 2026-05-09
15
+
16
+ ### Changed
17
+
18
+ - **PyPI distribution name is `robotrace-dev`**, matching our
19
+ `robotrace.dev` domain. The un-hyphenated `robotrace` namespace on
20
+ PyPI was claimed in March 2026 by an unrelated robotics
21
+ observability project, and PyPI's typo-squat protector blocks any
22
+ single-edit-distance variant — including `robo-trace`, which we
23
+ tried first and PyPI rejected with `400 Bad Request`.
24
+ `robotrace-dev` clears the similarity threshold (Damerau-Levenshtein
25
+ ≥ 3 from both `robotrace` and `robotrace-sdk`) and reads as the
26
+ obvious match for our domain. The *import* name is still
27
+ `robotrace` (no hyphen, no `-dev`) — same convention as
28
+ `pip install python-dateutil` → `import dateutil`. **No code
29
+ changes required** in your application; the install command is
30
+ `pip install robotrace-dev`.
31
+ - Earlier `0.1.0a*` releases were never published to PyPI, so
32
+ there's no in-the-wild upgrade path to worry about — `0.1.0a2` is
33
+ the first published release.
34
+
35
+ ### Added
36
+
37
+ - **`robotrace-dev[otel]`** extra — opt-in OpenTelemetry trace
38
+ correlation. Pulls only `opentelemetry-api>=1.20` (~30 KB), not
39
+ the heavy `opentelemetry-sdk`. When `start_episode` is called
40
+ inside an active OTel span, the SDK reads the ambient context via
41
+ `opentelemetry.trace.get_current_span()` and attaches:
42
+ - `trace_id` (32-char lowercase hex)
43
+ - `span_id` (16-char lowercase hex)
44
+ - `traceparent` (W3C `00-<trace>-<span>-01` format)
45
+ to the create-episode payload. The server stores them under
46
+ `episodes.metadata.otel`; the portal renders a Tracing card on
47
+ the episode detail page with copy buttons and an optional
48
+ one-click "Open trace" deep-link via the
49
+ `NEXT_PUBLIC_TRACE_URL_TEMPLATE` env var (Datadog, Honeycomb,
50
+ Grafana Tempo, Jaeger).
51
+ - New `robotrace._otel` module with `capture_trace_context()`. Soft
52
+ imports — never raises if `opentelemetry` is missing or the active
53
+ span is invalid / unsampled.
54
+ - 7 new unit tests (`tests/test_otel.py`) covering: not-installed,
55
+ no-active-span, active-span happy path, unsampled flag, OTel
56
+ module misbehavior, public API exposure, and the `log_episode`
57
+ round-trip.
58
+ - The "sacred" `log_episode` / `start_episode` signature is
59
+ **unchanged** — OTel context is read implicitly. No new kwargs to
60
+ learn, no opt-in flag, no deprecation warnings on existing callers.
61
+
62
+ ## [0.1.0a1] — 2026-05-08
63
+
64
+ ### Added
65
+
66
+ - **`robotrace.adapters.ros2`** — read rosbag2 directories (sqlite3 +
67
+ mcap backends) and turn them into RoboTrace episodes without
68
+ needing an `rclpy` install. Three public verbs:
69
+ - `ros2.scan_bag(path) → BagSummary` — read-only introspection
70
+ with topic catalog, auto-classifier decisions, and bag duration.
71
+ - `ros2.encode_bag(path, output_dir) → EncodedBag` — writes
72
+ `video.mp4`, `sensors.npz`, `actions.npz` and returns the file
73
+ paths plus inferred `duration_s` / `fps`. No network.
74
+ - `ros2.upload_bag(path, **episode_kwargs) → Episode` — one-shot
75
+ scan + encode-to-tempdir + `start_episode` + `upload_*` +
76
+ `finalize`. The headline call.
77
+ - Auto-classification routes `sensor_msgs/Image` /
78
+ `CompressedImage` to `video`, `geometry_msgs/Twist*`,
79
+ `Wrench*`, `trajectory_msgs/JointTrajectory*`,
80
+ `control_msgs/JointJog`, and topics ending in `/cmd_*` /
81
+ `/command` to `actions`. Everything else lands in `sensors`.
82
+ Override per-slot via `video_topics=` / `sensor_topics=` /
83
+ `action_topics=` (empty list = exclude the slot).
84
+ - Multi-camera bags get tiled horizontally into a single
85
+ `video.mp4`; pass `canonical_video_topic=...` to pick one camera
86
+ instead. Frame rate inferred from the camera with the most
87
+ frames; falls back to 10 fps when timestamps are unusable.
88
+ - Built-in flatteners for `sensor_msgs/JointState`, `Imu`,
89
+ `geometry_msgs/Twist[Stamped]`, `Wrench[Stamped]`, `PoseStamped`,
90
+ `nav_msgs/Odometry`. Unknown message types fall through to a
91
+ generic dataclass walker that pulls every numeric leaf.
92
+
93
+ ### Changed
94
+
95
+ - Bumped `[project.optional-dependencies].ros2` from `[]` to
96
+ `["rosbags>=0.11,<0.12", "numpy>=1.26"]`. Pure Python — no real
97
+ ROS 2 install required to ingest bags.
98
+ - Image-topic encoding lives behind the existing `[video]` extra
99
+ (opencv-python). Install combo for camera bags is
100
+ `pip install 'robotrace-dev[ros2,video]'`; sensor-only bags can stick
101
+ with `[ros2]` and skip opencv entirely.
102
+
103
+ ## [0.1.0a0] — 2026-05-02
104
+
105
+ First public alpha. Contract under iteration.
106
+
107
+ ### Added
108
+
109
+ - `robotrace.init(...)`, `robotrace.start_episode(...)`,
110
+ `robotrace.log_episode(...)` top-level convenience.
111
+ - `robotrace.Client(...)` for explicit, multi-deployment use.
112
+ - `robotrace.Episode` handle with `upload_video` / `upload_sensors` /
113
+ `upload_actions` / `finalize` and context-manager auto-finalize
114
+ (clean exit → `ready`, exception → `failed`).
115
+ - Streaming PUT to Cloudflare R2 via signed URLs (no body buffering).
116
+ - Typed exception hierarchy (`RobotraceError` → `AuthError`,
117
+ `NotFoundError`, `ConflictError`, `ValidationError`, `ServerError`,
118
+ `TransportError`, `ConfigurationError`).
119
+ - `pytest`-based smoke tests pinning the wire format to
120
+ `/api/ingest/episode` and `/api/ingest/episode/{id}/finalize`.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 RoboTrace
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,265 @@
1
+ Metadata-Version: 2.4
2
+ Name: robotrace-dev
3
+ Version: 0.1.0a2
4
+ Summary: RoboTrace — observability and evals for AI robots.
5
+ Project-URL: Homepage, https://robotrace.dev
6
+ Project-URL: Documentation, https://robotrace.dev/docs
7
+ Project-URL: Issues, https://github.com/robotrace/robotrace/issues
8
+ Author-email: RoboTrace <hello@robotrace.dev>
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: evals,lerobot,observability,robotics,ros2,vla
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Programming Language :: Python :: 3 :: Only
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
19
+ Requires-Python: >=3.10
20
+ Requires-Dist: httpx>=0.27
21
+ Provides-Extra: dev
22
+ Requires-Dist: build>=1.2; extra == 'dev'
23
+ Requires-Dist: mypy>=1.10; extra == 'dev'
24
+ Requires-Dist: pytest-httpx>=0.30; extra == 'dev'
25
+ Requires-Dist: pytest>=8; extra == 'dev'
26
+ Requires-Dist: ruff>=0.6; extra == 'dev'
27
+ Provides-Extra: lerobot
28
+ Requires-Dist: lerobot; extra == 'lerobot'
29
+ Provides-Extra: numpy
30
+ Requires-Dist: numpy>=1.26; extra == 'numpy'
31
+ Provides-Extra: otel
32
+ Requires-Dist: opentelemetry-api>=1.20; extra == 'otel'
33
+ Provides-Extra: ros2
34
+ Requires-Dist: numpy>=1.26; extra == 'ros2'
35
+ Requires-Dist: rosbags<0.12,>=0.11; extra == 'ros2'
36
+ Provides-Extra: video
37
+ Requires-Dist: opencv-python>=4.10; extra == 'video'
38
+ Description-Content-Type: text/markdown
39
+
40
+ # robotrace-dev (Python SDK)
41
+
42
+ > The official Python SDK for [RoboTrace](https://robotrace.dev) —
43
+ > observability and evals for AI-powered robots.
44
+
45
+ ```bash
46
+ pip install robotrace-dev==0.1.0a2
47
+ ```
48
+
49
+ > **Distribution name vs. import name.** PyPI distributes us as
50
+ > `robotrace-dev` (matching our `robotrace.dev` domain). The
51
+ > un-hyphenated `robotrace` PyPI namespace is held by an unrelated
52
+ > robotics project, and PyPI's typo-squat protector blocks any
53
+ > single-edit-distance variant (so `robo-trace` was rejected too).
54
+ > The *import* name stays `import robotrace` — same pattern as
55
+ > `pip install python-dateutil` → `import dateutil`.
56
+
57
+ > **Status:** alpha (`0.1.0a2`). The public API in this README is the
58
+ > shape we're iterating against; once we cut `1.0.0`, the
59
+ > [`log_episode`](#log_episode-the-sacred-call) signature is locked
60
+ > and breakages require a major bump (per `AGENTS.md` in the
61
+ > RoboTrace monorepo).
62
+
63
+ ## Quickstart
64
+
65
+ Mint an API key in your RoboTrace admin console
66
+ (**Admin → Clients → \<client\> → API access**), then:
67
+
68
+ ```python
69
+ import robotrace as rt
70
+
71
+ rt.init(
72
+ api_key="rt_…",
73
+ base_url="https://app.robotrace.dev", # or http://localhost:3000 in dev
74
+ )
75
+
76
+ rt.log_episode(
77
+ name="pick_and_place v3 morning warmup",
78
+ source="real",
79
+ robot="halcyon-bimanual-01",
80
+ policy_version="pap-v3.2.1",
81
+ env_version="halcyon-cell-rev4",
82
+ git_sha="abc1234",
83
+ seed=8124,
84
+ video="/tmp/run.mp4",
85
+ sensors="/tmp/sensors.bin",
86
+ actions="/tmp/actions.parquet",
87
+ duration_s=47.2,
88
+ fps=30,
89
+ metadata={"task": "pick_and_place", "scene": "tabletop"},
90
+ )
91
+ ```
92
+
93
+ The episode appears in `/admin/episodes` immediately, with the four
94
+ reproducibility fields (policy / env / git / seed) front-and-center
95
+ on the detail page.
96
+
97
+ ### From environment variables
98
+
99
+ Same call without hardcoding the key:
100
+
101
+ ```bash
102
+ export ROBOTRACE_API_KEY=rt_…
103
+ export ROBOTRACE_BASE_URL=https://app.robotrace.dev
104
+ ```
105
+
106
+ ```python
107
+ import robotrace as rt
108
+
109
+ # init() is optional when both env vars are set — the default
110
+ # client is constructed lazily on first use.
111
+ rt.log_episode(
112
+ name="…",
113
+ policy_version="…",
114
+ video="/tmp/run.mp4",
115
+ )
116
+ ```
117
+
118
+ ## API
119
+
120
+ ### `log_episode` — the sacred call
121
+
122
+ The one-shot entrypoint. Equivalent to `start_episode` → upload all
123
+ artifacts → `finalize`. Use this for the 95% case of "I have files
124
+ on disk, log them and move on."
125
+
126
+ ```python
127
+ rt.log_episode(
128
+ *,
129
+ # Identification
130
+ name: str | None = None,
131
+ source: Literal["real", "sim", "replay"] = "real",
132
+ robot: str | None = None,
133
+
134
+ # Reproducibility — load-bearing per AGENTS.md
135
+ policy_version: str | None = None,
136
+ env_version: str | None = None,
137
+ git_sha: str | None = None,
138
+ seed: int | None = None,
139
+
140
+ # Artifact paths (uploaded to object storage via signed PUT URLs)
141
+ video: str | Path | None = None,
142
+ sensors: str | Path | None = None,
143
+ actions: str | Path | None = None,
144
+
145
+ # Run details
146
+ duration_s: float | None = None,
147
+ fps: float | None = None,
148
+ metadata: Mapping[str, Any] | None = None,
149
+
150
+ # Final state
151
+ status: Literal["ready", "failed"] = "ready",
152
+ ) -> Episode
153
+ ```
154
+
155
+ Returns the finalized `Episode`. On failure during upload the SDK
156
+ flips the run to `status="failed"` and re-raises so your program
157
+ sees what went wrong.
158
+
159
+ ### `start_episode` — explicit lifecycle
160
+
161
+ When you want fine-grained control (stream uploads, defer finalize,
162
+ react to upload errors per-artifact), use `start_episode` and the
163
+ returned `Episode` handle:
164
+
165
+ ```python
166
+ with rt.start_episode(
167
+ name="pick_and_place v3 morning warmup",
168
+ policy_version="pap-v3.2.1",
169
+ artifacts=["video", "sensors"], # only request the slots you'll fill
170
+ ) as ep:
171
+ ep.upload_video("/tmp/run.mp4")
172
+ ep.upload_sensors("/tmp/sensors.bin")
173
+ # No explicit finalize — context manager handles it:
174
+ # • clean exit → status="ready"
175
+ # • exception → status="failed", with metadata.failure_reason set
176
+ ```
177
+
178
+ Or explicit:
179
+
180
+ ```python
181
+ ep = rt.start_episode(name="…", policy_version="…", artifacts=["video"])
182
+ ep.upload_video("/tmp/run.mp4")
183
+ ep.finalize(status="ready", duration_s=47.2, fps=30)
184
+ ```
185
+
186
+ ### `Client` — explicit instance
187
+
188
+ Skip the module-level default when you need multiple deployments at
189
+ once (e.g. shipping the same run to staging + production), or for
190
+ clean dependency injection in tests:
191
+
192
+ ```python
193
+ with rt.Client(api_key="rt_…", base_url="https://…") as client:
194
+ client.log_episode(name="…", policy_version="…", video="…")
195
+ ```
196
+
197
+ `Client` holds a connection pool — construct it once at process
198
+ startup, reuse across many episodes, and `close()` (or use as a
199
+ context manager) on shutdown.
200
+
201
+ ## Errors
202
+
203
+ Every SDK error inherits from `robotrace.RobotraceError`. Catch by
204
+ type rather than parsing message strings:
205
+
206
+ | Exception | When |
207
+ | -------------------- | ------------------------------------------------------ |
208
+ | `ConfigurationError` | Missing `api_key` / `base_url`, file path doesn't exist |
209
+ | `TransportError` | Network / DNS / TLS / timeout |
210
+ | `AuthError` | 401 — bad / missing / revoked key |
211
+ | `NotFoundError` | 404 — episode id doesn't exist (or cross-tenant) |
212
+ | `ConflictError` | 409 — episode is archived, etc. |
213
+ | `ValidationError` | 400 — payload didn't pass server-side validation |
214
+ | `ServerError` | 5xx — flag for retries |
215
+
216
+ ```python
217
+ from robotrace import RobotraceError, AuthError
218
+
219
+ try:
220
+ rt.log_episode(...)
221
+ except AuthError:
222
+ # mint a fresh key and reload
223
+ raise
224
+ except RobotraceError:
225
+ # generic recovery / alert
226
+ raise
227
+ ```
228
+
229
+ ## Storage
230
+
231
+ Artifact uploads go to Cloudflare R2 via short-lived signed PUT URLs
232
+ the server mints for each call. The SDK streams from disk so memory
233
+ stays flat regardless of file size.
234
+
235
+ When the deployment hasn't wired R2 yet (`R2_ACCOUNT_ID` etc. are
236
+ blank), the create response has `storage="unconfigured"` and any
237
+ `upload_*` call raises `ConfigurationError` with a pointer to the
238
+ production setup checklist. Metadata-only runs still work — useful
239
+ for testing the SDK contract end-to-end before R2 is provisioned.
240
+
241
+ ## Layout (current)
242
+
243
+ ```
244
+ src/robotrace/
245
+ ├── __init__.py # public API + module-level default client
246
+ ├── _version.py
247
+ ├── client.py # Client class
248
+ ├── episode.py # Episode handle + UploadUrl + ArtifactKind
249
+ ├── errors.py # RobotraceError + typed subclasses
250
+ └── _http.py # internal httpx wrapper
251
+ ```
252
+
253
+ ROS 2 / LeRobot adapters land later under `src/robotrace/adapters/`.
254
+
255
+ ## Contributing
256
+
257
+ The SDK lives in the [RoboTrace monorepo](https://github.com/robotrace/robotrace).
258
+ The web app at `apps/web` exposes the ingest API the SDK talks to —
259
+ coordinate breaking changes across both, and treat the
260
+ [`/api/ingest/episode`](https://robotrace.dev/docs/api/ingest)
261
+ contract as the boundary.
262
+
263
+ ## License
264
+
265
+ MIT.
@@ -0,0 +1,226 @@
1
+ # robotrace-dev (Python SDK)
2
+
3
+ > The official Python SDK for [RoboTrace](https://robotrace.dev) —
4
+ > observability and evals for AI-powered robots.
5
+
6
+ ```bash
7
+ pip install robotrace-dev==0.1.0a2
8
+ ```
9
+
10
+ > **Distribution name vs. import name.** PyPI distributes us as
11
+ > `robotrace-dev` (matching our `robotrace.dev` domain). The
12
+ > un-hyphenated `robotrace` PyPI namespace is held by an unrelated
13
+ > robotics project, and PyPI's typo-squat protector blocks any
14
+ > single-edit-distance variant (so `robo-trace` was rejected too).
15
+ > The *import* name stays `import robotrace` — same pattern as
16
+ > `pip install python-dateutil` → `import dateutil`.
17
+
18
+ > **Status:** alpha (`0.1.0a2`). The public API in this README is the
19
+ > shape we're iterating against; once we cut `1.0.0`, the
20
+ > [`log_episode`](#log_episode-the-sacred-call) signature is locked
21
+ > and breakages require a major bump (per `AGENTS.md` in the
22
+ > RoboTrace monorepo).
23
+
24
+ ## Quickstart
25
+
26
+ Mint an API key in your RoboTrace admin console
27
+ (**Admin → Clients → \<client\> → API access**), then:
28
+
29
+ ```python
30
+ import robotrace as rt
31
+
32
+ rt.init(
33
+ api_key="rt_…",
34
+ base_url="https://app.robotrace.dev", # or http://localhost:3000 in dev
35
+ )
36
+
37
+ rt.log_episode(
38
+ name="pick_and_place v3 morning warmup",
39
+ source="real",
40
+ robot="halcyon-bimanual-01",
41
+ policy_version="pap-v3.2.1",
42
+ env_version="halcyon-cell-rev4",
43
+ git_sha="abc1234",
44
+ seed=8124,
45
+ video="/tmp/run.mp4",
46
+ sensors="/tmp/sensors.bin",
47
+ actions="/tmp/actions.parquet",
48
+ duration_s=47.2,
49
+ fps=30,
50
+ metadata={"task": "pick_and_place", "scene": "tabletop"},
51
+ )
52
+ ```
53
+
54
+ The episode appears in `/admin/episodes` immediately, with the four
55
+ reproducibility fields (policy / env / git / seed) front-and-center
56
+ on the detail page.
57
+
58
+ ### From environment variables
59
+
60
+ Same call without hardcoding the key:
61
+
62
+ ```bash
63
+ export ROBOTRACE_API_KEY=rt_…
64
+ export ROBOTRACE_BASE_URL=https://app.robotrace.dev
65
+ ```
66
+
67
+ ```python
68
+ import robotrace as rt
69
+
70
+ # init() is optional when both env vars are set — the default
71
+ # client is constructed lazily on first use.
72
+ rt.log_episode(
73
+ name="…",
74
+ policy_version="…",
75
+ video="/tmp/run.mp4",
76
+ )
77
+ ```
78
+
79
+ ## API
80
+
81
+ ### `log_episode` — the sacred call
82
+
83
+ The one-shot entrypoint. Equivalent to `start_episode` → upload all
84
+ artifacts → `finalize`. Use this for the 95% case of "I have files
85
+ on disk, log them and move on."
86
+
87
+ ```python
88
+ rt.log_episode(
89
+ *,
90
+ # Identification
91
+ name: str | None = None,
92
+ source: Literal["real", "sim", "replay"] = "real",
93
+ robot: str | None = None,
94
+
95
+ # Reproducibility — load-bearing per AGENTS.md
96
+ policy_version: str | None = None,
97
+ env_version: str | None = None,
98
+ git_sha: str | None = None,
99
+ seed: int | None = None,
100
+
101
+ # Artifact paths (uploaded to object storage via signed PUT URLs)
102
+ video: str | Path | None = None,
103
+ sensors: str | Path | None = None,
104
+ actions: str | Path | None = None,
105
+
106
+ # Run details
107
+ duration_s: float | None = None,
108
+ fps: float | None = None,
109
+ metadata: Mapping[str, Any] | None = None,
110
+
111
+ # Final state
112
+ status: Literal["ready", "failed"] = "ready",
113
+ ) -> Episode
114
+ ```
115
+
116
+ Returns the finalized `Episode`. On failure during upload the SDK
117
+ flips the run to `status="failed"` and re-raises so your program
118
+ sees what went wrong.
119
+
120
+ ### `start_episode` — explicit lifecycle
121
+
122
+ When you want fine-grained control (stream uploads, defer finalize,
123
+ react to upload errors per-artifact), use `start_episode` and the
124
+ returned `Episode` handle:
125
+
126
+ ```python
127
+ with rt.start_episode(
128
+ name="pick_and_place v3 morning warmup",
129
+ policy_version="pap-v3.2.1",
130
+ artifacts=["video", "sensors"], # only request the slots you'll fill
131
+ ) as ep:
132
+ ep.upload_video("/tmp/run.mp4")
133
+ ep.upload_sensors("/tmp/sensors.bin")
134
+ # No explicit finalize — context manager handles it:
135
+ # • clean exit → status="ready"
136
+ # • exception → status="failed", with metadata.failure_reason set
137
+ ```
138
+
139
+ Or explicit:
140
+
141
+ ```python
142
+ ep = rt.start_episode(name="…", policy_version="…", artifacts=["video"])
143
+ ep.upload_video("/tmp/run.mp4")
144
+ ep.finalize(status="ready", duration_s=47.2, fps=30)
145
+ ```
146
+
147
+ ### `Client` — explicit instance
148
+
149
+ Skip the module-level default when you need multiple deployments at
150
+ once (e.g. shipping the same run to staging + production), or for
151
+ clean dependency injection in tests:
152
+
153
+ ```python
154
+ with rt.Client(api_key="rt_…", base_url="https://…") as client:
155
+ client.log_episode(name="…", policy_version="…", video="…")
156
+ ```
157
+
158
+ `Client` holds a connection pool — construct it once at process
159
+ startup, reuse across many episodes, and `close()` (or use as a
160
+ context manager) on shutdown.
161
+
162
+ ## Errors
163
+
164
+ Every SDK error inherits from `robotrace.RobotraceError`. Catch by
165
+ type rather than parsing message strings:
166
+
167
+ | Exception | When |
168
+ | -------------------- | ------------------------------------------------------ |
169
+ | `ConfigurationError` | Missing `api_key` / `base_url`, file path doesn't exist |
170
+ | `TransportError` | Network / DNS / TLS / timeout |
171
+ | `AuthError` | 401 — bad / missing / revoked key |
172
+ | `NotFoundError` | 404 — episode id doesn't exist (or cross-tenant) |
173
+ | `ConflictError` | 409 — episode is archived, etc. |
174
+ | `ValidationError` | 400 — payload didn't pass server-side validation |
175
+ | `ServerError` | 5xx — flag for retries |
176
+
177
+ ```python
178
+ from robotrace import RobotraceError, AuthError
179
+
180
+ try:
181
+ rt.log_episode(...)
182
+ except AuthError:
183
+ # mint a fresh key and reload
184
+ raise
185
+ except RobotraceError:
186
+ # generic recovery / alert
187
+ raise
188
+ ```
189
+
190
+ ## Storage
191
+
192
+ Artifact uploads go to Cloudflare R2 via short-lived signed PUT URLs
193
+ the server mints for each call. The SDK streams from disk so memory
194
+ stays flat regardless of file size.
195
+
196
+ When the deployment hasn't wired R2 yet (`R2_ACCOUNT_ID` etc. are
197
+ blank), the create response has `storage="unconfigured"` and any
198
+ `upload_*` call raises `ConfigurationError` with a pointer to the
199
+ production setup checklist. Metadata-only runs still work — useful
200
+ for testing the SDK contract end-to-end before R2 is provisioned.
201
+
202
+ ## Layout (current)
203
+
204
+ ```
205
+ src/robotrace/
206
+ ├── __init__.py # public API + module-level default client
207
+ ├── _version.py
208
+ ├── client.py # Client class
209
+ ├── episode.py # Episode handle + UploadUrl + ArtifactKind
210
+ ├── errors.py # RobotraceError + typed subclasses
211
+ └── _http.py # internal httpx wrapper
212
+ ```
213
+
214
+ ROS 2 / LeRobot adapters land later under `src/robotrace/adapters/`.
215
+
216
+ ## Contributing
217
+
218
+ The SDK lives in the [RoboTrace monorepo](https://github.com/robotrace/robotrace).
219
+ The web app at `apps/web` exposes the ingest API the SDK talks to —
220
+ coordinate breaking changes across both, and treat the
221
+ [`/api/ingest/episode`](https://robotrace.dev/docs/api/ingest)
222
+ contract as the boundary.
223
+
224
+ ## License
225
+
226
+ MIT.