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.
- oopsie_data_tools/__init__.py +7 -0
- oopsie_data_tools/annotation_tool/__init__.py +5 -0
- oopsie_data_tools/annotation_tool/annotation_schema.py +266 -0
- oopsie_data_tools/annotation_tool/annotator_server.py +850 -0
- oopsie_data_tools/annotation_tool/episode_recorder.py +648 -0
- oopsie_data_tools/annotation_tool/rollout_annotator.py +333 -0
- oopsie_data_tools/annotation_tool/ui/annotator.html +2240 -0
- oopsie_data_tools/cli.py +894 -0
- oopsie_data_tools/init_wizard.py +329 -0
- oopsie_data_tools/skill/SKILL.md +98 -0
- oopsie_data_tools/skill/reference/conversion.md +257 -0
- oopsie_data_tools/skill/reference/format.md +112 -0
- oopsie_data_tools/skill/reference/robot-profile.md +104 -0
- oopsie_data_tools/skill/reference/setup.md +216 -0
- oopsie_data_tools/skill/reference/troubleshooting.md +97 -0
- oopsie_data_tools/test/__init__.py +1 -0
- oopsie_data_tools/test/conftest.py +174 -0
- oopsie_data_tools/test/fixtures/__init__.py +0 -0
- oopsie_data_tools/test/fixtures/make_invalid.py +633 -0
- oopsie_data_tools/test/fixtures/make_valid.py +406 -0
- oopsie_data_tools/test/test_annotation_completeness.py +154 -0
- oopsie_data_tools/test/test_annotation_schema.py +190 -0
- oopsie_data_tools/test/test_annotator_server.py +240 -0
- oopsie_data_tools/test/test_annotator_server_guards.py +146 -0
- oopsie_data_tools/test/test_bulk_inference_end_to_end.py +112 -0
- oopsie_data_tools/test/test_cli_config.py +111 -0
- oopsie_data_tools/test/test_cli_tools.py +438 -0
- oopsie_data_tools/test/test_contributor_config.py +38 -0
- oopsie_data_tools/test/test_conversion_utils_annotations.py +75 -0
- oopsie_data_tools/test/test_credentials_location.py +106 -0
- oopsie_data_tools/test/test_diversity.py +33 -0
- oopsie_data_tools/test/test_episode_recorder.py +335 -0
- oopsie_data_tools/test/test_episode_video_paths.py +173 -0
- oopsie_data_tools/test/test_hf_upload.py +234 -0
- oopsie_data_tools/test/test_init_wizard.py +193 -0
- oopsie_data_tools/test/test_install_skill.py +120 -0
- oopsie_data_tools/test/test_migrate_taxonomy_v2.py +255 -0
- oopsie_data_tools/test/test_new_profile.py +84 -0
- oopsie_data_tools/test/test_paths.py +137 -0
- oopsie_data_tools/test/test_python38_compat.py +79 -0
- oopsie_data_tools/test/test_record_step_purity.py +127 -0
- oopsie_data_tools/test/test_robot_setup.py +244 -0
- oopsie_data_tools/test/test_rollout_annotator.py +134 -0
- oopsie_data_tools/test/test_rotation_utils.py +126 -0
- oopsie_data_tools/test/test_validate.py +494 -0
- oopsie_data_tools/utils/__init__.py +1 -0
- oopsie_data_tools/utils/claude_skill.py +96 -0
- oopsie_data_tools/utils/contributor_config.py +91 -0
- oopsie_data_tools/utils/conversion_utils.py +220 -0
- oopsie_data_tools/utils/h5.py +60 -0
- oopsie_data_tools/utils/h5_inspect.py +167 -0
- oopsie_data_tools/utils/hf_limits.py +22 -0
- oopsie_data_tools/utils/hf_upload.py +235 -0
- oopsie_data_tools/utils/log.py +35 -0
- oopsie_data_tools/utils/migrate_taxonomy_v2.py +215 -0
- oopsie_data_tools/utils/paths.py +162 -0
- oopsie_data_tools/utils/restructure.py +402 -0
- oopsie_data_tools/utils/robot_profile/__init__.py +1 -0
- oopsie_data_tools/utils/robot_profile/robot_profile.py +240 -0
- oopsie_data_tools/utils/robot_profile/rotation_utils.py +119 -0
- oopsie_data_tools/utils/robot_profile/template.py +108 -0
- oopsie_data_tools/utils/validation/__init__.py +5 -0
- oopsie_data_tools/utils/validation/annotation_completeness.py +67 -0
- oopsie_data_tools/utils/validation/diversity.py +93 -0
- oopsie_data_tools/utils/validation/episode_data.py +54 -0
- oopsie_data_tools/utils/validation/episode_loader.py +201 -0
- oopsie_data_tools/utils/validation/episode_validator.py +315 -0
- oopsie_data_tools/utils/validation/errors.py +17 -0
- oopsie_data_tools/utils/validation/validation_utils.py +88 -0
- oopsie_data_tools-0.2.0.dist-info/METADATA +131 -0
- oopsie_data_tools-0.2.0.dist-info/RECORD +74 -0
- oopsie_data_tools-0.2.0.dist-info/WHEEL +4 -0
- oopsie_data_tools-0.2.0.dist-info/entry_points.txt +2 -0
- 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
|
+
)
|