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,190 @@
1
+ """The v2 annotation schema core: the outcome mapping, v1 reads, and UI/Python drift.
2
+
3
+ With the questionnaire YAML gone the annotation form is hardcoded markup, so nothing in the
4
+ build would notice if the slugs in ``annotator.html`` and the ones in ``annotation_schema``
5
+ drifted apart. :func:`test_ui_vocabulary_matches_python` is what notices.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import re
12
+
13
+ import h5py
14
+ import pytest
15
+
16
+ from oopsie_data_tools.annotation_tool import ANNOTATION_TOOL_DIR
17
+ from oopsie_data_tools.annotation_tool.annotation_schema import (
18
+ OUTCOME_SUCCESS,
19
+ OUTCOMES,
20
+ SEVERITIES,
21
+ SIDE_EFFECT_CATEGORIES,
22
+ SUCCESS_THRESHOLD,
23
+ outcome_to_success,
24
+ read_annotation_attrs,
25
+ success_to_outcome,
26
+ write_annotation_attrs,
27
+ )
28
+ from oopsie_data_tools.utils.validation.annotation_completeness import (
29
+ OUTCOME_EXPECTED_FIELDS,
30
+ )
31
+
32
+ _ANNOTATOR_HTML = ANNOTATION_TOOL_DIR / "ui" / "annotator.html"
33
+
34
+
35
+ # ── The outcome <-> success mapping ───────────────────────────────────────────
36
+
37
+
38
+ def test_every_outcome_has_a_success_value():
39
+ assert set(OUTCOME_SUCCESS) == set(OUTCOMES)
40
+ assert all(isinstance(v, float) for v in OUTCOME_SUCCESS.values())
41
+
42
+
43
+ def test_all_three_success_outcomes_score_as_success():
44
+ """Downstream consumers that only read the float must see every success as one."""
45
+ for outcome in ("success", "success_suboptimal", "success_side_effect"):
46
+ assert outcome_to_success(outcome) >= SUCCESS_THRESHOLD
47
+ assert outcome_to_success("failure") < SUCCESS_THRESHOLD
48
+
49
+
50
+ def test_an_unrecognized_outcome_has_no_success_value():
51
+ """None, not 0.0 — guessing "failure" would silently mislabel the episode."""
52
+ assert outcome_to_success("sort_of_worked") is None
53
+ assert outcome_to_success("") is None
54
+ assert success_to_outcome("not a number") is None
55
+
56
+
57
+ def test_success_to_outcome_is_coarse():
58
+ """The float cannot carry the qualified successes, and must not pretend otherwise."""
59
+ assert success_to_outcome(1.0) == "success"
60
+ assert success_to_outcome(0.0) == "failure"
61
+ assert success_to_outcome(0.9) == "success"
62
+
63
+
64
+ def test_an_unrecognized_outcome_leaves_success_unwritten(tmp_path):
65
+ path = tmp_path / "ep.h5"
66
+ with h5py.File(path, "w") as f:
67
+ group = f.require_group("episode_annotations/a")
68
+ write_annotation_attrs(group, {"outcome": "sort_of_worked"})
69
+ assert "success" not in group.attrs
70
+
71
+
72
+ # ── Reading v1 ────────────────────────────────────────────────────────────────
73
+
74
+
75
+ def _v1_attrs(**taxonomy) -> dict:
76
+ success = taxonomy.pop("success", 0.0)
77
+ return {
78
+ "schema": "oopsie_failure_taxonomy_v1",
79
+ "success": success,
80
+ "failure_description": taxonomy.pop("failure_description", ""),
81
+ "taxonomy": json.dumps(taxonomy),
82
+ }
83
+
84
+
85
+ @pytest.mark.parametrize(
86
+ "success,success_category,expected",
87
+ [
88
+ (1.0, None, "success"),
89
+ (1.0, "Clean success", "success"),
90
+ (1.0, "Suboptimal execution", "success_suboptimal"),
91
+ (1.0, "Success with side-effects", "success_side_effect"),
92
+ (0.0, None, "failure"),
93
+ ],
94
+ )
95
+ def test_v1_outcome_is_reconstructed(success, success_category, expected):
96
+ attrs = _v1_attrs(success=success)
97
+ if success_category:
98
+ attrs["taxonomy"] = json.dumps({"success_category": success_category})
99
+
100
+ assert read_annotation_attrs(attrs)["outcome"] == expected
101
+
102
+
103
+ def test_v1_prose_values_read_back_as_slugs():
104
+ attrs = _v1_attrs(
105
+ success=0.0,
106
+ failure_description="dropped it",
107
+ failure_category=["Grasp failure (at contact)", "Collision failure"],
108
+ severity="Low severity - no damage, can be reset and reattempted",
109
+ )
110
+ out = read_annotation_attrs(attrs)
111
+
112
+ assert out["episode_description"] == "dropped it"
113
+ assert out["side_effect_category"] == ["grasp", "collision"]
114
+ assert out["severity"] == "low"
115
+
116
+
117
+ def test_an_unknown_category_value_is_preserved():
118
+ """Stale labels stay visible to the annotator instead of being guessed at."""
119
+ attrs = _v1_attrs(success=0.0, failure_category=["grasp_failure"])
120
+
121
+ assert read_annotation_attrs(attrs)["side_effect_category"] == ["grasp_failure"]
122
+
123
+
124
+ def test_absent_keys_stay_absent():
125
+ """Callers distinguish "not annotated" from "annotated and left empty"."""
126
+ out = read_annotation_attrs({})
127
+
128
+ assert "outcome" not in out
129
+ assert "episode_description" not in out
130
+
131
+
132
+ def test_a_malformed_taxonomy_reads_as_empty_rather_than_raising():
133
+ out = read_annotation_attrs({"success": 0.0, "taxonomy": "{not json"})
134
+
135
+ assert out["outcome"] == "failure"
136
+ assert "side_effect_category" not in out
137
+
138
+
139
+ def test_the_legacy_v1_fixture_reads_as_v2(legacy_v1_episode):
140
+ """The on-disk v1 fixture, opened through the normal read path."""
141
+ with h5py.File(legacy_v1_episode, "r") as f:
142
+ out = read_annotation_attrs(f["episode_annotations"]["test_annotator"].attrs)
143
+
144
+ assert out["outcome"] == "failure"
145
+ assert out["side_effect_category"] == ["grasp", "collision"]
146
+ assert out["severity"] == "medium"
147
+ assert out["episode_description"].startswith("Gripper closed early")
148
+
149
+
150
+ # ── Drift guards ──────────────────────────────────────────────────────────────
151
+
152
+
153
+ def _html_values(field: str) -> list:
154
+ """Every ``value="…"`` on inputs named *field* in the annotation form."""
155
+ html = _ANNOTATOR_HTML.read_text(encoding="utf-8")
156
+ pattern = re.compile(
157
+ r'<input[^>]*name="' + re.escape(field) + r'"[^>]*value="([^"]*)"'
158
+ r"|"
159
+ r'<input[^>]*value="([^"]*)"[^>]*name="' + re.escape(field) + r'"'
160
+ )
161
+ return [a or b for a, b in pattern.findall(html)]
162
+
163
+
164
+ @pytest.mark.parametrize(
165
+ "field,expected",
166
+ [
167
+ ("outcome", OUTCOMES),
168
+ ("side_effect_category", SIDE_EFFECT_CATEGORIES),
169
+ ("severity", SEVERITIES),
170
+ ],
171
+ )
172
+ def test_ui_vocabulary_matches_python(field, expected):
173
+ """The hardcoded form and the schema must offer exactly the same slugs.
174
+
175
+ Nothing else connects them since the questionnaire YAML was removed, so a slug typo in
176
+ the markup would otherwise only show up as data that quietly fails to round-trip.
177
+ """
178
+ assert _html_values(field) == list(expected)
179
+
180
+
181
+ def test_expected_fields_match_the_outcomes():
182
+ assert set(OUTCOME_EXPECTED_FIELDS) == set(OUTCOMES)
183
+
184
+
185
+ def test_the_form_marks_only_outcome_as_required():
186
+ """Only the outcome is required; a required marker anywhere else contradicts that."""
187
+ html = _ANNOTATOR_HTML.read_text(encoding="utf-8")
188
+ required_groups = re.findall(r'data-question-id="([^"]+)"[^>]*data-required="1"', html)
189
+
190
+ assert required_groups == ["outcome"]
@@ -0,0 +1,240 @@
1
+ """Tests for the annotator Flask server's HDF5 sample endpoint.
2
+
3
+ These use Flask's test client (no real socket / browser). Focus: videos are read
4
+ from the correct ``observations/video_paths`` group — a regression guard for the
5
+ bug where the server read a non-existent ``image_observations`` group and only
6
+ rendered videos by accident via the ``<stem>_<cam>.mp4`` filename fallback.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from pathlib import Path
12
+
13
+ import h5py
14
+ import pytest
15
+
16
+ from oopsie_data_tools.annotation_tool.annotation_schema import write_annotation_attrs
17
+ from oopsie_data_tools.annotation_tool.annotator_server import app, configure_runtime
18
+ from oopsie_data_tools.test.fixtures.make_valid import (
19
+ _write_base_h5,
20
+ _write_video,
21
+ write_valid_episode,
22
+ )
23
+
24
+
25
+ @pytest.fixture
26
+ def client(tmp_path: Path):
27
+ configure_runtime(
28
+ samples_dir=tmp_path, annotator_name="test_annotator", browse_only=True
29
+ )
30
+ app.config.update(TESTING=True)
31
+ return app.test_client()
32
+
33
+
34
+ def _get_sample(client, rel_path: str) -> dict:
35
+ resp = client.get(f"/api/h5/sample?path={rel_path}")
36
+ assert resp.status_code == 200, resp.data
37
+ return resp.get_json()
38
+
39
+
40
+ def test_video_urls_from_video_paths_group_nested_layout(
41
+ client, tmp_path: Path
42
+ ) -> None:
43
+ """Video sits in a subdir with no ``<stem>_<cam>.mp4`` sibling, so only a
44
+ correct ``observations/video_paths`` read (not the glob fallback) finds it."""
45
+ nested = tmp_path / "nested"
46
+ (nested / "cam_videos").mkdir(parents=True)
47
+ _write_video(nested / "cam_videos" / "front.mp4", color=(10, 20, 30))
48
+ with h5py.File(nested / "ep.h5", "w") as f:
49
+ _write_base_h5(
50
+ f,
51
+ episode_id="ep",
52
+ language_instruction="do the thing",
53
+ camera_video_paths={"front": "cam_videos/front.mp4"},
54
+ )
55
+
56
+ data = _get_sample(client, "nested/ep.h5")
57
+
58
+ assert list(data["video_urls"].keys()) == ["front"]
59
+ assert data["video_urls"]["front"].endswith("cam_videos/front.mp4")
60
+
61
+
62
+ def test_video_urls_resolved_for_flat_layout(client, tmp_path: Path) -> None:
63
+ """A standard flat episode still resolves its camera video."""
64
+ write_valid_episode(tmp_path, stem="flat")
65
+
66
+ data = _get_sample(client, "flat.h5")
67
+
68
+ assert "front" in data["video_urls"]
69
+
70
+
71
+ def test_sample_returns_episode_fields(client, tmp_path: Path) -> None:
72
+ """/api/h5/sample summarizes all logged fields for the visualizer (#30)."""
73
+ write_valid_episode(tmp_path, stem="ep")
74
+
75
+ fields = _get_sample(client, "ep.h5")["episode_fields"]
76
+
77
+ assert fields["attributes"]["lab_id"] == "test_lab"
78
+ assert fields["robot_profile"]["policy_name"] == "test_policy"
79
+ assert fields["robot_states"]["joint_position"]["shape"] == [20, 7]
80
+ assert fields["robot_states"]["joint_position"]["empty"] is False
81
+ # Unused action keys are stored as h5py.Empty and flagged empty.
82
+ assert fields["actions"]["joint_velocity"]["empty"] is False
83
+ assert fields["actions"]["cartesian_position"]["empty"] is True
84
+ assert fields["trajectory_length"] == 20
85
+
86
+
87
+ def test_set_instruction_persists(client, tmp_path: Path) -> None:
88
+ """POST /api/h5/instruction overwrites the language_instruction attr (#31)."""
89
+ write_valid_episode(tmp_path, stem="ep")
90
+
91
+ resp = client.post(
92
+ "/api/h5/instruction?path=ep.h5",
93
+ json={"instruction": "pour the water carefully"},
94
+ )
95
+ assert resp.status_code == 200, resp.data
96
+
97
+ data = _get_sample(client, "ep.h5")
98
+ assert data["metadata"]["language_instruction"] == "pour the water carefully"
99
+ assert data["episode_fields"]["attributes"]["language_instruction"] == "pour the water carefully"
100
+
101
+
102
+ def test_set_instruction_rejects_empty(client, tmp_path: Path) -> None:
103
+ write_valid_episode(tmp_path, stem="ep")
104
+
105
+ resp = client.post("/api/h5/instruction?path=ep.h5", json={"instruction": " "})
106
+
107
+ assert resp.status_code == 400
108
+
109
+
110
+ def test_save_outcome_roundtrip(client, tmp_path: Path) -> None:
111
+ """A qualified success stores its outcome in the taxonomy and reads back (#29)."""
112
+ write_valid_episode(tmp_path, stem="ep")
113
+
114
+ resp = client.post(
115
+ "/api/h5/annotations?path=ep.h5",
116
+ json={
117
+ "outcome": "success_side_effect",
118
+ "severity": "low",
119
+ "side_effect_category": ["collision"],
120
+ "episode_description": "clipped a nearby cup",
121
+ "additional_notes": "no damage",
122
+ },
123
+ )
124
+ assert resp.status_code == 200, resp.data
125
+
126
+ data = _get_sample(client, "ep.h5")
127
+ # All three success_* outcomes still store success=1.0, so existing converters and
128
+ # uploaders keep reading the file unchanged.
129
+ assert data["metadata"]["success"] == 1.0
130
+ ann = data["existing_annotation"]
131
+ assert ann["outcome"] == "success_side_effect"
132
+ assert ann["side_effect_category"] == ["collision"]
133
+ assert ann["severity"] == "low"
134
+
135
+
136
+ def test_save_rejects_unknown_vocabulary(client, tmp_path: Path) -> None:
137
+ """Slugs are opaque, so a typo must be caught before it reaches disk."""
138
+ write_valid_episode(tmp_path, stem="ep")
139
+
140
+ resp = client.post(
141
+ "/api/h5/annotations?path=ep.h5",
142
+ json={"outcome": "success_side_effect", "side_effect_category": ["grasp_failure"]},
143
+ )
144
+ assert resp.status_code == 400
145
+ assert "grasp_failure" in resp.get_json()["error"]
146
+
147
+ resp = client.post("/api/h5/annotations?path=ep.h5", json={"outcome": "sort_of"})
148
+ assert resp.status_code == 400
149
+
150
+
151
+ def test_save_accepts_a_partial_taxonomy(client, tmp_path: Path) -> None:
152
+ """Only the outcome is required — a failure with just a severity saves cleanly."""
153
+ write_valid_episode(tmp_path, stem="ep")
154
+
155
+ resp = client.post(
156
+ "/api/h5/annotations?path=ep.h5",
157
+ json={"outcome": "failure", "severity": "low"},
158
+ )
159
+ assert resp.status_code == 200, resp.data
160
+
161
+ ann = _get_sample(client, "ep.h5")["existing_annotation"]
162
+ assert ann["outcome"] == "failure"
163
+ assert ann["episode_description"] == ""
164
+
165
+
166
+ def test_recent_annotations_returns_distinct(client, tmp_path: Path) -> None:
167
+ """/api/annotations/recent surfaces the annotator's distinct prior failures (#27).
168
+
169
+ The endpoint feeds the taxonomy autofill picker, so clean successes are filtered
170
+ out and identical labels are deduplicated.
171
+ """
172
+ write_valid_episode(tmp_path, stem="a") # clean success by test_annotator — not offered
173
+ for stem, description in (("b", "dropped the object"), ("c", "dropped the object"), ("d", "missed the grasp")):
174
+ write_valid_episode(tmp_path, stem=stem)
175
+ resp = client.post(
176
+ f"/api/h5/annotations?path={stem}.h5",
177
+ json={
178
+ "outcome": "failure",
179
+ "side_effect_category": ["other"],
180
+ "episode_description": description,
181
+ "severity": "low",
182
+ },
183
+ )
184
+ assert resp.status_code == 200, resp.data
185
+
186
+ items = client.get("/api/annotations/recent?limit=10").get_json()
187
+
188
+ assert {i["outcome"] for i in items} == {"failure"}
189
+ # b and c share a label; only one of them is offered.
190
+ assert sorted(i["episode_description"] for i in items) == [
191
+ "dropped the object",
192
+ "missed the grasp",
193
+ ]
194
+
195
+
196
+ def test_recent_annotations_includes_qualified_successes(client, tmp_path: Path) -> None:
197
+ """A suboptimal-execution description is as worth copying as a failure's."""
198
+ write_valid_episode(tmp_path, stem="a") # clean success — still filtered out
199
+ write_valid_episode(tmp_path, stem="b")
200
+ resp = client.post(
201
+ "/api/h5/annotations?path=b.h5",
202
+ json={"outcome": "success_suboptimal", "episode_description": "took a long detour"},
203
+ )
204
+ assert resp.status_code == 200, resp.data
205
+
206
+ items = client.get("/api/annotations/recent?limit=10").get_json()
207
+
208
+ assert [i["episode_description"] for i in items] == ["took a long detour"]
209
+
210
+
211
+ def test_list_reports_other_human_annotator(client, tmp_path: Path) -> None:
212
+ """api_h5_list flags episodes annotated by a different human (#26)."""
213
+ write_valid_episode(tmp_path, stem="ep") # annotated by test_annotator
214
+ with h5py.File(tmp_path / "ep.h5", "r+") as f:
215
+ g = f["episode_annotations"].require_group("someone_else")
216
+ write_annotation_attrs(
217
+ g,
218
+ {
219
+ "outcome": "failure",
220
+ "source": "human",
221
+ "side_effect_category": ["other"],
222
+ "episode_description": "x",
223
+ "severity": "low",
224
+ },
225
+ )
226
+
227
+ entry = next(e for e in client.get("/api/h5/list").get_json() if e["rel_path"] == "ep.h5")
228
+ assert entry["annotated_by_others"] is True
229
+
230
+
231
+ def test_list_ignores_nonhuman_annotator(client, tmp_path: Path) -> None:
232
+ """VLM/automated subgroups do not count as 'another annotator' (#26)."""
233
+ write_valid_episode(tmp_path, stem="ep")
234
+ with h5py.File(tmp_path / "ep.h5", "r+") as f:
235
+ g = f["episode_annotations"].require_group("cosmos-7b")
236
+ g.attrs["source"] = "cosmos-7b"
237
+ g.attrs["success"] = 0.0
238
+
239
+ entry = next(e for e in client.get("/api/h5/list").get_json() if e["rel_path"] == "ep.h5")
240
+ assert entry["annotated_by_others"] is False
@@ -0,0 +1,146 @@
1
+ """Path-handling guards on the annotation server.
2
+
3
+ The server hands out files from ``--samples-dir`` and writes annotations into HDF5 files
4
+ named by a query parameter. Both of those take a caller-supplied path, and every rejection
5
+ branch was untested: the traversal guard on ``/videos-path/<path>``, and all four failure
6
+ modes of ``_safe_h5_path_from_query``.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import h5py
12
+ import pytest
13
+
14
+ from oopsie_data_tools.annotation_tool import annotator_server
15
+ from oopsie_data_tools.test.fixtures.make_valid import write_valid_episode
16
+
17
+
18
+ @pytest.fixture
19
+ def client(tmp_path):
20
+ """A configured server over a samples dir holding one valid episode."""
21
+ write_valid_episode(tmp_path, "ep")
22
+ annotator_server.configure_runtime(
23
+ samples_dir=tmp_path, annotator_name="tester", browse_only=True
24
+ )
25
+ annotator_server.app.config.update(TESTING=True)
26
+ with annotator_server.app.test_client() as test_client:
27
+ yield test_client
28
+
29
+
30
+ @pytest.fixture
31
+ def secret(tmp_path):
32
+ """A file outside the samples directory that must never be reachable."""
33
+ outside = tmp_path.parent / "outside_the_samples_dir.txt"
34
+ outside.write_text("private", encoding="utf-8")
35
+ return outside
36
+
37
+
38
+ # ── /videos-path/<path> ────────────────────────────────────────────────────────
39
+
40
+
41
+ def test_serves_a_video_inside_the_samples_dir(client, tmp_path):
42
+ mp4 = next(tmp_path.glob("*.mp4"))
43
+
44
+ response = client.get(f"/videos-path/{mp4.name}")
45
+ try:
46
+ assert response.status_code == 200
47
+ assert response.mimetype == "video/mp4"
48
+ assert response.data[:4] != b"", "the file should actually stream"
49
+ finally:
50
+ # send_file keeps the handle open until the response is closed, and the suite runs
51
+ # with filterwarnings=error, so a leaked ResourceWarning fails the test.
52
+ response.close()
53
+
54
+
55
+ @pytest.mark.parametrize(
56
+ "attempt",
57
+ [
58
+ "../outside_the_samples_dir.txt",
59
+ "../../etc/passwd",
60
+ "subdir/../../outside_the_samples_dir.txt",
61
+ "%2e%2e/outside_the_samples_dir.txt",
62
+ ],
63
+ )
64
+ def test_traversal_out_of_the_samples_dir_is_refused(client, secret, attempt):
65
+ response = client.get(f"/videos-path/{attempt}")
66
+
67
+ assert response.status_code in (403, 404), (
68
+ f"{attempt!r} returned {response.status_code}; it must not be served"
69
+ )
70
+ assert b"private" not in response.data
71
+
72
+
73
+ def test_missing_video_is_404(client):
74
+ assert client.get("/videos-path/no_such_video.mp4").status_code == 404
75
+
76
+
77
+ # ── ?path= on the HDF5 endpoints ───────────────────────────────────────────────
78
+
79
+
80
+ def test_sample_reads_an_episode_in_the_samples_dir(client, tmp_path):
81
+ h5_name = next(tmp_path.glob("*.h5")).name
82
+
83
+ response = client.get(f"/api/h5/sample?path={h5_name}")
84
+
85
+ assert response.status_code == 200
86
+ assert response.get_json()["metadata"]["rel_path"] == h5_name
87
+
88
+
89
+ @pytest.mark.parametrize(
90
+ "query,expected_status,reason",
91
+ [
92
+ ("", 400, "path query parameter is required"),
93
+ ("?path=", 400, "empty path"),
94
+ ("?path=../escape.h5", 403, "escapes samples directory"),
95
+ ("?path=notes.txt", 400, "must refer to an .h5 file"),
96
+ ("?path=absent.h5", 404, "not found"),
97
+ ],
98
+ )
99
+ def test_sample_rejects_bad_paths(client, query, expected_status, reason):
100
+ response = client.get(f"/api/h5/sample{query}")
101
+
102
+ assert response.status_code == expected_status, reason
103
+ assert "error" in response.get_json()
104
+
105
+
106
+ def test_annotation_write_refuses_a_path_outside_the_samples_dir(client, secret):
107
+ response = client.post(
108
+ "/api/h5/annotations?path=../outside_the_samples_dir.txt",
109
+ json={"outcome": "success"},
110
+ )
111
+
112
+ assert response.status_code in (400, 403)
113
+ assert secret.read_text(encoding="utf-8") == "private", "the file must be untouched"
114
+
115
+
116
+ def test_annotation_write_lands_in_the_episode(client, tmp_path):
117
+ h5_path = next(tmp_path.glob("*.h5"))
118
+
119
+ response = client.post(
120
+ f"/api/h5/annotations?path={h5_path.name}",
121
+ json={"outcome": "success", "additional_notes": "looks fine"},
122
+ )
123
+
124
+ assert response.status_code == 200
125
+ with h5py.File(h5_path, "r") as f:
126
+ group = f["episode_annotations"]["tester"]
127
+ assert float(group.attrs["success"]) == 1.0
128
+ assert group.attrs["additional_notes"] == "looks fine"
129
+
130
+
131
+ def test_instruction_write_refuses_a_path_outside_the_samples_dir(client, secret):
132
+ response = client.post(
133
+ "/api/h5/instruction?path=../outside_the_samples_dir.txt",
134
+ json={"instruction": "overwritten"},
135
+ )
136
+
137
+ assert response.status_code in (400, 403)
138
+ assert secret.read_text(encoding="utf-8") == "private"
139
+
140
+
141
+ def test_instruction_rejects_an_empty_value(client, tmp_path):
142
+ h5_name = next(tmp_path.glob("*.h5")).name
143
+
144
+ response = client.post(f"/api/h5/instruction?path={h5_name}", json={"instruction": " "})
145
+
146
+ assert response.status_code == 400
@@ -0,0 +1,112 @@
1
+ """End-to-end: a bulk inference loop records episodes that validate.
2
+
3
+ This is the only test that exercises record -> HDF5 -> MP4 -> validate as one flow. It
4
+ lived in ``mock_bulk_inference.py``, which pytest never collected because of the filename,
5
+ so the one integration check in the repo never ran in CI.
6
+
7
+ It also no longer sleeps between episodes. That sleep was hiding a real defect: episode
8
+ names are second-resolution, so a fast loop produced two episodes with the same name and
9
+ the second overwrote the first. ``test_back_to_back_episodes_do_not_collide`` pins the fix.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import numpy as np
15
+ import pytest
16
+
17
+ from oopsie_data_tools.annotation_tool.episode_recorder import EpisodeRecorder
18
+ from oopsie_data_tools.utils.robot_profile.robot_profile import RobotProfile
19
+ from oopsie_data_tools.utils.validation.validation_utils import validate_session_dir
20
+
21
+ IMG_H, IMG_W = 224, 224
22
+
23
+ PROFILE = RobotProfile(
24
+ policy_name="mock_pi0",
25
+ robot_name="franka_research_3",
26
+ is_biarm=False,
27
+ uses_mobile_base=False,
28
+ gripper_name="robotiq_2f_85",
29
+ control_freq=10,
30
+ camera_names=["front", "wrist"],
31
+ robot_state_keys=["joint_position", "gripper_position"],
32
+ robot_state_joint_names=["j1", "j2", "j3", "j4", "j5", "j6", "j7"],
33
+ action_space=["joint_velocity", "gripper_position"],
34
+ action_joint_names=["j1", "j2", "j3", "j4", "j5", "j6", "j7"],
35
+ )
36
+
37
+ EPISODES = [
38
+ {"instruction": "pick up the red block", "n_steps": 25, "success": 1.0},
39
+ {"instruction": "place the cup on the tray", "n_steps": 30, "success": 0.0},
40
+ {"instruction": "open the drawer", "n_steps": 22, "success": 1.0},
41
+ ]
42
+
43
+
44
+ def _observation(step: int) -> dict:
45
+ rng = np.random.default_rng(step)
46
+ return {
47
+ "robot_state": {
48
+ "joint_position": rng.standard_normal(7).astype(np.float32),
49
+ "gripper_position": np.array([rng.uniform(-1, 1)], dtype=np.float32),
50
+ },
51
+ "image_observation": {
52
+ cam: rng.integers(0, 255, (IMG_H, IMG_W, 3), dtype=np.uint8)
53
+ for cam in PROFILE.camera_names
54
+ },
55
+ }
56
+
57
+
58
+ def _action() -> dict:
59
+ return {
60
+ "joint_velocity": (np.random.randn(7) * 0.05).astype(np.float32),
61
+ "gripper_position": np.clip(np.random.randn(1).astype(np.float32), -1.0, 1.0),
62
+ }
63
+
64
+
65
+ @pytest.fixture
66
+ def recorded_session(tmp_path):
67
+ """Record every episode back to back, with no pause between them."""
68
+ recorder = EpisodeRecorder(
69
+ robot_profile=PROFILE, data_root_dir=tmp_path, operator_name="mock_operator"
70
+ )
71
+ for episode in EPISODES:
72
+ recorder.reset_episode_recorder()
73
+ for step in range(episode["n_steps"]):
74
+ recorder.record_step(_observation(step), _action())
75
+ recorder.finish_rollout(
76
+ instruction=episode["instruction"], success=episode["success"]
77
+ )
78
+ return recorder.session_dir
79
+
80
+
81
+ def test_a_recorded_session_validates(recorded_session):
82
+ """What the recorder writes must be what the validator accepts."""
83
+ assert validate_session_dir(str(recorded_session), strict_annotation_check=True) == 0
84
+
85
+
86
+ def test_every_episode_is_written(recorded_session):
87
+ assert len(list(recorded_session.glob("*.h5"))) == len(EPISODES)
88
+
89
+
90
+ def test_back_to_back_episodes_do_not_collide(recorded_session):
91
+ """The defect the old sleep(1.1) was working around.
92
+
93
+ These episodes are recorded within the same second, so second-resolution names used to
94
+ repeat and each episode overwrote the previous one's HDF5 and videos.
95
+ """
96
+ episodes = sorted(p.stem for p in recorded_session.glob("*.h5"))
97
+
98
+ assert len(set(episodes)) == len(EPISODES), f"names collided: {episodes}"
99
+ expected_videos = len(EPISODES) * len(PROFILE.camera_names)
100
+ assert len(list(recorded_session.glob("*.mp4"))) == expected_videos
101
+
102
+
103
+ def test_each_episode_keeps_its_own_instruction(recorded_session):
104
+ """A weaker collision would leave the right number of files with the wrong contents."""
105
+ import h5py
106
+
107
+ instructions = set()
108
+ for h5_path in recorded_session.glob("*.h5"):
109
+ with h5py.File(h5_path, "r") as f:
110
+ instructions.add(f.attrs["language_instruction"])
111
+
112
+ assert instructions == {e["instruction"] for e in EPISODES}