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,255 @@
1
+ """The v1 -> v2 migration, and the read-time upcast it has to agree with.
2
+
3
+ Two paths turn a v1 file into v2 data: reading it (which upcasts in memory and leaves the
4
+ file alone) and migrating it (which rewrites the attrs). If they ever disagreed, the same
5
+ episode would mean different things depending on whether anyone had run the migration —
6
+ so the central test here is that the two produce identical results.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+
13
+ import h5py
14
+ import pytest
15
+
16
+ from oopsie_data_tools.annotation_tool.annotation_schema import (
17
+ ANNOTATION_SCHEMA_V2,
18
+ SIDE_EFFECT_CATEGORIES,
19
+ TAXONOMY_SCHEMA_V2,
20
+ V1_CATEGORY_TO_SLUG,
21
+ read_annotation_attrs,
22
+ )
23
+ from oopsie_data_tools.test.fixtures.make_valid import write_valid_episode
24
+ from oopsie_data_tools.utils.migrate_taxonomy_v2 import (
25
+ migrate_annotation_attrs,
26
+ migrate_h5_file,
27
+ migrate_tree,
28
+ )
29
+ from oopsie_data_tools.utils.validation.validation_utils import validate_h5_file
30
+
31
+
32
+ def _v1_attrs(
33
+ *,
34
+ success: float = 0.0,
35
+ failure_description: str = "",
36
+ failure_category=(),
37
+ severity: str = "",
38
+ success_category: str = "",
39
+ ) -> dict:
40
+ taxonomy: dict = {
41
+ "failure_category": list(failure_category),
42
+ "severity": severity,
43
+ }
44
+ if success_category:
45
+ taxonomy["success_category"] = success_category
46
+ return {
47
+ "schema": "oopsie_failure_taxonomy_v1",
48
+ "source": "human",
49
+ "timestamp": "2026-04-21T10:00:00+00:00",
50
+ "success": success,
51
+ "failure_description": failure_description,
52
+ "taxonomy_schema": "oopsiedata_taxonomy_schema_v1",
53
+ "taxonomy": json.dumps(taxonomy),
54
+ "additional_notes": "",
55
+ }
56
+
57
+
58
+ def _outcome_of(attrs: dict) -> str:
59
+ return json.loads(attrs["taxonomy"])["outcome"]
60
+
61
+
62
+ # ── The outcome mapping ───────────────────────────────────────────────────────
63
+
64
+
65
+ @pytest.mark.parametrize(
66
+ "success,success_category,expected",
67
+ [
68
+ (1.0, "", "success"),
69
+ (1.0, "Clean success", "success"),
70
+ (1.0, "Suboptimal execution", "success_suboptimal"),
71
+ (1.0, "Success with side-effects", "success_side_effect"),
72
+ (0.0, "", "failure"),
73
+ # A qualified-success label on a failure is nonsense; the float wins.
74
+ (0.0, "Clean success", "failure"),
75
+ ],
76
+ )
77
+ def test_outcome_is_derived_from_success_and_category(success, success_category, expected):
78
+ out = migrate_annotation_attrs(
79
+ _v1_attrs(success=success, success_category=success_category)
80
+ )
81
+ assert _outcome_of(out) == expected
82
+
83
+
84
+ @pytest.mark.parametrize("prose,slug", sorted(V1_CATEGORY_TO_SLUG.items()))
85
+ def test_every_v1_category_maps_to_a_known_slug(prose, slug):
86
+ """Parametrized over the whole table, so a new category cannot be added untested."""
87
+ out = migrate_annotation_attrs(_v1_attrs(failure_category=[prose]))
88
+
89
+ assert json.loads(out["taxonomy"])["side_effect_category"] == [slug]
90
+ assert slug in SIDE_EFFECT_CATEGORIES
91
+
92
+
93
+ @pytest.mark.parametrize(
94
+ "prose,slug",
95
+ [
96
+ ("Low severity - no damage, can be reset and reattempted", "low"),
97
+ (
98
+ "Medium severity - some damage or risk of damage or significant reset "
99
+ "required, but can be reattempted",
100
+ "medium",
101
+ ),
102
+ (
103
+ "Catastrophic - significant damage or risk of damage, cannot be "
104
+ "reattempted without repair",
105
+ "catastrophic",
106
+ ),
107
+ ("", ""),
108
+ ],
109
+ )
110
+ def test_severity_prose_maps_to_a_slug(prose, slug):
111
+ out = migrate_annotation_attrs(_v1_attrs(severity=prose))
112
+ assert json.loads(out["taxonomy"])["severity"] == slug
113
+
114
+
115
+ def test_description_is_renamed_and_the_old_key_removed():
116
+ out = migrate_annotation_attrs(_v1_attrs(failure_description="dropped it"))
117
+
118
+ assert out["episode_description"] == "dropped it"
119
+ # Leaving both would force every reader to decide which one wins.
120
+ assert "failure_description" not in out
121
+
122
+
123
+ def test_schema_strings_are_bumped():
124
+ out = migrate_annotation_attrs(_v1_attrs())
125
+
126
+ assert out["schema"] == ANNOTATION_SCHEMA_V2
127
+ assert out["taxonomy_schema"] == TAXONOMY_SCHEMA_V2
128
+
129
+
130
+ def test_unmapped_values_are_preserved_verbatim():
131
+ """Stale labels stay visible rather than being guessed into ``other``."""
132
+ out = migrate_annotation_attrs(
133
+ _v1_attrs(failure_category=["grasp_failure", "transport_failure"], severity="major")
134
+ )
135
+ taxonomy = json.loads(out["taxonomy"])
136
+
137
+ assert taxonomy["side_effect_category"] == ["grasp_failure", "transport_failure"]
138
+ assert taxonomy["severity"] == "major"
139
+
140
+
141
+ # ── Nothing-to-do cases ───────────────────────────────────────────────────────
142
+
143
+
144
+ def test_an_already_v2_annotation_is_left_alone():
145
+ v2 = migrate_annotation_attrs(_v1_attrs(success=1.0))
146
+ assert migrate_annotation_attrs(v2) is None
147
+
148
+
149
+ def test_a_subgroup_that_is_not_an_annotation_is_skipped():
150
+ """No success and no taxonomy: inventing one would make an invalid file pass."""
151
+ assert migrate_annotation_attrs({"source": "human"}) is None
152
+
153
+
154
+ # ── Whole files ───────────────────────────────────────────────────────────────
155
+
156
+
157
+ def test_migration_and_read_time_upcast_agree(tmp_path):
158
+ """The load-bearing test: migrating must not change what the file already means."""
159
+ h5_path = write_valid_episode(tmp_path, stem="ep")
160
+ with h5py.File(h5_path, "r+") as f:
161
+ g = f["episode_annotations"]["test_annotator"]
162
+ for key in list(g.attrs):
163
+ del g.attrs[key]
164
+ for key, value in _v1_attrs(
165
+ success=0.0,
166
+ failure_description="Gripper closed early.",
167
+ failure_category=["Grasp failure (at contact)", "Collision failure"],
168
+ severity="Low severity - no damage, can be reset and reattempted",
169
+ ).items():
170
+ g.attrs[key] = value
171
+
172
+ with h5py.File(h5_path, "r") as f:
173
+ before = read_annotation_attrs(f["episode_annotations"]["test_annotator"].attrs)
174
+
175
+ migrate_h5_file(h5_path, apply=True)
176
+
177
+ with h5py.File(h5_path, "r") as f:
178
+ after = read_annotation_attrs(f["episode_annotations"]["test_annotator"].attrs)
179
+
180
+ assert before == after
181
+ assert after["outcome"] == "failure"
182
+ assert after["side_effect_category"] == ["grasp", "collision"]
183
+ assert after["severity"] == "low"
184
+
185
+
186
+ def test_a_dry_run_writes_nothing(tmp_path):
187
+ h5_path = write_valid_episode(tmp_path, stem="ep")
188
+ with h5py.File(h5_path, "r+") as f:
189
+ g = f["episode_annotations"]["test_annotator"]
190
+ for key in list(g.attrs):
191
+ del g.attrs[key]
192
+ for key, value in _v1_attrs(success=1.0).items():
193
+ g.attrs[key] = value
194
+ before = h5_path.read_bytes()
195
+
196
+ report = migrate_h5_file(h5_path, apply=False)
197
+
198
+ assert report["migrated"] == ["test_annotator"]
199
+ assert h5_path.read_bytes() == before
200
+
201
+
202
+ def test_migration_is_idempotent(tmp_path):
203
+ h5_path = write_valid_episode(tmp_path, stem="ep")
204
+ with h5py.File(h5_path, "r+") as f:
205
+ g = f["episode_annotations"]["test_annotator"]
206
+ for key in list(g.attrs):
207
+ del g.attrs[key]
208
+ for key, value in _v1_attrs(success=1.0).items():
209
+ g.attrs[key] = value
210
+
211
+ first = migrate_h5_file(h5_path, apply=True)
212
+ after_first = h5_path.read_bytes()
213
+ second = migrate_h5_file(h5_path, apply=True)
214
+
215
+ assert first["migrated"] == ["test_annotator"]
216
+ assert second["migrated"] == []
217
+ assert second["skipped"]["test_annotator"] == "already v2"
218
+ assert h5_path.read_bytes() == after_first
219
+
220
+
221
+ def test_a_v2_file_is_untouched(tmp_path):
222
+ """write_valid_episode already writes v2, so there is nothing to do."""
223
+ h5_path = write_valid_episode(tmp_path, stem="ep")
224
+ before = h5_path.read_bytes()
225
+
226
+ report = migrate_h5_file(h5_path, apply=True)
227
+
228
+ assert report["migrated"] == []
229
+ assert h5_path.read_bytes() == before
230
+
231
+
232
+ def test_a_migrated_file_still_validates(tmp_path):
233
+ h5_path = write_valid_episode(tmp_path, stem="ep")
234
+ with h5py.File(h5_path, "r+") as f:
235
+ g = f["episode_annotations"]["test_annotator"]
236
+ for key in list(g.attrs):
237
+ del g.attrs[key]
238
+ for key, value in _v1_attrs(
239
+ success=0.0, failure_description="dropped it", severity="major"
240
+ ).items():
241
+ g.attrs[key] = value
242
+
243
+ migrate_h5_file(h5_path, apply=True)
244
+
245
+ assert validate_h5_file(str(h5_path), strict_annotation_check=True) is True
246
+
247
+
248
+ def test_migrate_tree_reports_one_entry_per_file(tmp_path):
249
+ write_valid_episode(tmp_path, stem="a")
250
+ write_valid_episode(tmp_path, stem="b")
251
+
252
+ reports = migrate_tree(tmp_path, apply=False)
253
+
254
+ assert len(reports) == 2
255
+ assert all(r["error"] is None for r in reports)
@@ -0,0 +1,84 @@
1
+ """Tests for ``oopsie-data new-profile``.
2
+
3
+ The command replaces the old "copy configs/robot_profiles/template.yaml" instruction,
4
+ which only worked from a source checkout. Nothing is read out of the installed package,
5
+ so the template text lives in Python.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import pytest
11
+
12
+ from oopsie_data_tools import cli
13
+ from oopsie_data_tools.utils import paths
14
+ from oopsie_data_tools.utils.robot_profile.robot_profile import load_robot_profile
15
+ from oopsie_data_tools.utils.robot_profile.template import PROFILE_TEMPLATE
16
+
17
+
18
+ @pytest.fixture
19
+ def project(tmp_path, monkeypatch):
20
+ """A project directory with no profiles, as the working directory."""
21
+ monkeypatch.delenv(paths.ENV_PROFILES_DIR, raising=False)
22
+ monkeypatch.chdir(tmp_path)
23
+ return tmp_path
24
+
25
+
26
+ def test_checked_in_template_matches_the_python_source():
27
+ """configs/robot_profiles/template.yaml is a copy for browsing; it must not drift."""
28
+ repo_config = paths.repo_config_dir()
29
+ assert repo_config is not None, "this test runs from a checkout"
30
+ checked_in = repo_config / paths.PROFILES_DIR_NAME / "template.yaml"
31
+
32
+ assert checked_in.read_text(encoding="utf-8") == PROFILE_TEMPLATE, (
33
+ "configs/robot_profiles/template.yaml has drifted from "
34
+ "oopsie_data_tools/utils/robot_profile/template.py, which is the source of truth. "
35
+ "Regenerate the checked-in copy from PROFILE_TEMPLATE."
36
+ )
37
+
38
+
39
+ def test_writes_into_project_local_robot_profiles(project):
40
+ assert cli.main(["new-profile"]) == 0
41
+
42
+ written = project / paths.PROFILES_DIR_NAME / "robot_profile.yaml"
43
+ assert written.is_file()
44
+ assert written.read_text(encoding="utf-8") == PROFILE_TEMPLATE
45
+
46
+
47
+ def test_honours_name_and_dir(project):
48
+ assert cli.main(["new-profile", "--name", "yam_pro", "--dir", str(project / "cfg")]) == 0
49
+
50
+ assert (project / "cfg" / "yam_pro.yaml").is_file()
51
+ assert not (project / paths.PROFILES_DIR_NAME).exists()
52
+
53
+
54
+ def test_refuses_to_clobber_without_force(project, caplog):
55
+ assert cli.main(["new-profile"]) == 0
56
+ written = project / paths.PROFILES_DIR_NAME / "robot_profile.yaml"
57
+ written.write_text("policy_name: mine\n", encoding="utf-8")
58
+
59
+ assert cli.main(["new-profile"]) == 1
60
+ assert written.read_text(encoding="utf-8") == "policy_name: mine\n", "must not overwrite"
61
+ assert "already exists" in caplog.text
62
+
63
+ assert cli.main(["new-profile", "--force"]) == 0
64
+ assert written.read_text(encoding="utf-8") == PROFILE_TEMPLATE
65
+
66
+
67
+ def test_emitted_profile_does_not_load_until_filled_in(project):
68
+ """The skeleton must fail loudly rather than record placeholder metadata."""
69
+ assert cli.main(["new-profile"]) == 0
70
+ written = project / paths.PROFILES_DIR_NAME / "robot_profile.yaml"
71
+
72
+ # Which check fires first is an ordering detail of robot_profile_from_raw; all that
73
+ # matters here is that a blank skeleton is rejected rather than silently loaded.
74
+ with pytest.raises(ValueError, match="Invalid action_space|robot state keys|missing keys"):
75
+ load_robot_profile(written)
76
+
77
+
78
+ def test_written_profile_is_not_picked_up_from_site_packages(project):
79
+ """The profile lookup must resolve to the project, never to the installed package."""
80
+ assert cli.main(["new-profile"]) == 0
81
+
82
+ resolved = paths.robot_profiles_dir()
83
+ assert resolved == project / paths.PROFILES_DIR_NAME
84
+ assert "site-packages" not in str(resolved)
@@ -0,0 +1,137 @@
1
+ """Tests for config path resolution (``oopsie_data_tools/utils/paths.py``).
2
+
3
+ Credentials and robot profiles are resolved through separate chains: credentials are
4
+ per-user, profiles live next to the robot code and never in the user config directory.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import pytest
10
+
11
+ from oopsie_data_tools.utils import paths
12
+
13
+
14
+ @pytest.fixture
15
+ def isolated_home(tmp_path, monkeypatch):
16
+ """Point the user config dir at a scratch directory and clear both env overrides."""
17
+ monkeypatch.delenv(paths.ENV_CONFIG_DIR, raising=False)
18
+ monkeypatch.delenv(paths.ENV_PROFILES_DIR, raising=False)
19
+ monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "xdg"))
20
+ return tmp_path / "xdg" / "oopsie-data"
21
+
22
+
23
+ # ── credentials ───────────────────────────────────────────────────────────────
24
+
25
+
26
+ def test_user_config_dir_follows_xdg(isolated_home):
27
+ assert paths.user_config_dir() == isolated_home
28
+
29
+
30
+ # The repo-fallback leg of the credential chain is deliberately not tested here: it only
31
+ # triggers when configs/contributor_config.yaml exists, and that file is gitignored (it holds
32
+ # a token), so any such test passes or fails according to whether the developer happens to
33
+ # have created one. What the chain does with a config that *is* present is covered below.
34
+
35
+
36
+ def test_user_dir_wins_over_repo_for_credentials(isolated_home):
37
+ isolated_home.mkdir(parents=True)
38
+ (isolated_home / "contributor_config.yaml").write_text("lab_id: UserLab\n")
39
+
40
+ assert paths.contributor_config_path() == isolated_home / "contributor_config.yaml"
41
+ assert paths.config_dir_source() == "user config dir"
42
+
43
+
44
+ def test_env_var_wins_when_it_holds_the_config(isolated_home, tmp_path, monkeypatch):
45
+ env_dir = tmp_path / "explicit"
46
+ env_dir.mkdir()
47
+ (env_dir / "contributor_config.yaml").write_text("lab_id: EnvLab\n")
48
+ isolated_home.mkdir(parents=True)
49
+ (isolated_home / "contributor_config.yaml").write_text("lab_id: UserLab\n")
50
+ monkeypatch.setenv(paths.ENV_CONFIG_DIR, str(env_dir))
51
+
52
+ assert paths.contributor_config_path() == env_dir / "contributor_config.yaml"
53
+ assert paths.write_config_dir() == env_dir
54
+ assert paths.config_dir_source() == f"${paths.ENV_CONFIG_DIR}"
55
+
56
+
57
+ def test_env_var_is_the_write_target_but_reads_still_fall_back(
58
+ isolated_home, tmp_path, monkeypatch
59
+ ):
60
+ """An override that does not hold a config yet still reads the one that exists."""
61
+ env_dir = tmp_path / "explicit"
62
+ env_dir.mkdir()
63
+ isolated_home.mkdir(parents=True)
64
+ (isolated_home / "contributor_config.yaml").write_text("lab_id: UserLab\n")
65
+ monkeypatch.setenv(paths.ENV_CONFIG_DIR, str(env_dir))
66
+
67
+ assert paths.write_config_dir() == env_dir
68
+ assert paths.contributor_config_path() == isolated_home / "contributor_config.yaml"
69
+
70
+
71
+ def test_write_config_dir_defaults_to_user_dir(isolated_home):
72
+ assert paths.write_config_dir() == isolated_home
73
+
74
+
75
+ # ── robot profiles ────────────────────────────────────────────────────────────
76
+
77
+
78
+ def test_profiles_never_resolve_to_the_user_config_dir(isolated_home, tmp_path, monkeypatch):
79
+ """Credentials live in ~/.config; profiles must not be expected there."""
80
+ profiles_in_user_dir = isolated_home / paths.PROFILES_DIR_NAME
81
+ profiles_in_user_dir.mkdir(parents=True)
82
+ monkeypatch.chdir(tmp_path)
83
+
84
+ assert paths.robot_profiles_dir() != profiles_in_user_dir
85
+ assert profiles_in_user_dir not in paths.profiles_search_dirs()
86
+
87
+
88
+ def test_project_local_profiles_win(isolated_home, tmp_path, monkeypatch):
89
+ project = tmp_path / "my_robot_code"
90
+ (project / paths.PROFILES_DIR_NAME).mkdir(parents=True)
91
+ monkeypatch.chdir(project)
92
+
93
+ assert paths.robot_profiles_dir() == project / paths.PROFILES_DIR_NAME
94
+
95
+
96
+ def test_project_configs_subdir_is_also_searched(isolated_home, tmp_path, monkeypatch):
97
+ project = tmp_path / "my_robot_code"
98
+ (project / "configs" / paths.PROFILES_DIR_NAME).mkdir(parents=True)
99
+ monkeypatch.chdir(project)
100
+
101
+ assert paths.robot_profiles_dir() == project / "configs" / paths.PROFILES_DIR_NAME
102
+
103
+
104
+ def test_profiles_fall_back_to_the_checkout(isolated_home, tmp_path, monkeypatch):
105
+ monkeypatch.chdir(tmp_path) # nothing project-local here
106
+
107
+ assert paths.robot_profiles_dir() == paths.repo_config_dir() / paths.PROFILES_DIR_NAME
108
+
109
+
110
+ def test_profiles_env_override_wins(isolated_home, tmp_path, monkeypatch):
111
+ override = tmp_path / "shared_profiles"
112
+ override.mkdir()
113
+ project = tmp_path / "my_robot_code"
114
+ (project / paths.PROFILES_DIR_NAME).mkdir(parents=True)
115
+ monkeypatch.chdir(project)
116
+ monkeypatch.setenv(paths.ENV_PROFILES_DIR, str(override))
117
+
118
+ assert paths.robot_profiles_dir() == override
119
+ assert paths.write_profiles_dir() == override
120
+
121
+
122
+ def test_write_profiles_dir_is_project_local_by_default(isolated_home, tmp_path, monkeypatch):
123
+ project = tmp_path / "my_robot_code"
124
+ project.mkdir()
125
+ monkeypatch.chdir(project)
126
+
127
+ assert paths.write_profiles_dir() == project / paths.PROFILES_DIR_NAME
128
+
129
+
130
+ def test_config_dir_override_does_not_move_profiles(isolated_home, tmp_path, monkeypatch):
131
+ """$OOPSIE_CONFIG_DIR is about credentials only."""
132
+ env_dir = tmp_path / "explicit"
133
+ (env_dir / paths.PROFILES_DIR_NAME).mkdir(parents=True)
134
+ monkeypatch.setenv(paths.ENV_CONFIG_DIR, str(env_dir))
135
+ monkeypatch.chdir(tmp_path)
136
+
137
+ assert paths.robot_profiles_dir() == paths.repo_config_dir() / paths.PROFILES_DIR_NAME
@@ -0,0 +1,79 @@
1
+ """Guard the Python 3.8 floor against runtime-only annotation breakage.
2
+
3
+ Ruff's ``target-version`` only tunes autofixes; it does not reject newer syntax, and it
4
+ cannot see runtime-only breakage at all. tyro (used by the ``examples/``) resolves dataclass
5
+ field annotations at runtime via ``typing.get_type_hints``, which raises ``TypeError`` on 3.8
6
+ for a PEP 604 union (``str | None``) *even under* ``from __future__ import annotations``.
7
+
8
+ The check is static rather than an import test because the examples import ``droid`` and
9
+ ``openpi_client``, neither of which is on PyPI, so CI cannot import them.
10
+ """
11
+
12
+ import ast
13
+ from pathlib import Path
14
+
15
+ import pytest
16
+
17
+ _REPO_ROOT = Path(__file__).resolve().parents[2]
18
+ _PACKAGE_DIR = _REPO_ROOT / "oopsie_data_tools"
19
+ _EXAMPLES_DIR = _REPO_ROOT / "examples"
20
+
21
+
22
+ def _decorator_name(node):
23
+ """Last dotted component of a decorator, without ast.unparse (absent on 3.8)."""
24
+ while isinstance(node, ast.Call):
25
+ node = node.func
26
+ while isinstance(node, ast.Attribute):
27
+ node = node.attr if isinstance(node.attr, str) else node.value
28
+ return node if isinstance(node, str) else ""
29
+ return node.id if isinstance(node, ast.Name) else ""
30
+
31
+
32
+ def _scan(root: Path):
33
+ """Return ``(offenders, dataclasses_inspected)`` for every .py file under *root*."""
34
+ offenders = []
35
+ inspected = 0
36
+ for path in sorted(root.rglob("*.py")):
37
+ if "__pycache__" in path.parts:
38
+ continue
39
+ tree = ast.parse(path.read_text(encoding="utf-8"))
40
+ for node in ast.walk(tree):
41
+ if not isinstance(node, ast.ClassDef):
42
+ continue
43
+ if not any("dataclass" in _decorator_name(d) for d in node.decorator_list):
44
+ continue
45
+ inspected += 1
46
+ for stmt in node.body:
47
+ if not isinstance(stmt, ast.AnnAssign):
48
+ continue
49
+ if isinstance(stmt.annotation, ast.BinOp) and isinstance(
50
+ stmt.annotation.op, ast.BitOr
51
+ ):
52
+ field = stmt.target.id if isinstance(stmt.target, ast.Name) else "?"
53
+ rel = path.relative_to(_REPO_ROOT)
54
+ offenders.append(f"{rel}:{stmt.lineno} {node.name}.{field}")
55
+ return offenders, inspected
56
+
57
+
58
+ @pytest.mark.parametrize(
59
+ "root",
60
+ [
61
+ pytest.param(_PACKAGE_DIR, id="package"),
62
+ pytest.param(_EXAMPLES_DIR, id="examples"),
63
+ ],
64
+ )
65
+ def test_no_pep604_in_dataclass_fields(root: Path) -> None:
66
+ if not root.is_dir():
67
+ # examples/ is not shipped in the wheel; nothing to scan from an installed package.
68
+ pytest.skip(f"{root.name}/ not present")
69
+
70
+ offenders, inspected = _scan(root)
71
+
72
+ # Without this the test passes trivially if the scan ever stops finding dataclasses —
73
+ # exactly how the original version of this check silently reported success.
74
+ assert inspected > 0, f"no dataclasses found under {root}; the scan is not working"
75
+ assert not offenders, (
76
+ "PEP 604 union in a dataclass field. tyro resolves these via typing.get_type_hints, "
77
+ "which raises TypeError on the Python 3.8 floor. Use Optional[X] instead:\n "
78
+ + "\n ".join(offenders)
79
+ )
@@ -0,0 +1,127 @@
1
+ """``record_step`` must not modify the dicts the caller passes in.
2
+
3
+ It used to: ``_check_step_data`` wrote the quaternion-converted pose back into the caller's
4
+ ``action``/``robot_state``, and ``record_step`` then read those same dicts to fill its
5
+ buffer. The mutation was therefore load-bearing — the conversion reached the recording
6
+ buffer through it — so "just copy the dict" would have silently recorded raw euler values
7
+ labelled as quaternions. These tests pin down both halves: the caller's data is untouched,
8
+ *and* the conversion still lands in the episode.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import copy
14
+ import tempfile
15
+ from pathlib import Path
16
+
17
+ import numpy as np
18
+ import pytest
19
+ from scipy.spatial.transform import Rotation as R
20
+
21
+ from oopsie_data_tools.annotation_tool.episode_recorder import EpisodeRecorder
22
+ from oopsie_data_tools.utils.robot_profile.robot_profile import RobotProfile
23
+
24
+ EULER = [0.3, -0.5, 1.1]
25
+ POSITION = [0.1, -0.2, 0.3]
26
+
27
+
28
+ def _cartesian_profile(**overrides) -> RobotProfile:
29
+ defaults = dict(
30
+ policy_name="p",
31
+ robot_name="r",
32
+ is_biarm=False,
33
+ uses_mobile_base=False,
34
+ gripper_name="g",
35
+ control_freq=10,
36
+ camera_names=["left"],
37
+ robot_state_keys=["joint_position", "gripper_position", "cartesian_position"],
38
+ robot_state_joint_names=["j1", "j2"],
39
+ action_space=["cartesian_position", "gripper_position"],
40
+ orientation_representation="euler_xyz",
41
+ robot_state_orientation_representation="euler_xyz",
42
+ )
43
+ defaults.update(overrides)
44
+ return RobotProfile(**defaults)
45
+
46
+
47
+ @pytest.fixture
48
+ def recorder():
49
+ with tempfile.TemporaryDirectory() as tmp:
50
+ rec = EpisodeRecorder(
51
+ robot_profile=_cartesian_profile(),
52
+ data_root_dir=Path(tmp),
53
+ operator_name="op",
54
+ )
55
+ rec.reset_episode_recorder()
56
+ yield rec
57
+
58
+
59
+ def _observation() -> dict:
60
+ return {
61
+ "robot_state": {
62
+ "joint_position": np.zeros(2, dtype=np.float32),
63
+ "gripper_position": np.zeros(1, dtype=np.float32),
64
+ "cartesian_position": np.array(POSITION + EULER, dtype=np.float32),
65
+ },
66
+ "image_observation": {"left": np.zeros((64, 64, 3), dtype=np.uint8)},
67
+ }
68
+
69
+
70
+ def _action() -> dict:
71
+ return {
72
+ "cartesian_position": np.array(POSITION + EULER, dtype=np.float32),
73
+ "gripper_position": np.zeros(1, dtype=np.float32),
74
+ }
75
+
76
+
77
+ def test_caller_dicts_are_not_mutated(recorder):
78
+ observation, action = _observation(), _action()
79
+ observation_before = copy.deepcopy(observation)
80
+ action_before = copy.deepcopy(action)
81
+
82
+ recorder.record_step(observation=observation, action=action)
83
+
84
+ np.testing.assert_array_equal(
85
+ action["cartesian_position"],
86
+ action_before["cartesian_position"],
87
+ err_msg="record_step rewrote the caller's action dict",
88
+ )
89
+ np.testing.assert_array_equal(
90
+ observation["robot_state"]["cartesian_position"],
91
+ observation_before["robot_state"]["cartesian_position"],
92
+ err_msg="record_step rewrote the caller's robot_state dict",
93
+ )
94
+ assert action["cartesian_position"].shape == (6,), "still euler, not converted in place"
95
+
96
+
97
+ def test_conversion_still_reaches_the_buffer(recorder):
98
+ """The half that a naive de-mutation would have broken, silently."""
99
+ recorder.record_step(observation=_observation(), action=_action())
100
+
101
+ buffered_action = recorder.timesteps[0]["action_dict"]["cartesian_position"]
102
+ buffered_state = recorder.timesteps[0]["robot_state"]["cartesian_position"]
103
+
104
+ assert buffered_action.shape == (7,), "euler must have been converted to a quaternion"
105
+ assert buffered_state.shape == (7,)
106
+ np.testing.assert_allclose(buffered_action[:3], POSITION, rtol=1e-6)
107
+ np.testing.assert_allclose(
108
+ R.from_quat(buffered_action[3:]).as_matrix(),
109
+ R.from_euler("xyz", EULER).as_matrix(),
110
+ atol=1e-6,
111
+ )
112
+
113
+
114
+ def test_the_same_action_dict_can_be_reused_across_steps(recorder):
115
+ """A caller reusing one preallocated dict must not accumulate conversions."""
116
+ observation, action = _observation(), _action()
117
+
118
+ for _ in range(3):
119
+ recorder.record_step(observation=observation, action=action)
120
+
121
+ assert recorder.num_steps == 3
122
+ for step in recorder.timesteps:
123
+ np.testing.assert_allclose(
124
+ step["action_dict"]["cartesian_position"],
125
+ recorder.timesteps[0]["action_dict"]["cartesian_position"],
126
+ err_msg="repeated conversion of an already-converted pose",
127
+ )