oopsie-data-tools 0.2.0__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.
Files changed (74) hide show
  1. oopsie_data_tools/__init__.py +7 -0
  2. oopsie_data_tools/annotation_tool/__init__.py +5 -0
  3. oopsie_data_tools/annotation_tool/annotation_schema.py +266 -0
  4. oopsie_data_tools/annotation_tool/annotator_server.py +850 -0
  5. oopsie_data_tools/annotation_tool/episode_recorder.py +648 -0
  6. oopsie_data_tools/annotation_tool/rollout_annotator.py +333 -0
  7. oopsie_data_tools/annotation_tool/ui/annotator.html +2240 -0
  8. oopsie_data_tools/cli.py +894 -0
  9. oopsie_data_tools/init_wizard.py +329 -0
  10. oopsie_data_tools/skill/SKILL.md +98 -0
  11. oopsie_data_tools/skill/reference/conversion.md +257 -0
  12. oopsie_data_tools/skill/reference/format.md +112 -0
  13. oopsie_data_tools/skill/reference/robot-profile.md +104 -0
  14. oopsie_data_tools/skill/reference/setup.md +216 -0
  15. oopsie_data_tools/skill/reference/troubleshooting.md +97 -0
  16. oopsie_data_tools/test/__init__.py +1 -0
  17. oopsie_data_tools/test/conftest.py +174 -0
  18. oopsie_data_tools/test/fixtures/__init__.py +0 -0
  19. oopsie_data_tools/test/fixtures/make_invalid.py +633 -0
  20. oopsie_data_tools/test/fixtures/make_valid.py +406 -0
  21. oopsie_data_tools/test/test_annotation_completeness.py +154 -0
  22. oopsie_data_tools/test/test_annotation_schema.py +190 -0
  23. oopsie_data_tools/test/test_annotator_server.py +240 -0
  24. oopsie_data_tools/test/test_annotator_server_guards.py +146 -0
  25. oopsie_data_tools/test/test_bulk_inference_end_to_end.py +112 -0
  26. oopsie_data_tools/test/test_cli_config.py +111 -0
  27. oopsie_data_tools/test/test_cli_tools.py +438 -0
  28. oopsie_data_tools/test/test_contributor_config.py +38 -0
  29. oopsie_data_tools/test/test_conversion_utils_annotations.py +75 -0
  30. oopsie_data_tools/test/test_credentials_location.py +106 -0
  31. oopsie_data_tools/test/test_diversity.py +33 -0
  32. oopsie_data_tools/test/test_episode_recorder.py +335 -0
  33. oopsie_data_tools/test/test_episode_video_paths.py +173 -0
  34. oopsie_data_tools/test/test_hf_upload.py +234 -0
  35. oopsie_data_tools/test/test_init_wizard.py +193 -0
  36. oopsie_data_tools/test/test_install_skill.py +120 -0
  37. oopsie_data_tools/test/test_migrate_taxonomy_v2.py +255 -0
  38. oopsie_data_tools/test/test_new_profile.py +84 -0
  39. oopsie_data_tools/test/test_paths.py +137 -0
  40. oopsie_data_tools/test/test_python38_compat.py +79 -0
  41. oopsie_data_tools/test/test_record_step_purity.py +127 -0
  42. oopsie_data_tools/test/test_robot_setup.py +244 -0
  43. oopsie_data_tools/test/test_rollout_annotator.py +134 -0
  44. oopsie_data_tools/test/test_rotation_utils.py +126 -0
  45. oopsie_data_tools/test/test_validate.py +494 -0
  46. oopsie_data_tools/utils/__init__.py +1 -0
  47. oopsie_data_tools/utils/claude_skill.py +96 -0
  48. oopsie_data_tools/utils/contributor_config.py +91 -0
  49. oopsie_data_tools/utils/conversion_utils.py +220 -0
  50. oopsie_data_tools/utils/h5.py +60 -0
  51. oopsie_data_tools/utils/h5_inspect.py +167 -0
  52. oopsie_data_tools/utils/hf_limits.py +22 -0
  53. oopsie_data_tools/utils/hf_upload.py +235 -0
  54. oopsie_data_tools/utils/log.py +35 -0
  55. oopsie_data_tools/utils/migrate_taxonomy_v2.py +215 -0
  56. oopsie_data_tools/utils/paths.py +162 -0
  57. oopsie_data_tools/utils/restructure.py +402 -0
  58. oopsie_data_tools/utils/robot_profile/__init__.py +1 -0
  59. oopsie_data_tools/utils/robot_profile/robot_profile.py +240 -0
  60. oopsie_data_tools/utils/robot_profile/rotation_utils.py +119 -0
  61. oopsie_data_tools/utils/robot_profile/template.py +108 -0
  62. oopsie_data_tools/utils/validation/__init__.py +5 -0
  63. oopsie_data_tools/utils/validation/annotation_completeness.py +67 -0
  64. oopsie_data_tools/utils/validation/diversity.py +93 -0
  65. oopsie_data_tools/utils/validation/episode_data.py +54 -0
  66. oopsie_data_tools/utils/validation/episode_loader.py +201 -0
  67. oopsie_data_tools/utils/validation/episode_validator.py +315 -0
  68. oopsie_data_tools/utils/validation/errors.py +17 -0
  69. oopsie_data_tools/utils/validation/validation_utils.py +88 -0
  70. oopsie_data_tools-0.2.0.dist-info/METADATA +131 -0
  71. oopsie_data_tools-0.2.0.dist-info/RECORD +74 -0
  72. oopsie_data_tools-0.2.0.dist-info/WHEEL +4 -0
  73. oopsie_data_tools-0.2.0.dist-info/entry_points.txt +2 -0
  74. oopsie_data_tools-0.2.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,494 @@
1
+ """Tests for episode validation against oopsiedata_format_v1.
2
+
3
+ Fixtures are generated once per session via conftest.py (tmp_path_factory) and
4
+ cleaned up automatically. Per-test files use the built-in ``tmp_path`` fixture.
5
+
6
+ Every ``pytest.raises`` here names the message it expects. Without that, an invalid
7
+ fixture that fails for an unrelated reason still passes its test — which is what happened
8
+ for a long time: the fixtures declared videos nobody wrote, so most of them failed on
9
+ "Video file does not exist" and never reached the defect they were named for.
10
+
11
+ Sections
12
+ --------
13
+ TestReadable – file-level guard (exists, is HDF5)
14
+ TestRequiredAttrs – missing root attrs
15
+ TestRobotProfile – malformed / inconsistent robot_profile JSON
16
+ TestRequiredGroups – missing top-level or nested groups
17
+ TestTrajectoryLengths – mismatched / zero trajectory lengths
18
+ TestVideos – missing or too-small video files
19
+ TestValidEpisodes – happy-path: all registered tests pass
20
+ TestProfileDocumentsTheEpisode – profile and file must agree in both directions
21
+ TestValidateSessionDir – directory-level validation
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import json
27
+ from pathlib import Path
28
+
29
+ import h5py
30
+ import numpy as np
31
+ import pytest
32
+
33
+ from oopsie_data_tools.test.fixtures.make_valid import write_valid_episode
34
+ from oopsie_data_tools.utils.hf_upload import run_validation
35
+ from oopsie_data_tools.utils.validation.errors import EpisodeValidationError
36
+ from oopsie_data_tools.utils.validation.validation_utils import (
37
+ validate_h5_file,
38
+ validate_session_dir,
39
+ )
40
+
41
+ # ---------------------------------------------------------------------------
42
+ # Happy path
43
+ # ---------------------------------------------------------------------------
44
+
45
+
46
+ class TestValidEpisodes:
47
+ def test_minimally_annotated_episode_passes(self, valid_episode):
48
+ assert validate_h5_file(str(valid_episode)) is True
49
+
50
+ def test_minimally_annotated_episode_passes_strict(self, valid_episode):
51
+ assert validate_h5_file(str(valid_episode), strict_annotation_check=True) is True
52
+
53
+ def test_episode_without_annotations_passes_lenient(self, episode_without_annotations):
54
+ """No annotation group is structurally fine — this is a just-recorded episode."""
55
+ assert validate_h5_file(str(episode_without_annotations)) is True
56
+
57
+ def test_episode_without_annotations_fails_strict(self, episode_without_annotations):
58
+ """...but the CLI always checks annotations, so it cannot be uploaded yet.
59
+
60
+ This pins the asymmetry between EpisodeRecorder.finish_rollout (lenient, so an
61
+ episode can be saved before anyone has annotated it) and run_validation, which
62
+ both 'oopsie-data validate' and 'oopsie-data upload' call with strict=True.
63
+ """
64
+ with pytest.raises(EpisodeValidationError, match="Annotations dict is empty"):
65
+ validate_h5_file(str(episode_without_annotations), strict_annotation_check=True)
66
+
67
+ def test_cli_validation_rejects_an_episode_without_annotations(
68
+ self, episode_without_annotations
69
+ ):
70
+ """The strict flag is not optional at the CLI boundary — assert on run_validation."""
71
+ assert run_validation(str(episode_without_annotations.parent), None, None) == 1
72
+
73
+ def test_success_episode_passes(self, valid_success_episode):
74
+ assert validate_h5_file(str(valid_success_episode)) is True
75
+
76
+ def test_failure_episode_passes(self, valid_failure_episode):
77
+ assert validate_h5_file(str(valid_failure_episode)) is True
78
+
79
+
80
+ # ---------------------------------------------------------------------------
81
+ # File-level guard
82
+ # ---------------------------------------------------------------------------
83
+
84
+
85
+ class TestReadable:
86
+ def test_nonexistent_file_raises(self, tmp_path):
87
+ with pytest.raises(AssertionError, match="does not exist"):
88
+ validate_h5_file(str(tmp_path / "ghost.h5"))
89
+
90
+ def test_not_h5_raises(self, invalid_fixtures):
91
+ # (AssertionError, Exception) is just Exception, i.e. "something went wrong".
92
+ with pytest.raises(AssertionError, match="not readable"):
93
+ validate_h5_file(str(invalid_fixtures["invalid_not_h5"]))
94
+
95
+ def test_empty_h5_raises(self, invalid_fixtures):
96
+ with pytest.raises(AssertionError, match="Unsupported or missing schema"):
97
+ validate_h5_file(str(invalid_fixtures["invalid_empty_h5"]))
98
+
99
+
100
+ # ---------------------------------------------------------------------------
101
+ # Missing root attrs
102
+ # ---------------------------------------------------------------------------
103
+
104
+
105
+ class TestRequiredAttrs:
106
+ @pytest.mark.parametrize(
107
+ "fixture_key",
108
+ [
109
+ "invalid_missing_attrs", # all attrs absent
110
+ ],
111
+ )
112
+ def test_missing_attrs_raises(self, invalid_fixtures, fixture_key):
113
+ with pytest.raises(AssertionError, match="Missing root attr"):
114
+ validate_h5_file(str(invalid_fixtures[fixture_key]))
115
+
116
+
117
+ # ---------------------------------------------------------------------------
118
+ # robot_profile JSON validation
119
+ # ---------------------------------------------------------------------------
120
+
121
+
122
+ class TestRobotProfile:
123
+ def test_malformed_robot_profile_raises(self, invalid_fixtures):
124
+ with pytest.raises(AssertionError, match="robot_profile"):
125
+ validate_h5_file(str(invalid_fixtures["invalid_malformed_profile"]))
126
+
127
+ @pytest.mark.parametrize(
128
+ "fixture_key,match",
129
+ [
130
+ ("invalid_profile_missing_key", "Robot profile missing keys"),
131
+ ("invalid_profile_no_gripper", "Invalid action_space"),
132
+ ("invalid_profile_joint_no_names", "action_joint_names is required"),
133
+ ("invalid_profile_unsupported_action", "Invalid action_space"),
134
+ ("invalid_profile_missing_rs_key", "missing robot state keys"),
135
+ # An empty camera_names list is not itself rejected; the episode fails because
136
+ # it then carries no video group. Worth knowing which rule is doing the work.
137
+ ("invalid_profile_empty_cameras", "Missing group: observations/video_paths"),
138
+ ],
139
+ )
140
+ def test_invalid_profile_semantics_raise(self, invalid_fixtures, fixture_key, match):
141
+ with pytest.raises(AssertionError, match=match):
142
+ validate_h5_file(str(invalid_fixtures[fixture_key]))
143
+
144
+ def test_invalid_control_freq_zero_raises(self, invalid_fixtures):
145
+ # "control_freq" alone used to match the fixture's own *filename* in the
146
+ # "Video file does not exist: .../invalid_control_freq_zero_front.mp4" message,
147
+ # so this passed without the check under test ever running.
148
+ with pytest.raises(AssertionError, match=r"control_freq must be > 0"):
149
+ validate_h5_file(str(invalid_fixtures["invalid_control_freq_zero"]))
150
+
151
+ # ---------------------------------------------------------------------------
152
+ # Missing groups
153
+ # ---------------------------------------------------------------------------
154
+
155
+
156
+ class TestRequiredGroups:
157
+ @pytest.mark.parametrize(
158
+ "fixture_key,match",
159
+ [
160
+ ("invalid_actions_missing", "Missing group: actions"),
161
+ ("invalid_robot_states_missing", "Missing group: observations/robot_states"),
162
+ # observations/video_paths group entirely absent
163
+ ("invalid_no_video_group", "Missing group: observations/video_paths"),
164
+ ],
165
+ )
166
+ def test_missing_group_raises(self, invalid_fixtures, fixture_key, match):
167
+ with pytest.raises(AssertionError, match=match):
168
+ validate_h5_file(str(invalid_fixtures[fixture_key]))
169
+
170
+ def test_missing_robot_state_key_raises(self, invalid_fixtures):
171
+ with pytest.raises(AssertionError, match="Missing observations/robot_states/"):
172
+ validate_h5_file(str(invalid_fixtures["invalid_robot_state_missing_key"]))
173
+
174
+
175
+ # ---------------------------------------------------------------------------
176
+ # Trajectory length violations
177
+ # ---------------------------------------------------------------------------
178
+
179
+
180
+ class TestTrajectoryLengths:
181
+ def test_mismatched_lengths_raises(self, invalid_fixtures):
182
+ with pytest.raises(AssertionError, match="Inconsistent trajectory lengths"):
183
+ validate_h5_file(str(invalid_fixtures["invalid_mismatched_steps"]))
184
+
185
+ def test_zero_steps_raises(self, invalid_fixtures):
186
+ with pytest.raises(AssertionError, match="episode duration 0.00s out of range"):
187
+ validate_h5_file(str(invalid_fixtures["invalid_zero_steps"]))
188
+
189
+ def test_zero_trajectory_via_tmp_path(self, tmp_path):
190
+ h5_path = write_valid_episode(tmp_path, "zero", n=0)
191
+ with pytest.raises(AssertionError, match="No trajectory data|out of range"):
192
+ validate_h5_file(str(h5_path), strict_annotation_check=True)
193
+
194
+
195
+ # ---------------------------------------------------------------------------
196
+ # Video checks
197
+ # ---------------------------------------------------------------------------
198
+
199
+
200
+ class TestVideos:
201
+ def test_broken_video_ref_raises(self, invalid_fixtures):
202
+ with pytest.raises(AssertionError, match="does not exist"):
203
+ validate_h5_file(str(invalid_fixtures["invalid_broken_video_ref"]))
204
+
205
+ def test_array_in_video_path_raises(self, invalid_fixtures):
206
+ with pytest.raises(AssertionError, match="does not exist"):
207
+ validate_h5_file(str(invalid_fixtures["invalid_image_obs_float"]))
208
+
209
+ # Both of these used to allow "|Video too small" as an alternative, and every fixture
210
+ # video was 64px against a 180px minimum — so that branch always won and neither
211
+ # frame-count check ran even once.
212
+ def test_inconsistent_video_lengths_raise(self, invalid_fixtures):
213
+ with pytest.raises(AssertionError, match=r"Frame count / trajectory mismatch"):
214
+ validate_h5_file(str(invalid_fixtures["invalid_inconsistent_video_lengths"]))
215
+
216
+ def test_video_length_step_mismatch_raises(self, invalid_fixtures):
217
+ with pytest.raises(AssertionError, match=r"Frame count / trajectory mismatch"):
218
+ validate_h5_file(str(invalid_fixtures["invalid_video_length_step_mismatch"]))
219
+
220
+ def test_video_below_the_minimum_size_is_rejected(self, tmp_path):
221
+ """The check that used to mask every other video assertion, now on its own."""
222
+ from oopsie_data_tools.test.fixtures.make_invalid import _write_video
223
+
224
+ h5_path = write_valid_episode(tmp_path, "tiny")
225
+ with h5py.File(h5_path, "r") as f:
226
+ rel = f["observations/video_paths/front"][()].decode("utf-8")
227
+ _write_video(tmp_path / rel, (10, 10, 10), size=64)
228
+
229
+ with pytest.raises(AssertionError, match="Video too small"):
230
+ validate_h5_file(str(h5_path), strict_annotation_check=True)
231
+
232
+
233
+ # ---------------------------------------------------------------------------
234
+ # Profile/file consistency
235
+ # ---------------------------------------------------------------------------
236
+
237
+
238
+ class TestProfileFileConsistency:
239
+ @pytest.mark.parametrize(
240
+ "fixture_key,match",
241
+ [
242
+ (
243
+ "invalid_joint_names_length_mismatch",
244
+ "robot_state_joint_names count does not match",
245
+ ),
246
+ (
247
+ "invalid_action_names_length_mismatch",
248
+ "action_joint_names count does not match",
249
+ ),
250
+ (
251
+ "invalid_profile_camera_not_in_obs",
252
+ "Missing observations/video_paths/",
253
+ ),
254
+ (
255
+ "invalid_profile_action_not_in_recorded",
256
+ "Missing actions/",
257
+ ),
258
+ (
259
+ "invalid_profile_rs_key_not_in_recorded",
260
+ "Missing observations/robot_states/",
261
+ ),
262
+ (
263
+ "invalid_multiple_promised_fields_missing",
264
+ "Missing observations/robot_states/",
265
+ ),
266
+ ],
267
+ )
268
+ def test_profile_file_consistency_raises(
269
+ self, invalid_fixtures, fixture_key, match
270
+ ):
271
+ with pytest.raises(AssertionError, match=match):
272
+ validate_h5_file(str(invalid_fixtures[fixture_key]))
273
+
274
+ # ---------------------------------------------------------------------------
275
+ # validate_session_dir
276
+ # ---------------------------------------------------------------------------
277
+
278
+
279
+ class TestPreviouslyUnreferencedFixtures:
280
+ """Fixtures that were generated every session and asserted on by nothing.
281
+
282
+ All four detect a real defect; they simply had no test. The annotation-dataset one is
283
+ the sharpest: it was written to catch ``episode_annotations`` stored as a dataset, which
284
+ used to escape as ``AttributeError`` from ``.keys()`` rather than as a validation error.
285
+ """
286
+
287
+ @pytest.mark.parametrize(
288
+ "fixture_key,match",
289
+ [
290
+ (
291
+ "invalid_annotation_dataset",
292
+ "episode_annotations must be a group of per-annotator subgroups",
293
+ ),
294
+ (
295
+ "invalid_joint_pos_wrong_dof",
296
+ "robot_state_joint_names count does not match",
297
+ ),
298
+ (
299
+ "invalid_joint_vel_wrong_dof",
300
+ "action_joint_names count does not match",
301
+ ),
302
+ (
303
+ "invalid_taxonomy_not_json",
304
+ "taxonomy is not valid JSON",
305
+ ),
306
+ ],
307
+ )
308
+ def test_fixture_fails_on_its_own_defect(self, invalid_fixtures, fixture_key, match):
309
+ with pytest.raises(AssertionError, match=match):
310
+ validate_h5_file(str(invalid_fixtures[fixture_key]), strict_annotation_check=True)
311
+
312
+
313
+ class TestProfileDocumentsTheEpisode:
314
+ """The profile is what documents an episode, so the two must agree in both directions."""
315
+
316
+ def test_undeclared_robot_state_key_is_rejected(self, invalid_fixtures):
317
+ """Data the profile does not declare has no joint names, units or expected DOF."""
318
+ with pytest.raises(AssertionError, match="does not declare.*velocity_hack"):
319
+ validate_h5_file(str(invalid_fixtures["invalid_robot_state_extra_key"]))
320
+
321
+ def test_a_declared_key_may_be_absent_is_still_the_other_error(self, invalid_fixtures):
322
+ """The reverse direction was always enforced; check the two messages stay distinct."""
323
+ with pytest.raises(AssertionError, match="Missing observations/robot_states/"):
324
+ validate_h5_file(str(invalid_fixtures["invalid_robot_state_missing_key"]))
325
+
326
+ def test_biarm_profile_rejects_a_single_arm_cartesian_pose(self, invalid_fixtures):
327
+ """7 DOF is one [x,y,z,qx,qy,qz,qw]; a bimanual robot records 14."""
328
+ with pytest.raises(AssertionError, match=r"has 7 DOF.*is_biarm=True.*means 14"):
329
+ validate_h5_file(str(invalid_fixtures["invalid_profile_biarm_mismatch"]))
330
+
331
+ def test_joint_count_is_not_constrained_by_is_biarm(self, tmp_path):
332
+ """Deliberately not a rule: two arms need not share a DOF count, so 7+6 is valid."""
333
+ h5_path = write_valid_episode(tmp_path, "asymmetric")
334
+ with h5py.File(h5_path, "r+") as f:
335
+ profile = json.loads(f.attrs["robot_profile"])
336
+ profile["is_biarm"] = True
337
+ f.attrs["robot_profile"] = json.dumps(profile)
338
+
339
+ assert validate_h5_file(str(h5_path), strict_annotation_check=True) is True
340
+
341
+ @pytest.mark.parametrize("gripper_dof", [1, 2, 3])
342
+ def test_gripper_dof_is_whatever_the_profile_owner_records(self, tmp_path, gripper_dof):
343
+ """Deliberately not a rule.
344
+
345
+ Nothing in RobotProfile declares how many gripper DOF to expect — only
346
+ ``gripper_name`` — so there is nothing to check against. Inferring 1-or-2 from
347
+ ``is_biarm`` would reject a multi-finger hand, which ``gripper_name`` explicitly
348
+ anticipates (``robotiq_3f`` and friends). Constraining this needs a
349
+ ``gripper_joint_names`` field mirroring ``robot_state_joint_names``; until then the
350
+ recorded width is accepted as given.
351
+ """
352
+ h5_path = write_valid_episode(tmp_path, f"gripper{gripper_dof}")
353
+ with h5py.File(h5_path, "r+") as f:
354
+ group = f["observations/robot_states"]
355
+ n = group["gripper_position"].shape[0]
356
+ del group["gripper_position"]
357
+ group.create_dataset(
358
+ "gripper_position", data=np.zeros((n, gripper_dof), dtype=np.float64)
359
+ )
360
+
361
+ assert validate_h5_file(str(h5_path), strict_annotation_check=True) is True
362
+
363
+
364
+ class TestValidateSessionDir:
365
+ """Return codes follow the shell convention: 0 = all passed, 1 = failure."""
366
+
367
+ def test_valid_session_passes(self, valid_session_dir):
368
+ assert validate_session_dir(str(valid_session_dir)) == 0
369
+
370
+ def test_nonexistent_dir_returns_1(self, tmp_path):
371
+ assert validate_session_dir(str(tmp_path / "no_such_dir")) == 1
372
+
373
+ def test_empty_dir_returns_1(self, tmp_path):
374
+ assert validate_session_dir(str(tmp_path)) == 1
375
+
376
+ def test_mixed_dir_returns_1(self, tmp_path):
377
+ write_valid_episode(tmp_path, "good")
378
+ (tmp_path / "bad.h5").write_text("not hdf5")
379
+ assert validate_session_dir(str(tmp_path)) == 1
380
+
381
+ def test_all_valid_returns_0(self, tmp_path):
382
+ write_valid_episode(tmp_path, "ep_a")
383
+ write_valid_episode(tmp_path, "ep_b")
384
+ assert validate_session_dir(str(tmp_path)) == 0
385
+
386
+
387
+ # ---------------------------------------------------------------------------
388
+ # Better error messaging (#21)
389
+ # ---------------------------------------------------------------------------
390
+
391
+
392
+ class TestBetterErrors:
393
+ def test_validate_accepts_log_path(self, valid_success_episode, tmp_path):
394
+ # Regression: upload.py passes log_path to validate_h5_file for single files.
395
+ log_path = tmp_path / "validate.log"
396
+ assert validate_h5_file(str(valid_success_episode), log_path=str(log_path)) is True
397
+ assert log_path.exists(), "a log path that is accepted but never written is not a log"
398
+
399
+ def test_robot_state_joint_dof_message(self, invalid_fixtures):
400
+ with pytest.raises(
401
+ AssertionError, match="robot_state_joint_names count does not match"
402
+ ):
403
+ validate_h5_file(str(invalid_fixtures["invalid_joint_names_length_mismatch"]))
404
+
405
+ def test_action_joint_dof_message(self, invalid_fixtures):
406
+ with pytest.raises(
407
+ AssertionError, match="action_joint_names count does not match"
408
+ ):
409
+ validate_h5_file(str(invalid_fixtures["invalid_action_names_length_mismatch"]))
410
+
411
+
412
+ # ---------------------------------------------------------------------------
413
+ # Annotation semantics (#29 qualified success, #26 multi/non-human annotators)
414
+ # ---------------------------------------------------------------------------
415
+
416
+
417
+ class TestAnnotationSemantics:
418
+ def test_success_side_effect_with_severity_passes(self, tmp_path: Path) -> None:
419
+ """A qualified success may set severity without a full taxonomy (#29)."""
420
+ h5_path = write_valid_episode(tmp_path, stem="ep")
421
+ with h5py.File(h5_path, "r+") as f:
422
+ g = f["episode_annotations"]["test_annotator"]
423
+ g.attrs["success"] = 1.0
424
+ g.attrs["taxonomy"] = json.dumps(
425
+ {
426
+ "outcome": "success_side_effect",
427
+ "side_effect_category": [],
428
+ "severity": "low",
429
+ }
430
+ )
431
+ assert validate_h5_file(str(h5_path), strict_annotation_check=True) is True
432
+
433
+ def test_partial_failure_taxonomy_is_now_valid(self, tmp_path: Path) -> None:
434
+ """v2 relaxed the rule: only the outcome is required, so a partial trio passes.
435
+
436
+ Under v1 this exact file was rejected ("all filled or all empty"). Keeping it
437
+ rejected would mean an annotator could not save work in progress.
438
+ """
439
+ h5_path = write_valid_episode(tmp_path, stem="ep")
440
+ with h5py.File(h5_path, "r+") as f:
441
+ g = f["episode_annotations"]["test_annotator"]
442
+ g.attrs["success"] = 0.0
443
+ g.attrs["episode_description"] = ""
444
+ g.attrs["taxonomy"] = json.dumps(
445
+ {"outcome": "failure", "side_effect_category": [], "severity": "low"}
446
+ )
447
+ assert validate_h5_file(str(h5_path), strict_annotation_check=True) is True
448
+
449
+ def test_failure_with_no_taxonomy_at_all_is_valid(self, tmp_path: Path) -> None:
450
+ h5_path = write_valid_episode(tmp_path, stem="ep")
451
+ with h5py.File(h5_path, "r+") as f:
452
+ g = f["episode_annotations"]["test_annotator"]
453
+ g.attrs["success"] = 0.0
454
+ g.attrs["episode_description"] = ""
455
+ g.attrs["taxonomy"] = json.dumps(
456
+ {"outcome": "failure", "side_effect_category": [], "severity": ""}
457
+ )
458
+ assert validate_h5_file(str(h5_path), strict_annotation_check=True) is True
459
+
460
+ def test_outcome_disagreeing_with_success_is_rejected(self, tmp_path: Path) -> None:
461
+ """A float and a slug that contradict each other would split downstream readers."""
462
+ h5_path = write_valid_episode(tmp_path, stem="ep")
463
+ with h5py.File(h5_path, "r+") as f:
464
+ g = f["episode_annotations"]["test_annotator"]
465
+ g.attrs["success"] = 1.0
466
+ g.attrs["taxonomy"] = json.dumps({"outcome": "failure", "severity": ""})
467
+ with pytest.raises(AssertionError, match="disagrees with success"):
468
+ validate_h5_file(str(h5_path), strict_annotation_check=True)
469
+
470
+ def test_unrecognized_outcome_is_rejected(self, tmp_path: Path) -> None:
471
+ h5_path = write_valid_episode(tmp_path, stem="ep")
472
+ with h5py.File(h5_path, "r+") as f:
473
+ g = f["episode_annotations"]["test_annotator"]
474
+ g.attrs["taxonomy"] = json.dumps({"outcome": "sort_of_worked"})
475
+ with pytest.raises(AssertionError, match="unrecognized outcome"):
476
+ validate_h5_file(str(h5_path), strict_annotation_check=True)
477
+
478
+ def test_incomplete_extra_subgroup_fails(self, tmp_path: Path) -> None:
479
+ """Every annotator subgroup must be complete: a stray incomplete one fails the episode."""
480
+ h5_path = write_valid_episode(tmp_path, stem="ep")
481
+ with h5py.File(h5_path, "r+") as f:
482
+ extra = f["episode_annotations"].require_group("cosmos-7b")
483
+ extra.attrs["source"] = "cosmos-7b" # no 'success' → incomplete subgroup
484
+ with pytest.raises(AssertionError, match="missing 'success'"):
485
+ validate_h5_file(str(h5_path), strict_annotation_check=True)
486
+
487
+ def test_extra_complete_subgroup_passes(self, tmp_path: Path) -> None:
488
+ """An additional fully-annotated subgroup (any source) is fine."""
489
+ h5_path = write_valid_episode(tmp_path, stem="ep")
490
+ with h5py.File(h5_path, "r+") as f:
491
+ extra = f["episode_annotations"].require_group("cosmos-7b")
492
+ extra.attrs["source"] = "cosmos-7b"
493
+ extra.attrs["success"] = 1.0
494
+ assert validate_h5_file(str(h5_path), strict_annotation_check=True) is True
@@ -0,0 +1 @@
1
+ """Shared utilities: config paths, logging, robot profiles, validation."""
@@ -0,0 +1,96 @@
1
+ """Install the bundled Claude Code skill into a user's Claude configuration.
2
+
3
+ Claude Code discovers skills by scanning the filesystem for ``<dir>/SKILL.md``, so
4
+ "installing" one is a directory copy — no Claude CLI, and no network access, is involved.
5
+ Two scopes exist:
6
+
7
+ * project (default) — ``<cwd>/.claude/skills/<name>/``, checked in and shared with
8
+ collaborators, and versioned alongside the data-collection code it describes
9
+ * personal — ``~/.claude/skills/<name>/``, available in every project
10
+
11
+ Project scope is the default because the skill describes one project's workflow, and
12
+ because writing inside the working directory is the change a contributor can most easily
13
+ see and undo. This is also deliberately an explicit opt-in subcommand rather than anything
14
+ that runs at install time: contributors who do not use Claude Code never have files
15
+ written for them at all.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import logging
21
+ import shutil
22
+ import sys
23
+ from pathlib import Path
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+ SKILL_NAME = "oopsie-data"
28
+
29
+
30
+ def bundled_skill_dir() -> Path:
31
+ """Path to the skill payload shipped inside the installed package."""
32
+ if sys.version_info >= (3, 9):
33
+ from importlib.resources import files
34
+
35
+ # The payload is plain files in a normal wheel, so this is always a real path.
36
+ return Path(str(files("oopsie_data_tools") / "skill"))
37
+ return Path(__file__).resolve().parent.parent / "skill"
38
+
39
+
40
+ def skill_destination(user: bool = False, root: Path | None = None) -> Path:
41
+ """Where the skill would be installed for the given scope."""
42
+ if root is not None:
43
+ base = root
44
+ elif user:
45
+ base = Path.home() / ".claude" / "skills"
46
+ else:
47
+ base = Path.cwd() / "skills"
48
+ return base / SKILL_NAME
49
+
50
+
51
+ def install_skill(
52
+ user: bool = False,
53
+ force: bool = False,
54
+ root: Path | None = None,
55
+ ) -> int:
56
+ source = bundled_skill_dir()
57
+ if not (source / "SKILL.md").is_file():
58
+ logger.error("The installed package does not contain a skill payload at %s.", source)
59
+ return 1
60
+
61
+ dest = skill_destination(user, root)
62
+ if dest.exists():
63
+ if not force:
64
+ logger.error(
65
+ "%s already exists. Pass --force to overwrite it.\n"
66
+ "Anything you added inside that directory would be lost, so it is not "
67
+ "overwritten by default.",
68
+ dest,
69
+ )
70
+ return 1
71
+ shutil.rmtree(dest)
72
+
73
+ dest.parent.mkdir(parents=True, exist_ok=True)
74
+ shutil.copytree(source, dest)
75
+
76
+ logger.info("Installed the '%s' skill to %s", SKILL_NAME, dest)
77
+ if user:
78
+ logger.info(
79
+ "Claude Code picks it up from ~/.claude/skills/ in every project; start a new "
80
+ "session, then run /%s to invoke it directly.",
81
+ SKILL_NAME,
82
+ )
83
+ else:
84
+ # ./skills/ is a plain project directory, so it is visible and committable, but it is
85
+ # not one of the two locations Claude Code scans. Say so rather than let the skill sit
86
+ # there looking installed.
87
+ logger.info(
88
+ "This is a plain project directory, so Claude Code does not scan it yet. Link it "
89
+ "into the project's skills directory to activate it:\n\n"
90
+ " mkdir -p .claude/skills && ln -s ../../skills/%s .claude/skills/%s\n\n"
91
+ "Or install it for yourself in every project instead: "
92
+ "oopsie-data install-skill --user",
93
+ SKILL_NAME,
94
+ SKILL_NAME,
95
+ )
96
+ return 0
@@ -0,0 +1,91 @@
1
+ """Read the contributor config (lab_id + HuggingFace token) with clear errors.
2
+
3
+ Shared by the episode recorder, the upload pipeline, and the repo-stats script so a
4
+ missing/blank config gives one actionable message instead of a cryptic crash.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+ from pathlib import Path
11
+
12
+ import yaml
13
+
14
+ from oopsie_data_tools.utils.paths import contributor_config_path, repo_config_dir, user_config_dir
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+ _REGISTER_HINT = (
19
+ "Register at https://forms.gle/9arwZHAvRjvbozoT7 to obtain your lab_id and "
20
+ "HuggingFace token, then set them in the contributor config:\n"
21
+ " lab_id: <YOUR_LAB_ID>\n"
22
+ " huggingface_token: <YOUR_HF_TOKEN>\n"
23
+ "Use the exact lab_id you were given (capitalization matters)."
24
+ )
25
+
26
+ # One warning per process; this is read once per recorder and once per upload.
27
+ _warned_about_checkout_config = False
28
+
29
+
30
+ def _warn_if_inside_checkout(path: Path) -> None:
31
+ """Nudge users whose token still lives in the checkout towards the user config dir.
32
+
33
+ ``configs/contributor_config.yaml`` used to be tracked by git, so a filled-in copy sat
34
+ in the working tree of every contributor. It is untracked and ignored now, but existing
35
+ clones still have one, and a token in a working tree is a token that can be committed.
36
+ """
37
+ global _warned_about_checkout_config
38
+ if _warned_about_checkout_config:
39
+ return
40
+ repo_dir = repo_config_dir()
41
+ if repo_dir is None or path.parent.resolve() != repo_dir.resolve():
42
+ return
43
+ _warned_about_checkout_config = True
44
+ logger.warning(
45
+ "Reading credentials from %s, inside the repository working tree.\n"
46
+ "That file is no longer tracked by git, but it still holds your HuggingFace token "
47
+ "where an accidental commit can pick it up, and it is lost if you re-clone.\n"
48
+ "Move it with: oopsie-data init (writes to %s)",
49
+ path,
50
+ user_config_dir(),
51
+ )
52
+
53
+
54
+ def read_contributor_config(config_path: Path | str | None = None) -> tuple[str, str]:
55
+ """Return ``(lab_id, huggingface_token)`` from the contributor config.
56
+
57
+ Args:
58
+ config_path: Optional override for the config location.
59
+
60
+ Returns:
61
+ A ``(lab_id, huggingface_token)`` tuple; the token may be empty.
62
+
63
+ Raises:
64
+ RuntimeError: If the file is missing/unparseable, or ``lab_id`` is unset or
65
+ still the placeholder — always with an actionable message.
66
+ """
67
+ path = Path(config_path) if config_path is not None else contributor_config_path()
68
+ if not path.exists():
69
+ raise RuntimeError(f"Contributor config not found at {path}.\n{_REGISTER_HINT}")
70
+
71
+ try:
72
+ config = yaml.safe_load(path.read_text(encoding="utf-8"))
73
+ except yaml.YAMLError as e:
74
+ raise RuntimeError(f"Could not parse {path}: {e}\n{_REGISTER_HINT}") from e
75
+ if not isinstance(config, dict):
76
+ config = {}
77
+
78
+ # ``config.get("lab_id", "")`` returns None when the key is present but blank
79
+ # (``lab_id:``), which used to crash with ``None.strip()`` — normalize first.
80
+ lab_id = str(config.get("lab_id") or "").strip()
81
+ huggingface_token = str(config.get("huggingface_token") or "").strip()
82
+
83
+ if not lab_id:
84
+ raise RuntimeError(f"lab_id is not set in {path}.\n{_REGISTER_HINT}")
85
+ if lab_id == "your_lab_id":
86
+ raise RuntimeError(
87
+ f"lab_id in {path} is still the placeholder 'your_lab_id'.\n{_REGISTER_HINT}"
88
+ )
89
+
90
+ _warn_if_inside_checkout(path)
91
+ return lab_id, huggingface_token