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,106 @@
1
+ """The contributor config holds a HuggingFace token, so where it lands matters.
2
+
3
+ ``configs/contributor_config.yaml`` used to be tracked by git *and* listed in .gitignore
4
+ (which does nothing for an already-tracked file), while the wizard defaulted to writing
5
+ there. These tests pin down the two halves of the fix.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import subprocess
11
+ from pathlib import Path
12
+
13
+ import pytest
14
+
15
+ from oopsie_data_tools import init_wizard
16
+ from oopsie_data_tools.utils import contributor_config, paths
17
+
18
+ # conftest._test_contributor_config rebinds the module attribute for the whole session, so
19
+ # `contributor_config.read_contributor_config` resolves to a stub at call time. Bind the real
20
+ # function here at import, which happens during collection, before that fixture runs.
21
+ # Batch 5 switches conftest to monkeypatch, after which this can go back to a plain call.
22
+ _real_read_contributor_config = contributor_config.read_contributor_config
23
+
24
+ REPO_ROOT = Path(__file__).resolve().parents[2]
25
+
26
+
27
+ @pytest.fixture
28
+ def isolated(tmp_path, monkeypatch):
29
+ """No env override, a fake home, and an interactive stdin."""
30
+ monkeypatch.delenv(paths.ENV_CONFIG_DIR, raising=False)
31
+ monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "xdg"))
32
+ monkeypatch.setattr(init_wizard.sys.stdin, "isatty", lambda: True)
33
+ return tmp_path
34
+
35
+
36
+ def test_config_file_is_not_tracked_by_git():
37
+ """A tracked file makes its .gitignore entry inert, which is how tokens got committed."""
38
+ tracked = subprocess.run(
39
+ ["git", "ls-files", "--error-unmatch", "configs/contributor_config.yaml"],
40
+ cwd=REPO_ROOT,
41
+ capture_output=True,
42
+ )
43
+ assert tracked.returncode != 0, (
44
+ "configs/contributor_config.yaml is tracked again. While tracked, .gitignore does "
45
+ "not apply to it and a contributor's HuggingFace token shows up in git status."
46
+ )
47
+
48
+
49
+ def test_config_file_is_ignored():
50
+ ignored = subprocess.run(
51
+ ["git", "check-ignore", "configs/contributor_config.yaml"],
52
+ cwd=REPO_ROOT,
53
+ capture_output=True,
54
+ )
55
+ assert ignored.returncode == 0, "configs/contributor_config.yaml must stay gitignored"
56
+
57
+
58
+ def test_wizard_defaults_away_from_the_checkout(isolated, monkeypatch):
59
+ """Pressing Enter must not put a token inside a git working tree."""
60
+ answers = iter([""]) # accept the default
61
+ monkeypatch.setattr("builtins.input", lambda *_: next(answers))
62
+
63
+ target = init_wizard.choose_target_dir()
64
+
65
+ assert target == paths.user_config_dir()
66
+ assert target != paths.repo_config_dir()
67
+
68
+
69
+ def test_wizard_still_allows_the_checkout_when_asked(isolated, monkeypatch):
70
+ answers = iter(["2"]) # the second option is the checkout
71
+ monkeypatch.setattr("builtins.input", lambda *_: next(answers))
72
+
73
+ assert init_wizard.choose_target_dir() == paths.repo_config_dir()
74
+
75
+
76
+ def test_wizard_uses_user_dir_when_not_interactive(isolated, monkeypatch):
77
+ monkeypatch.setattr(init_wizard.sys.stdin, "isatty", lambda: False)
78
+
79
+ assert init_wizard.choose_target_dir() == paths.user_config_dir()
80
+
81
+
82
+ def test_reading_from_the_checkout_warns(tmp_path, monkeypatch, caplog):
83
+ """Existing clones still have a filled-in copy; point them at the user config dir."""
84
+ monkeypatch.setattr(contributor_config, "_warned_about_checkout_config", False)
85
+ repo_configs = tmp_path / "configs"
86
+ repo_configs.mkdir()
87
+ config = repo_configs / "contributor_config.yaml"
88
+ config.write_text("lab_id: MyLab\nhuggingface_token: hf_secret\n", encoding="utf-8")
89
+ monkeypatch.setattr(paths, "_REPO_CONFIG_DIR", repo_configs)
90
+
91
+ lab_id, token = _real_read_contributor_config(config)
92
+
93
+ assert (lab_id, token) == ("MyLab", "hf_secret"), "must still work, only warn"
94
+ assert "inside the repository working tree" in caplog.text
95
+ assert "oopsie-data init" in caplog.text
96
+ assert "hf_secret" not in caplog.text, "never log the token itself"
97
+
98
+
99
+ def test_reading_from_the_user_dir_is_silent(tmp_path, monkeypatch, caplog):
100
+ monkeypatch.setattr(contributor_config, "_warned_about_checkout_config", False)
101
+ config = tmp_path / "contributor_config.yaml"
102
+ config.write_text("lab_id: MyLab\n", encoding="utf-8")
103
+
104
+ _real_read_contributor_config(config)
105
+
106
+ assert caplog.text == ""
@@ -0,0 +1,33 @@
1
+ """Tests for the attr-only diversity heuristics (issue #40)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ import h5py
8
+
9
+ from oopsie_data_tools.test.fixtures.make_valid import write_valid_episode
10
+ from oopsie_data_tools.utils.validation.diversity import check_diversity
11
+
12
+
13
+ def test_below_min_episodes_no_warning(tmp_path: Path) -> None:
14
+ for i in range(3):
15
+ write_valid_episode(tmp_path, stem=f"ep{i}")
16
+ assert check_diversity(str(tmp_path)) == []
17
+
18
+
19
+ def test_identical_instructions_warns(tmp_path: Path) -> None:
20
+ # write_valid_episode uses a single fixed language_instruction for every episode.
21
+ for i in range(6):
22
+ write_valid_episode(tmp_path, stem=f"ep{i}")
23
+ warnings = check_diversity(str(tmp_path))
24
+ assert any("task diversity" in w.lower() for w in warnings)
25
+
26
+
27
+ def test_diverse_instructions_no_task_warning(tmp_path: Path) -> None:
28
+ for i in range(6):
29
+ h5_path = write_valid_episode(tmp_path, stem=f"ep{i}")
30
+ with h5py.File(h5_path, "r+") as f:
31
+ f.attrs["language_instruction"] = f"task number {i}"
32
+ warnings = check_diversity(str(tmp_path))
33
+ assert not any("task diversity" in w.lower() for w in warnings)
@@ -0,0 +1,335 @@
1
+ """Tests for EpisodeRecorder: buffering, validation, and HDF5 output."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import tempfile
6
+ import unittest
7
+ from pathlib import Path
8
+
9
+ import h5py
10
+ import numpy as np
11
+
12
+ from oopsie_data_tools.annotation_tool.episode_recorder import (
13
+ EpisodeRecorder,
14
+ write_mp4,
15
+ )
16
+ from oopsie_data_tools.utils.robot_profile.robot_profile import RobotProfile
17
+
18
+
19
+ def _profile(**overrides) -> RobotProfile:
20
+ defaults = dict(
21
+ policy_name="test_policy",
22
+ robot_name="test_robot",
23
+ is_biarm=False,
24
+ uses_mobile_base=False,
25
+ gripper_name="test_gripper",
26
+ control_freq=10,
27
+ camera_names=["left", "wrist"],
28
+ robot_state_keys=["joint_position", "gripper_position"],
29
+ robot_state_joint_names=["j1", "j2", "j3", "j4", "j5", "j6", "j7"],
30
+ action_space=["joint_velocity", "gripper_position"],
31
+ action_joint_names=["j1", "j2", "j3", "j4", "j5", "j6", "j7"],
32
+ )
33
+ defaults.update(overrides)
34
+ return RobotProfile(**defaults)
35
+
36
+
37
+ def _obs(profile: RobotProfile) -> dict:
38
+ return {
39
+ "robot_state": {
40
+ "joint_position": np.zeros(7, dtype=np.float32),
41
+ "gripper_position": np.zeros(1, dtype=np.float32),
42
+ },
43
+ "image_observation": {
44
+ cam: np.zeros((64, 64, 3), dtype=np.uint8) for cam in profile.camera_names
45
+ },
46
+ }
47
+
48
+
49
+ def _action(profile: RobotProfile) -> dict:
50
+ sizes = {
51
+ "joint_velocity": 7,
52
+ "joint_position": 7,
53
+ "gripper_position": 1,
54
+ "gripper_velocity": 1,
55
+ }
56
+ return {
57
+ k: np.zeros(sizes.get(k, 1), dtype=np.float32) for k in profile.action_space
58
+ }
59
+
60
+
61
+ def _save_data(recorder: EpisodeRecorder, cam_names: list[str]) -> dict:
62
+ return {
63
+ "language_instruction": "pick up the cup",
64
+ "metadata": {
65
+ "episode_id": recorder.save_fname,
66
+ "operator_name": "tester",
67
+ },
68
+ "video_paths": {cam: f"/tmp/{cam}.mp4" for cam in cam_names},
69
+ }
70
+
71
+
72
+ class TestEpisodeRecorderInit(unittest.TestCase):
73
+ def test_session_dir_created(self):
74
+ with tempfile.TemporaryDirectory() as tmp:
75
+ recorder = EpisodeRecorder(robot_profile=_profile(), data_root_dir=tmp, operator_name="test_operator")
76
+ self.assertTrue(recorder.session_dir.is_dir())
77
+
78
+ def test_resume_session_name(self):
79
+ with tempfile.TemporaryDirectory() as tmp:
80
+ recorder = EpisodeRecorder(
81
+ robot_profile=_profile(),
82
+ data_root_dir=tmp,
83
+ resume_session_name="my_session",
84
+ operator_name="test_operator",
85
+ )
86
+ self.assertEqual(recorder.session_name, "my_session")
87
+
88
+ def test_initial_num_steps_zero(self):
89
+ with tempfile.TemporaryDirectory() as tmp:
90
+ recorder = EpisodeRecorder(robot_profile=_profile(), data_root_dir=tmp, operator_name="test_operator")
91
+ self.assertEqual(recorder.num_steps, 0)
92
+
93
+
94
+ class TestEpisodeRecorderRecordStep(unittest.TestCase):
95
+ def setUp(self):
96
+ self._tmp = tempfile.TemporaryDirectory()
97
+ self.profile = _profile()
98
+ self.recorder = EpisodeRecorder(
99
+ robot_profile=self.profile, data_root_dir=self._tmp.name, operator_name="test_operator"
100
+ )
101
+ self.recorder.reset_episode_recorder()
102
+
103
+ def tearDown(self):
104
+ self._tmp.cleanup()
105
+
106
+ def test_record_step_increments_count(self):
107
+ self.recorder.record_step(_obs(self.profile), _action(self.profile))
108
+ self.assertEqual(self.recorder.num_steps, 1)
109
+
110
+ def test_multiple_steps_accumulate(self):
111
+ for _ in range(5):
112
+ self.recorder.record_step(_obs(self.profile), _action(self.profile))
113
+ self.assertEqual(self.recorder.num_steps, 5)
114
+
115
+ def test_reset_clears_steps(self):
116
+ self.recorder.record_step(_obs(self.profile), _action(self.profile))
117
+ self.recorder.reset_episode_recorder()
118
+ self.assertEqual(self.recorder.num_steps, 0)
119
+
120
+ def test_rejects_non_dict_observation(self):
121
+ with self.assertRaises(ValueError):
122
+ self.recorder.record_step("not a dict", _action(self.profile))
123
+
124
+ def test_rejects_missing_robot_state_key(self):
125
+ obs = _obs(self.profile)
126
+ del obs["robot_state"]
127
+ with self.assertRaises(ValueError):
128
+ self.recorder.record_step(obs, _action(self.profile))
129
+
130
+ def test_rejects_missing_image_observation_key(self):
131
+ obs = _obs(self.profile)
132
+ del obs["image_observation"]
133
+ with self.assertRaises(ValueError):
134
+ self.recorder.record_step(obs, _action(self.profile))
135
+
136
+ def test_rejects_missing_camera(self):
137
+ obs = _obs(self.profile)
138
+ del obs["image_observation"]["left"]
139
+ with self.assertRaises(ValueError):
140
+ self.recorder.record_step(obs, _action(self.profile))
141
+
142
+ def test_rejects_missing_robot_state_component(self):
143
+ obs = _obs(self.profile)
144
+ del obs["robot_state"]["joint_position"]
145
+ with self.assertRaises(ValueError):
146
+ self.recorder.record_step(obs, _action(self.profile))
147
+
148
+ def test_rejects_empty_action(self):
149
+ with self.assertRaises(ValueError):
150
+ self.recorder.record_step(_obs(self.profile), {})
151
+
152
+ def test_rejects_unrecognized_action_key(self):
153
+ action = {**_action(self.profile), "bad_key": np.zeros(1)}
154
+ with self.assertRaises(ValueError):
155
+ self.recorder.record_step(_obs(self.profile), action)
156
+
157
+ def test_rejects_mismatched_action_keys(self):
158
+ with self.assertRaises(ValueError):
159
+ self.recorder.record_step(
160
+ _obs(self.profile), {"joint_velocity": np.zeros(7)}
161
+ )
162
+
163
+ def test_rejects_none_action_values(self):
164
+ action = {k: None for k in self.profile.action_space}
165
+ with self.assertRaises(ValueError):
166
+ self.recorder.record_step(_obs(self.profile), action)
167
+
168
+ def test_rejects_non_dict_action(self):
169
+ with self.assertRaises(ValueError):
170
+ self.recorder.record_step(_obs(self.profile), np.zeros(8))
171
+
172
+
173
+ class TestEpisodeRecorderSave(unittest.TestCase):
174
+ def setUp(self):
175
+ self._tmp = tempfile.TemporaryDirectory()
176
+ self.profile = _profile()
177
+ self.recorder = EpisodeRecorder(
178
+ robot_profile=self.profile, data_root_dir=self._tmp.name, operator_name="test_operator"
179
+ )
180
+ self.recorder.reset_episode_recorder()
181
+
182
+ def tearDown(self):
183
+ self._tmp.cleanup()
184
+
185
+ def _record_n(self, n: int = 3) -> None:
186
+ for _ in range(n):
187
+ self.recorder.record_step(_obs(self.profile), _action(self.profile))
188
+
189
+ def test_save_returns_h5_path(self):
190
+ self._record_n()
191
+ h5_path = self.recorder.save(
192
+ _save_data(self.recorder, self.profile.camera_names)
193
+ )
194
+ self.assertIsInstance(h5_path, Path)
195
+ self.assertEqual(h5_path.suffix, ".h5")
196
+
197
+ def test_save_file_exists(self):
198
+ self._record_n()
199
+ h5_path = self.recorder.save(
200
+ _save_data(self.recorder, self.profile.camera_names)
201
+ )
202
+ self.assertTrue(h5_path.exists())
203
+
204
+ def test_save_h5_attrs(self):
205
+ self._record_n()
206
+ h5_path = self.recorder.save(
207
+ _save_data(self.recorder, self.profile.camera_names)
208
+ )
209
+ with h5py.File(h5_path, "r") as f:
210
+ self.assertEqual(f.attrs["language_instruction"], "pick up the cup")
211
+ self.assertEqual(f.attrs["schema"], "oopsiedata_format_v1")
212
+
213
+ def test_save_h5_observations_and_robot_states(self):
214
+ self._record_n()
215
+ h5_path = self.recorder.save(
216
+ _save_data(self.recorder, self.profile.camera_names)
217
+ )
218
+ with h5py.File(h5_path, "r") as f:
219
+ self.assertIn("observations", f)
220
+ self.assertIn("robot_states", f["observations"])
221
+ self.assertIn("joint_position", f["observations/robot_states"])
222
+
223
+ def test_save_h5_robot_state_timestep_count(self):
224
+ self._record_n(4)
225
+ h5_path = self.recorder.save(
226
+ _save_data(self.recorder, self.profile.camera_names)
227
+ )
228
+ with h5py.File(h5_path, "r") as f:
229
+ self.assertEqual(f["observations/robot_states/joint_position"].shape[0], 4)
230
+
231
+ def test_save_h5_actions_group(self):
232
+ self._record_n()
233
+ h5_path = self.recorder.save(
234
+ _save_data(self.recorder, self.profile.camera_names)
235
+ )
236
+ with h5py.File(h5_path, "r") as f:
237
+ self.assertIn("actions", f)
238
+
239
+ def test_save_raises_without_steps(self):
240
+ with self.assertRaises(ValueError):
241
+ self.recorder.save(_save_data(self.recorder, self.profile.camera_names))
242
+
243
+ def test_save_in_session_dir(self):
244
+ self._record_n()
245
+ h5_path = self.recorder.save(
246
+ _save_data(self.recorder, self.profile.camera_names)
247
+ )
248
+ self.assertEqual(h5_path.parent, self.recorder.session_dir)
249
+
250
+
251
+ class TestWriteMp4Validation(unittest.TestCase):
252
+ def test_wrong_ndim_raises(self):
253
+ frames = np.zeros((64, 64, 3), dtype=np.uint8) # missing time dimension
254
+ with tempfile.TemporaryDirectory() as tmp:
255
+ with self.assertRaises(ValueError, msg="3-D array should raise"):
256
+ write_mp4(Path(tmp) / "out.mp4", frames, fps=10.0)
257
+
258
+ def test_wrong_channel_count_raises(self):
259
+ frames = np.zeros((4, 64, 64, 4), dtype=np.uint8) # RGBA instead of RGB
260
+ with tempfile.TemporaryDirectory() as tmp:
261
+ with self.assertRaises(ValueError, msg="RGBA frames should raise"):
262
+ write_mp4(Path(tmp) / "out.mp4", frames, fps=10.0)
263
+
264
+ def test_zero_frames_raises(self):
265
+ frames = np.zeros((0, 64, 64, 3), dtype=np.uint8)
266
+ with tempfile.TemporaryDirectory() as tmp:
267
+ with self.assertRaises(ValueError, msg="zero-frame array should raise"):
268
+ write_mp4(Path(tmp) / "out.mp4", frames, fps=10.0)
269
+
270
+ def test_2d_array_raises(self):
271
+ frames = np.zeros((64, 64), dtype=np.uint8)
272
+ with tempfile.TemporaryDirectory() as tmp:
273
+ with self.assertRaises(ValueError):
274
+ write_mp4(Path(tmp) / "out.mp4", frames, fps=10.0)
275
+
276
+
277
+ class TestRecordStepActionBreaking(unittest.TestCase):
278
+ def setUp(self):
279
+ self._tmp = tempfile.TemporaryDirectory()
280
+ self.profile = _profile()
281
+ self.recorder = EpisodeRecorder(
282
+ robot_profile=self.profile, data_root_dir=self._tmp.name, operator_name="test_operator"
283
+ )
284
+ self.recorder.reset_episode_recorder()
285
+
286
+ def tearDown(self):
287
+ self._tmp.cleanup()
288
+
289
+ def test_rejects_partial_none_action_value(self):
290
+ """One None among otherwise valid keys should still raise."""
291
+ action = _action(self.profile)
292
+ first_key = next(iter(action))
293
+ action[first_key] = None
294
+ with self.assertRaises(ValueError):
295
+ self.recorder.record_step(_obs(self.profile), action)
296
+
297
+ def test_rejects_action_superset_of_profile(self):
298
+ """Extra valid action key beyond profile's action_space should raise."""
299
+ action = _action(self.profile)
300
+ action["cartesian_position"] = np.zeros(7, dtype=np.float32)
301
+ with self.assertRaises(ValueError):
302
+ self.recorder.record_step(_obs(self.profile), action)
303
+
304
+ def test_rejects_action_subset_of_profile(self):
305
+ """Providing only one of the required profile action keys should raise."""
306
+ action = {"joint_velocity": np.zeros(7, dtype=np.float32)}
307
+ with self.assertRaises(ValueError):
308
+ self.recorder.record_step(_obs(self.profile), action)
309
+
310
+ def test_rejects_action_with_wrong_but_valid_global_keys(self):
311
+ """Globally-valid keys that don't match the profile's action_space should raise."""
312
+ action = {
313
+ "cartesian_position": np.zeros(7, dtype=np.float32),
314
+ "gripper_position": np.zeros(1, dtype=np.float32),
315
+ }
316
+ with self.assertRaises(ValueError):
317
+ self.recorder.record_step(_obs(self.profile), action)
318
+
319
+ def test_rejects_extra_camera_not_in_profile(self):
320
+ """image_observation with extra camera beyond profile is fine, but missing one raises."""
321
+ obs = _obs(self.profile)
322
+ del obs["image_observation"]["wrist"]
323
+ with self.assertRaises(ValueError):
324
+ self.recorder.record_step(obs, _action(self.profile))
325
+
326
+ def test_rejects_extra_robot_state_key_missing_required(self):
327
+ """robot_state with a different key than required should raise."""
328
+ obs = _obs(self.profile)
329
+ obs["robot_state"] = {"unexpected_key": np.zeros(7)}
330
+ with self.assertRaises(ValueError):
331
+ self.recorder.record_step(obs, _action(self.profile))
332
+
333
+
334
+ if __name__ == "__main__":
335
+ unittest.main()
@@ -0,0 +1,173 @@
1
+ """How an episode records where its videos live.
2
+
3
+ Paths are stored relative to the episode file so a session directory can be moved or
4
+ uploaded from anywhere. That only holds if both sides of the relpath use the same base:
5
+ measuring a resolved video against an unresolved directory used to store
6
+ ``../../../private/tmp/<session>/x.mp4`` for a video sitting right beside the episode,
7
+ because /tmp is a symlink to /private/tmp on macOS. Such a path still resolves in place,
8
+ so nothing failed — until the directory moved.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import shutil
14
+ from pathlib import Path
15
+
16
+ import h5py
17
+ import numpy as np
18
+ import pytest
19
+
20
+ from oopsie_data_tools.annotation_tool.episode_recorder import EpisodeRecorder
21
+ from oopsie_data_tools.utils.robot_profile.robot_profile import RobotProfile
22
+ from oopsie_data_tools.utils.validation.validation_utils import validate_h5_file
23
+
24
+ CAMERAS = ["left", "wrist"]
25
+
26
+
27
+ def _profile() -> RobotProfile:
28
+ return RobotProfile(
29
+ policy_name="p",
30
+ robot_name="r",
31
+ is_biarm=False,
32
+ uses_mobile_base=False,
33
+ gripper_name="g",
34
+ control_freq=10,
35
+ camera_names=CAMERAS,
36
+ robot_state_keys=["joint_position", "gripper_position"],
37
+ robot_state_joint_names=["j1", "j2"],
38
+ action_space=["joint_position", "gripper_position"],
39
+ action_joint_names=["j1", "j2"],
40
+ )
41
+
42
+
43
+ def _record_episode(data_root: Path) -> Path:
44
+ """Record a short episode into ``data_root``; returns the path to its .h5 file."""
45
+ profile = _profile()
46
+ recorder = EpisodeRecorder(
47
+ robot_profile=profile, data_root_dir=data_root, operator_name="op"
48
+ )
49
+ for _ in range(30):
50
+ recorder.record_step(
51
+ observation={
52
+ "robot_state": {
53
+ "joint_position": np.zeros(2, dtype=np.float32),
54
+ "gripper_position": np.zeros(1, dtype=np.float32),
55
+ },
56
+ "image_observation": {
57
+ cam: np.zeros((240, 320, 3), dtype=np.uint8) for cam in CAMERAS
58
+ },
59
+ },
60
+ action={
61
+ "joint_position": np.zeros(2, dtype=np.float32),
62
+ "gripper_position": np.zeros(1, dtype=np.float32),
63
+ },
64
+ )
65
+ recorder.finish_rollout(instruction="pick up the block", success=1.0)
66
+ return next(Path(data_root).rglob("*.h5"))
67
+
68
+
69
+ @pytest.fixture
70
+ def episode(tmp_path) -> Path:
71
+ return _record_episode(tmp_path)
72
+
73
+
74
+ @pytest.fixture
75
+ def symlinked_episode(tmp_path) -> Path:
76
+ """An episode recorded through a symlinked data root.
77
+
78
+ This is what makes the surrounding tests mean anything off macOS. There, /tmp is a
79
+ symlink to /private/tmp, so pytest's tmp_path exposed the resolved/unresolved mismatch
80
+ for free; on Linux tmp_path is a real directory and every assertion here passes even
81
+ against the unfixed code. Putting the symlink in explicitly makes the regression
82
+ reproducible on any platform — which matters, because CI only runs Linux.
83
+
84
+ The link has to be *shallower than its target* to reproduce the fault. With both at
85
+ the same depth the stray ".." segments still resolve back to the right place and the
86
+ stored path comes out clean anyway; only when they overshoot the link's own parent —
87
+ exactly the shape of /tmp -> /private/tmp — does the broken path survive into the file.
88
+ """
89
+ real = tmp_path / "deep" / "deeper" / "real_root"
90
+ real.mkdir(parents=True)
91
+ link = tmp_path / "linked_root"
92
+ try:
93
+ link.symlink_to(real, target_is_directory=True)
94
+ except (OSError, NotImplementedError) as e: # Windows without developer mode
95
+ pytest.skip(f"cannot create symlinks on this platform: {e}")
96
+ return _record_episode(link)
97
+
98
+
99
+ def _stored_paths(h5_path: Path) -> dict[str, str]:
100
+ with h5py.File(h5_path, "r") as f:
101
+ group = f["observations/video_paths"]
102
+ return {cam: group[cam][()].decode("utf-8") for cam in group}
103
+
104
+
105
+ def test_stored_paths_are_relative_and_point_at_the_videos(episode):
106
+ """Deliberately loose: any relative layout is fine as long as it resolves.
107
+
108
+ Pinning the exact string would freeze today's flat "<stem>_<cam>.mp4" layout and break
109
+ if videos ever move into a subdirectory, which is not what this is protecting.
110
+ """
111
+ stored = _stored_paths(episode)
112
+
113
+ assert set(stored) == set(CAMERAS)
114
+ for cam, rel in stored.items():
115
+ assert not Path(rel).is_absolute(), f"{cam} stored an absolute path: {rel!r}"
116
+ assert (episode.parent / rel).is_file(), f"{cam} path does not resolve: {rel!r}"
117
+
118
+
119
+ def test_stored_paths_stay_inside_the_data_root(episode, tmp_path):
120
+ """The actual regression: a path must not leave the dataset and climb back in.
121
+
122
+ A video beside its episode was stored as "../../../private/tmp/<session>/x.mp4". That
123
+ resolves correctly in place, so nothing failed — it is only wrong because it encodes
124
+ the absolute location of the machine that recorded it. Subdirectories and sibling
125
+ directories inside the data root remain perfectly acceptable.
126
+ """
127
+ root = tmp_path.resolve()
128
+
129
+ for cam, rel in _stored_paths(episode).items():
130
+ resolved = (episode.parent / rel).resolve()
131
+ assert root in resolved.parents, (
132
+ f"{cam} stored {rel!r}, which escapes the data root {root} before returning. "
133
+ "Both sides of the relpath must use the same resolved base."
134
+ )
135
+
136
+
137
+ def test_exactly_one_video_per_camera_is_written(episode):
138
+ """Two writers used to run over the same frames; only one should now."""
139
+ mp4s = sorted(p.name for p in episode.parent.glob("*.mp4"))
140
+
141
+ assert len(mp4s) == len(CAMERAS), f"expected one mp4 per camera, got {mp4s}"
142
+
143
+
144
+ def test_the_session_directory_can_be_moved(episode, tmp_path):
145
+ """The reason paths are relative in the first place."""
146
+ destination = tmp_path / "relocated"
147
+ shutil.move(str(episode.parent), str(destination))
148
+ moved = next(destination.glob("*.h5"))
149
+
150
+ assert validate_h5_file(str(moved), strict_annotation_check=True)
151
+
152
+
153
+ def test_a_symlinked_data_root_still_stores_portable_paths(symlinked_episode, tmp_path):
154
+ """The regression itself, reproducible on any platform.
155
+
156
+ Recording through a symlinked directory used to store a path that walked up to the
157
+ filesystem root and back down the resolved side of the link.
158
+ """
159
+ for cam, rel in _stored_paths(symlinked_episode).items():
160
+ assert not Path(rel).is_absolute(), f"{cam}: {rel!r}"
161
+ assert not rel.startswith("../"), (
162
+ f"{cam} stored {rel!r}: the relpath was measured against an unresolved base, "
163
+ "so it escapes the dataset and walks back in through the symlink target."
164
+ )
165
+ assert (symlinked_episode.parent / rel).is_file()
166
+
167
+
168
+ def test_a_symlinked_session_can_also_be_moved(symlinked_episode, tmp_path):
169
+ destination = tmp_path / "relocated_from_symlink"
170
+ shutil.move(str(symlinked_episode.parent), str(destination))
171
+ moved = next(destination.glob("*.h5"))
172
+
173
+ assert validate_h5_file(str(moved), strict_annotation_check=True)