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,235 @@
|
|
|
1
|
+
"""Validation + HuggingFace upload helpers.
|
|
2
|
+
|
|
3
|
+
Backs ``oopsie-data validate``, ``oopsie-data upload`` and ``oopsie-data submissions``
|
|
4
|
+
(:mod:`oopsie_data_tools.cli`), which is the only entry point — the parallel
|
|
5
|
+
``scripts/validate_and_upload/`` copies of this pipeline are gone.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
import os
|
|
12
|
+
from collections import Counter
|
|
13
|
+
|
|
14
|
+
from oopsie_data_tools.utils.contributor_config import read_contributor_config
|
|
15
|
+
from oopsie_data_tools.utils.hf_limits import FILE_LIMIT
|
|
16
|
+
from oopsie_data_tools.utils.log import setup_logger
|
|
17
|
+
from oopsie_data_tools.utils.validation.errors import EpisodeValidationError
|
|
18
|
+
from oopsie_data_tools.utils.validation.validation_utils import (
|
|
19
|
+
validate_h5_file,
|
|
20
|
+
validate_session_dir,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# ── HuggingFace authentication ────────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def resolve_hf_target() -> tuple[str, str]:
|
|
30
|
+
"""Resolve ``(hf_token, repo_id)`` lazily.
|
|
31
|
+
|
|
32
|
+
Read only when actually uploading so validation and ``--help`` work on a fresh
|
|
33
|
+
checkout without a filled-in contributor config. ``HF_TOKEN`` in the environment
|
|
34
|
+
overrides the config token.
|
|
35
|
+
"""
|
|
36
|
+
lab_id, config_token = read_contributor_config()
|
|
37
|
+
token = os.environ.get("HF_TOKEN", "").strip() or config_token
|
|
38
|
+
return token, f"OopsieData-Submissions/{lab_id}"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def hf_login(token: str) -> str:
|
|
42
|
+
from huggingface_hub import login, whoami
|
|
43
|
+
|
|
44
|
+
login(token=token, add_to_git_credential=False)
|
|
45
|
+
info = whoami(token=token)
|
|
46
|
+
logger.info("[auth] Logged in as: %s", info["name"])
|
|
47
|
+
return info["name"]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# ── Validation ────────────────────────────────────────────────────────────────
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def run_validation(base_path: str, episode_id: str | None = None, log_path: str | None = None) -> int:
|
|
54
|
+
"""Validate a single episode or a session dir.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
base_path: Session directory, or the directory holding ``<episode_id>.h5``.
|
|
58
|
+
episode_id: Optional zero-padded episode id; validates just that episode.
|
|
59
|
+
log_path: Optional file to mirror log output into.
|
|
60
|
+
|
|
61
|
+
Returns:
|
|
62
|
+
A shell-style exit code: 0 if everything passed, 1 otherwise.
|
|
63
|
+
"""
|
|
64
|
+
# Route this module's pass/fail lines to the log file too, so --log-path captures
|
|
65
|
+
# the single-file path (validate_session_dir already logs through its own logger).
|
|
66
|
+
if log_path is not None:
|
|
67
|
+
setup_logger(__name__, log_path)
|
|
68
|
+
|
|
69
|
+
target = os.path.join(base_path, f"{episode_id}.h5") if episode_id else base_path
|
|
70
|
+
if os.path.isfile(target):
|
|
71
|
+
try:
|
|
72
|
+
validate_h5_file(target, strict_annotation_check=True, log_path=log_path)
|
|
73
|
+
logger.info("%s passed", os.path.basename(target))
|
|
74
|
+
return 0
|
|
75
|
+
except EpisodeValidationError as e:
|
|
76
|
+
logger.error("Validation failed: %s", e)
|
|
77
|
+
return 1
|
|
78
|
+
except Exception as e:
|
|
79
|
+
logger.error("Unexpected error: %s", e)
|
|
80
|
+
return 1
|
|
81
|
+
|
|
82
|
+
if os.path.isdir(target):
|
|
83
|
+
return validate_session_dir(target, strict_annotation_check=True, log_path=log_path)
|
|
84
|
+
|
|
85
|
+
logger.error("Path does not exist: %s", target)
|
|
86
|
+
return 1
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
# ── Repo creation ─────────────────────────────────────────────────────────────
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def ensure_repo(api, repo: str) -> None:
|
|
93
|
+
try:
|
|
94
|
+
api.repo_info(repo_id=repo, repo_type="dataset")
|
|
95
|
+
logger.info("[hf] Repo already exists: https://huggingface.co/datasets/%s", repo)
|
|
96
|
+
except Exception:
|
|
97
|
+
logger.info("[hf] Creating repo: %s", repo)
|
|
98
|
+
api.create_repo(repo_id=repo, repo_type="dataset", private=False)
|
|
99
|
+
logger.info("[hf] Created: https://huggingface.co/datasets/%s", repo)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
# ── Upload ────────────────────────────────────────────────────────────────────
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def check_folder_size(samples_dir: str, suggest_fix: bool = True) -> list[tuple[str, int]]:
|
|
106
|
+
"""Find directories under ``samples_dir`` that exceed the HF per-directory file limit.
|
|
107
|
+
|
|
108
|
+
Args:
|
|
109
|
+
samples_dir: Session directory to check, recursively.
|
|
110
|
+
suggest_fix: Log the command that repairs the layout. Set False when the caller is
|
|
111
|
+
about to run it anyway (``upload --with-restructure``), so the output does not
|
|
112
|
+
tell the user to do by hand what is happening on the next line.
|
|
113
|
+
|
|
114
|
+
Returns:
|
|
115
|
+
A list of ``(directory, file_count)`` pairs; empty if the layout is fine.
|
|
116
|
+
"""
|
|
117
|
+
oversized = [
|
|
118
|
+
(dirpath, len(filenames))
|
|
119
|
+
for dirpath, _, filenames in os.walk(samples_dir)
|
|
120
|
+
if len(filenames) > FILE_LIMIT
|
|
121
|
+
]
|
|
122
|
+
if not oversized:
|
|
123
|
+
return []
|
|
124
|
+
|
|
125
|
+
logger.error("[precheck] The following directories exceed %d files:", FILE_LIMIT)
|
|
126
|
+
for d, n in oversized:
|
|
127
|
+
logger.error(" %s (%d files)", d, n)
|
|
128
|
+
if suggest_fix:
|
|
129
|
+
logger.error(
|
|
130
|
+
"[precheck] HuggingFace Hub enforces a per-directory file limit.\n"
|
|
131
|
+
" Restructure the folder first, then re-run the upload,\n"
|
|
132
|
+
" or pass --with-restructure to do both in one step:\n\n"
|
|
133
|
+
" oopsie-data restructure --source %s\n",
|
|
134
|
+
samples_dir,
|
|
135
|
+
)
|
|
136
|
+
return oversized
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def upload_dataset(api, repo: str, samples_dir: str) -> None:
|
|
140
|
+
logger.info("[upload] Uploading %s → %s", samples_dir, repo)
|
|
141
|
+
logger.info("[upload] Files to upload:")
|
|
142
|
+
total_bytes = 0
|
|
143
|
+
for root, _, files in os.walk(samples_dir):
|
|
144
|
+
for f in files:
|
|
145
|
+
fpath = os.path.join(root, f)
|
|
146
|
+
size = os.path.getsize(fpath)
|
|
147
|
+
rel = os.path.relpath(fpath, samples_dir)
|
|
148
|
+
total_bytes += size
|
|
149
|
+
logger.info(" %s (%.1f MB)", rel, size / 1e6)
|
|
150
|
+
|
|
151
|
+
logger.info("[upload] Total size: %.2f GB", total_bytes / 1e9)
|
|
152
|
+
logger.info("[upload] Uploading (this may take several minutes)...")
|
|
153
|
+
|
|
154
|
+
api.upload_large_folder(
|
|
155
|
+
folder_path=samples_dir,
|
|
156
|
+
repo_id=repo,
|
|
157
|
+
repo_type="dataset",
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
logger.info("[upload] Done!")
|
|
161
|
+
logger.info("[upload] Dataset URL: https://huggingface.co/datasets/%s", repo)
|
|
162
|
+
|
|
163
|
+
# Post-upload confirmation: read the repo back so the user sees their data landed.
|
|
164
|
+
try:
|
|
165
|
+
remote_h5 = [
|
|
166
|
+
f for f in api.list_repo_files(repo_id=repo, repo_type="dataset")
|
|
167
|
+
if f.endswith(".h5")
|
|
168
|
+
]
|
|
169
|
+
local_h5 = sum(
|
|
170
|
+
1 for _r, _d, files in os.walk(samples_dir) for fn in files if fn.endswith(".h5")
|
|
171
|
+
)
|
|
172
|
+
logger.info(
|
|
173
|
+
"[upload] Confirmed: %d episode(s) now in the repo (from %d local .h5).",
|
|
174
|
+
len(remote_h5), local_h5,
|
|
175
|
+
)
|
|
176
|
+
if local_h5 and len(remote_h5) < local_h5:
|
|
177
|
+
logger.warning(
|
|
178
|
+
"[upload] Remote episode count (%d) is below local (%d) — "
|
|
179
|
+
"re-run the upload if this is unexpected.",
|
|
180
|
+
len(remote_h5), local_h5,
|
|
181
|
+
)
|
|
182
|
+
except Exception as e:
|
|
183
|
+
logger.warning("[upload] Could not confirm upload via repo listing: %s", e)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
# ── Submissions query ─────────────────────────────────────────────────────────
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def query_submissions(lab_id: str | None = None) -> int:
|
|
190
|
+
"""Report what has landed in ``OopsieData-Submissions/<lab_id>``, downloading nothing.
|
|
191
|
+
|
|
192
|
+
Args:
|
|
193
|
+
lab_id: Lab to query. Defaults to the ``lab_id`` in the contributor config.
|
|
194
|
+
|
|
195
|
+
Returns:
|
|
196
|
+
A shell-style exit code. A repo that does not exist yet is not an error — it is
|
|
197
|
+
created on the first successful upload — so that case still returns 0.
|
|
198
|
+
"""
|
|
199
|
+
from huggingface_hub import HfApi
|
|
200
|
+
|
|
201
|
+
if lab_id:
|
|
202
|
+
# An explicit --lab-id must not require a filled-in contributor config, but the
|
|
203
|
+
# token still comes from it unless $HF_TOKEN overrides.
|
|
204
|
+
try:
|
|
205
|
+
_, config_token = read_contributor_config()
|
|
206
|
+
except RuntimeError:
|
|
207
|
+
config_token = ""
|
|
208
|
+
else:
|
|
209
|
+
lab_id, config_token = read_contributor_config()
|
|
210
|
+
|
|
211
|
+
hf_token = os.environ.get("HF_TOKEN", "").strip() or config_token
|
|
212
|
+
repo = f"OopsieData-Submissions/{lab_id}"
|
|
213
|
+
api = HfApi(token=hf_token or None)
|
|
214
|
+
|
|
215
|
+
try:
|
|
216
|
+
api.repo_info(repo_id=repo, repo_type="dataset")
|
|
217
|
+
except Exception:
|
|
218
|
+
logger.info("No submissions repo found yet at https://huggingface.co/datasets/%s", repo)
|
|
219
|
+
logger.info("(It is created automatically on your first successful upload.)")
|
|
220
|
+
return 0
|
|
221
|
+
|
|
222
|
+
files = api.list_repo_files(repo_id=repo, repo_type="dataset")
|
|
223
|
+
h5 = [f for f in files if f.endswith(".h5") or f.endswith(".hdf5")]
|
|
224
|
+
mp4 = [f for f in files if f.endswith(".mp4")]
|
|
225
|
+
by_dir = Counter(f.split("/")[0] if "/" in f else "(root)" for f in h5)
|
|
226
|
+
|
|
227
|
+
logger.info("Repo: https://huggingface.co/datasets/%s", repo)
|
|
228
|
+
logger.info("Episodes (.h5): %d", len(h5))
|
|
229
|
+
logger.info("Videos (.mp4): %d", len(mp4))
|
|
230
|
+
logger.info("Total files: %d", len(files))
|
|
231
|
+
if by_dir:
|
|
232
|
+
logger.info("Episodes by top-level folder:")
|
|
233
|
+
for name, count in sorted(by_dir.items()):
|
|
234
|
+
logger.info(" %-32s %d", name, count)
|
|
235
|
+
return 0
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Attach a log file to a named logger, without stacking duplicate handlers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def setup_logger(name: str, log_file: str | os.PathLike) -> logging.Logger:
|
|
10
|
+
"""Add a file handler for ``log_file`` to the named logger, once.
|
|
11
|
+
|
|
12
|
+
Pass ``__name__`` of the calling module so the logger stays within the
|
|
13
|
+
``oopsie_data_tools`` hierarchy and does not affect other packages.
|
|
14
|
+
|
|
15
|
+
Calling this repeatedly for the same file is safe — validation calls it once per
|
|
16
|
+
episode. Calling it for a *different* file adds a second handler rather than being
|
|
17
|
+
ignored: the guard used to be "does this logger have any handler at all", so the second
|
|
18
|
+
``--log-path`` in a process silently wrote nothing.
|
|
19
|
+
"""
|
|
20
|
+
logger = logging.getLogger(name)
|
|
21
|
+
logger.setLevel(logging.INFO)
|
|
22
|
+
|
|
23
|
+
target = os.path.abspath(os.fspath(log_file))
|
|
24
|
+
for handler in logger.handlers:
|
|
25
|
+
if isinstance(handler, logging.FileHandler) and (
|
|
26
|
+
os.path.abspath(handler.baseFilename) == target
|
|
27
|
+
):
|
|
28
|
+
return logger
|
|
29
|
+
|
|
30
|
+
handler = logging.FileHandler(target)
|
|
31
|
+
handler.setFormatter(
|
|
32
|
+
logging.Formatter("%(asctime)s %(levelname)s %(name)s — %(message)s")
|
|
33
|
+
)
|
|
34
|
+
logger.addHandler(handler)
|
|
35
|
+
return logger
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
"""Offline migration of annotation attrs from taxonomy v1 to v2.
|
|
2
|
+
|
|
3
|
+
Deliberately **not** wired into the ``oopsie-data`` CLI. Readers upcast v1 files on the fly
|
|
4
|
+
(see :func:`~oopsie_data_tools.annotation_tool.annotation_schema.read_annotation_attrs`), so
|
|
5
|
+
nobody *needs* to run this — it exists for the one-off case of normalizing a directory in
|
|
6
|
+
place, and making it a supported command would imply an ongoing guarantee we do not want.
|
|
7
|
+
|
|
8
|
+
Run it ad hoc::
|
|
9
|
+
|
|
10
|
+
python -m oopsie_data_tools.utils.migrate_taxonomy_v2 <path> # dry run
|
|
11
|
+
python -m oopsie_data_tools.utils.migrate_taxonomy_v2 <path> --apply
|
|
12
|
+
|
|
13
|
+
The mapping tables live in ``annotation_schema`` rather than here, so this and the read-time
|
|
14
|
+
upcast cannot disagree about what a v1 value means.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import argparse
|
|
20
|
+
import json
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Any, Mapping
|
|
23
|
+
|
|
24
|
+
import h5py
|
|
25
|
+
|
|
26
|
+
from oopsie_data_tools.annotation_tool.annotation_schema import (
|
|
27
|
+
ANNOTATION_SCHEMA_V2,
|
|
28
|
+
OUTCOME_SUCCESS,
|
|
29
|
+
SEVERITIES,
|
|
30
|
+
SIDE_EFFECT_CATEGORIES,
|
|
31
|
+
TAXONOMY_SCHEMA_V2,
|
|
32
|
+
V1_SUCCESS_CATEGORY_TO_OUTCOME,
|
|
33
|
+
as_value_list,
|
|
34
|
+
decode_attr,
|
|
35
|
+
normalize_category,
|
|
36
|
+
normalize_severity,
|
|
37
|
+
parse_taxonomy,
|
|
38
|
+
success_to_outcome,
|
|
39
|
+
)
|
|
40
|
+
from oopsie_data_tools.utils.h5 import find_episode_files
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def unmapped_values(attrs: Mapping[str, Any]) -> list:
|
|
44
|
+
"""v1 category/severity values with no slug, reported so an operator can look at them.
|
|
45
|
+
|
|
46
|
+
These are preserved verbatim rather than folded into ``other``: they are real human
|
|
47
|
+
labels, and relabelling one on a guess is not something a later run could undo.
|
|
48
|
+
"""
|
|
49
|
+
taxonomy = parse_taxonomy(attrs.get("taxonomy", ""))
|
|
50
|
+
out = []
|
|
51
|
+
raw_categories = taxonomy.get("side_effect_category", taxonomy.get("failure_category"))
|
|
52
|
+
for value in as_value_list(raw_categories):
|
|
53
|
+
if normalize_category(value) not in SIDE_EFFECT_CATEGORIES:
|
|
54
|
+
out.append(decode_attr(value))
|
|
55
|
+
severity = taxonomy.get("severity")
|
|
56
|
+
if severity is not None and decode_attr(severity):
|
|
57
|
+
if normalize_severity(severity) not in SEVERITIES:
|
|
58
|
+
out.append(decode_attr(severity))
|
|
59
|
+
return out
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def is_already_v2(attrs: Mapping[str, Any]) -> bool:
|
|
63
|
+
"""Whether this subgroup is already v2, by schema string or by a usable outcome."""
|
|
64
|
+
if decode_attr(attrs.get("schema")) == ANNOTATION_SCHEMA_V2:
|
|
65
|
+
return True
|
|
66
|
+
outcome = decode_attr(parse_taxonomy(attrs.get("taxonomy", "")).get("outcome")).lower()
|
|
67
|
+
return outcome in OUTCOME_SUCCESS
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def migrate_annotation_attrs(attrs: Mapping[str, Any]) -> "dict | None":
|
|
71
|
+
"""v1 attr set -> the v2 attr set, or ``None`` when there is nothing to do.
|
|
72
|
+
|
|
73
|
+
``None`` covers both "already v2" and "not actually an annotation", which keeps the
|
|
74
|
+
migration idempotent: a second run rewrites nothing.
|
|
75
|
+
|
|
76
|
+
Pure — takes and returns plain mappings — so the mapping can be tested without HDF5.
|
|
77
|
+
"""
|
|
78
|
+
if is_already_v2(attrs):
|
|
79
|
+
return None
|
|
80
|
+
# A subgroup with neither a success score nor a taxonomy is not an annotation. Inventing
|
|
81
|
+
# a success for it would turn a file that fails strict validation into one that passes.
|
|
82
|
+
if attrs.get("success") is None and not decode_attr(attrs.get("taxonomy", "")):
|
|
83
|
+
return None
|
|
84
|
+
|
|
85
|
+
taxonomy = parse_taxonomy(attrs.get("taxonomy", ""))
|
|
86
|
+
|
|
87
|
+
outcome = success_to_outcome(attrs.get("success"))
|
|
88
|
+
if outcome is None:
|
|
89
|
+
return None
|
|
90
|
+
legacy_category = decode_attr(taxonomy.get("success_category")).lower()
|
|
91
|
+
if outcome == "success" and legacy_category:
|
|
92
|
+
outcome = V1_SUCCESS_CATEGORY_TO_OUTCOME.get(legacy_category, outcome)
|
|
93
|
+
|
|
94
|
+
raw_categories = taxonomy.get("side_effect_category", taxonomy.get("failure_category"))
|
|
95
|
+
|
|
96
|
+
out = dict(attrs)
|
|
97
|
+
out["schema"] = ANNOTATION_SCHEMA_V2
|
|
98
|
+
out["taxonomy_schema"] = TAXONOMY_SCHEMA_V2
|
|
99
|
+
# The v1 spelling is dropped rather than left alongside the new one, so no file ever
|
|
100
|
+
# carries both and readers never have to pick.
|
|
101
|
+
out.pop("failure_description", None)
|
|
102
|
+
out["episode_description"] = decode_attr(attrs.get("failure_description", ""))
|
|
103
|
+
out["taxonomy"] = json.dumps(
|
|
104
|
+
{
|
|
105
|
+
"outcome": outcome,
|
|
106
|
+
"side_effect_category": [
|
|
107
|
+
normalize_category(c) for c in as_value_list(raw_categories)
|
|
108
|
+
],
|
|
109
|
+
"severity": normalize_severity(taxonomy.get("severity", "")),
|
|
110
|
+
},
|
|
111
|
+
ensure_ascii=False,
|
|
112
|
+
)
|
|
113
|
+
return out
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def migrate_h5_file(h5_path: "str | Path", *, apply: bool = True) -> dict:
|
|
117
|
+
"""Migrate every annotator subgroup in one episode file.
|
|
118
|
+
|
|
119
|
+
Returns a report: ``{"path", "migrated": [...], "skipped": {annotator: reason},
|
|
120
|
+
"unmapped": {annotator: [...]}, "error": str | None}``.
|
|
121
|
+
"""
|
|
122
|
+
path = Path(h5_path)
|
|
123
|
+
report: dict = {
|
|
124
|
+
"path": str(path),
|
|
125
|
+
"migrated": [],
|
|
126
|
+
"skipped": {},
|
|
127
|
+
"unmapped": {},
|
|
128
|
+
"error": None,
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
try:
|
|
132
|
+
# Read-only first, so a dry run — and a file with nothing to migrate — never opens
|
|
133
|
+
# for write and never touches mtime.
|
|
134
|
+
with h5py.File(path, "r") as f:
|
|
135
|
+
ea = f.get("episode_annotations")
|
|
136
|
+
if not isinstance(ea, h5py.Group):
|
|
137
|
+
report["skipped"]["*"] = "no episode_annotations group"
|
|
138
|
+
return report
|
|
139
|
+
planned = {}
|
|
140
|
+
for name in ea.keys():
|
|
141
|
+
sub = ea[name]
|
|
142
|
+
if not isinstance(sub, h5py.Group):
|
|
143
|
+
report["skipped"][name] = "not a group"
|
|
144
|
+
continue
|
|
145
|
+
attrs = dict(sub.attrs)
|
|
146
|
+
new_attrs = migrate_annotation_attrs(attrs)
|
|
147
|
+
if new_attrs is None:
|
|
148
|
+
report["skipped"][name] = (
|
|
149
|
+
"already v2" if is_already_v2(attrs) else "not an annotation"
|
|
150
|
+
)
|
|
151
|
+
continue
|
|
152
|
+
leftover = unmapped_values(attrs)
|
|
153
|
+
if leftover:
|
|
154
|
+
report["unmapped"][name] = leftover
|
|
155
|
+
planned[name] = (attrs, new_attrs)
|
|
156
|
+
|
|
157
|
+
if not planned or not apply:
|
|
158
|
+
report["migrated"] = sorted(planned)
|
|
159
|
+
return report
|
|
160
|
+
|
|
161
|
+
with h5py.File(path, "r+") as f:
|
|
162
|
+
ea = f["episode_annotations"]
|
|
163
|
+
for name, (old_attrs, new_attrs) in planned.items():
|
|
164
|
+
sub = ea[name]
|
|
165
|
+
for key in set(old_attrs) - set(new_attrs):
|
|
166
|
+
del sub.attrs[key]
|
|
167
|
+
for key, value in new_attrs.items():
|
|
168
|
+
sub.attrs[key] = value
|
|
169
|
+
report["migrated"] = sorted(planned)
|
|
170
|
+
except Exception as e: # noqa: BLE001 - one bad file must not stop the walk
|
|
171
|
+
report["error"] = f"{type(e).__name__}: {e}"
|
|
172
|
+
|
|
173
|
+
return report
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def migrate_tree(root: "str | Path", *, apply: bool = True) -> list:
|
|
177
|
+
"""Migrate every episode file under *root*; one report per file."""
|
|
178
|
+
return [migrate_h5_file(p, apply=apply) for p in find_episode_files(Path(root))]
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def main() -> None:
|
|
182
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
183
|
+
parser.add_argument("path", type=Path, help="episode .h5 file or a directory of them")
|
|
184
|
+
parser.add_argument(
|
|
185
|
+
"--apply",
|
|
186
|
+
action="store_true",
|
|
187
|
+
help="actually write the changes (without this it is a dry run)",
|
|
188
|
+
)
|
|
189
|
+
args = parser.parse_args()
|
|
190
|
+
|
|
191
|
+
if args.path.is_dir():
|
|
192
|
+
reports = migrate_tree(args.path, apply=args.apply)
|
|
193
|
+
else:
|
|
194
|
+
reports = [migrate_h5_file(args.path, apply=args.apply)]
|
|
195
|
+
|
|
196
|
+
migrated = 0
|
|
197
|
+
for report in reports:
|
|
198
|
+
if report["error"]:
|
|
199
|
+
print(f"ERROR {report['path']}: {report['error']}")
|
|
200
|
+
continue
|
|
201
|
+
if report["migrated"]:
|
|
202
|
+
migrated += 1
|
|
203
|
+
print(f"{'migrated' if args.apply else 'would migrate'} {report['path']}: "
|
|
204
|
+
f"{', '.join(report['migrated'])}")
|
|
205
|
+
for name, values in report["unmapped"].items():
|
|
206
|
+
print(f" unmapped values kept verbatim in {name}: {values}")
|
|
207
|
+
|
|
208
|
+
verb = "Migrated" if args.apply else "Would migrate"
|
|
209
|
+
print(f"\n{verb} {migrated} of {len(reports)} file(s).")
|
|
210
|
+
if not args.apply:
|
|
211
|
+
print("Dry run — re-run with --apply to write.")
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
if __name__ == "__main__":
|
|
215
|
+
main()
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""Resolve where oopsie configs live, so the package works when pip-installed.
|
|
2
|
+
|
|
3
|
+
The two kinds of config are looked up separately, because they belong to different things.
|
|
4
|
+
|
|
5
|
+
**Credentials** (``contributor_config.yaml``) belong to the person, so they are stored once
|
|
6
|
+
per user and shared by every project:
|
|
7
|
+
|
|
8
|
+
1. ``$OOPSIE_CONFIG_DIR`` — explicit override
|
|
9
|
+
2. ``$XDG_CONFIG_HOME/oopsie-data`` (default ``~/.config/oopsie-data``)
|
|
10
|
+
3. the repo's ``configs/`` directory, when running from a source checkout
|
|
11
|
+
|
|
12
|
+
**Robot profiles** belong to the robot code that uses them, so they are looked up next to
|
|
13
|
+
that code and never in the user config directory:
|
|
14
|
+
|
|
15
|
+
1. ``$OOPSIE_ROBOT_PROFILES_DIR`` — explicit override
|
|
16
|
+
2. ``./robot_profiles`` or ``./configs/robot_profiles``, relative to the working directory
|
|
17
|
+
3. the repo's ``configs/robot_profiles``, when running from a source checkout
|
|
18
|
+
|
|
19
|
+
In each chain the first location that actually exists wins; when none do, the first
|
|
20
|
+
*writable* location is returned, which is where new files should be created. Note that
|
|
21
|
+
profiles are usually loaded by explicit path (``load_robot_profile(path)``), so this lookup
|
|
22
|
+
mainly backs the bundled example profiles.
|
|
23
|
+
|
|
24
|
+
``oopsie-data --config-dir <dir> <command>`` sets the credential override for one invocation.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import os
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
|
|
32
|
+
ENV_CONFIG_DIR = "OOPSIE_CONFIG_DIR"
|
|
33
|
+
ENV_PROFILES_DIR = "OOPSIE_ROBOT_PROFILES_DIR"
|
|
34
|
+
|
|
35
|
+
_PACKAGE_ROOT = Path(__file__).resolve().parent.parent
|
|
36
|
+
# Deliberately only the checkout's configs/. Nothing in either lookup chain resolves into
|
|
37
|
+
# an installed package: config that lives in site-packages is read-only, invisible to the
|
|
38
|
+
# user, and would silently shadow their own. Installed users get None here and fall through
|
|
39
|
+
# to a location they control. ``oopsie-data new-profile`` covers the "I have no checkout to
|
|
40
|
+
# copy a template from" case.
|
|
41
|
+
_REPO_CONFIG_DIR = _PACKAGE_ROOT.parent / "configs"
|
|
42
|
+
|
|
43
|
+
PROFILES_DIR_NAME = "robot_profiles"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _env_dir(name: str) -> Path | None:
|
|
47
|
+
value = os.environ.get(name, "").strip()
|
|
48
|
+
return Path(value).expanduser().resolve() if value else None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _unique(candidates: list[Path | None]) -> list[Path]:
|
|
52
|
+
"""Drop Nones and repeats, keeping order.
|
|
53
|
+
|
|
54
|
+
Two entries can name the same directory — running from the checkout root makes
|
|
55
|
+
``./configs/robot_profiles`` and the repo's own profile dir identical — and a chain
|
|
56
|
+
that lists it twice is confusing when printed by ``oopsie-data config``.
|
|
57
|
+
"""
|
|
58
|
+
seen = set()
|
|
59
|
+
unique = []
|
|
60
|
+
for candidate in candidates:
|
|
61
|
+
if candidate is None:
|
|
62
|
+
continue
|
|
63
|
+
key = candidate.resolve()
|
|
64
|
+
if key not in seen:
|
|
65
|
+
seen.add(key)
|
|
66
|
+
unique.append(candidate)
|
|
67
|
+
return unique
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
# ── credentials ───────────────────────────────────────────────────────────────
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def env_config_dir() -> Path | None:
|
|
74
|
+
"""``$OOPSIE_CONFIG_DIR`` if set, else None."""
|
|
75
|
+
return _env_dir(ENV_CONFIG_DIR)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def user_config_dir() -> Path:
|
|
79
|
+
"""Per-user config location, where ``init`` stores credentials."""
|
|
80
|
+
xdg = os.environ.get("XDG_CONFIG_HOME", "").strip()
|
|
81
|
+
base = Path(xdg).expanduser() if xdg else Path.home() / ".config"
|
|
82
|
+
return base / "oopsie-data"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def repo_config_dir() -> Path | None:
|
|
86
|
+
"""The checkout's ``configs/`` directory, or None when not running from source."""
|
|
87
|
+
return _REPO_CONFIG_DIR if _REPO_CONFIG_DIR.is_dir() else None
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def config_search_dirs() -> list[Path]:
|
|
91
|
+
"""Candidate directories for the contributor config, highest precedence first."""
|
|
92
|
+
return _unique([env_config_dir(), user_config_dir(), repo_config_dir()])
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def write_config_dir() -> Path:
|
|
96
|
+
"""Where a new contributor config should be written."""
|
|
97
|
+
return env_config_dir() or user_config_dir()
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def contributor_config_path() -> Path:
|
|
101
|
+
"""The contributor config that will be read, or where to create one."""
|
|
102
|
+
for candidate in config_search_dirs():
|
|
103
|
+
target = candidate / "contributor_config.yaml"
|
|
104
|
+
if target.is_file():
|
|
105
|
+
return target
|
|
106
|
+
return write_config_dir() / "contributor_config.yaml"
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def config_dir() -> Path:
|
|
110
|
+
"""Directory the contributor config resolves to."""
|
|
111
|
+
return contributor_config_path().parent
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def config_dir_source() -> str:
|
|
115
|
+
"""Human-readable reason the resolved config dir was chosen (for messages)."""
|
|
116
|
+
resolved = contributor_config_path().parent
|
|
117
|
+
if resolved == env_config_dir():
|
|
118
|
+
return f"${ENV_CONFIG_DIR}"
|
|
119
|
+
if resolved == user_config_dir():
|
|
120
|
+
return "user config dir"
|
|
121
|
+
if resolved == repo_config_dir():
|
|
122
|
+
return "repo checkout"
|
|
123
|
+
return str(resolved)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
# ── robot profiles ────────────────────────────────────────────────────────────
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def env_profiles_dir() -> Path | None:
|
|
130
|
+
"""``$OOPSIE_ROBOT_PROFILES_DIR`` if set, else None."""
|
|
131
|
+
return _env_dir(ENV_PROFILES_DIR)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def project_profiles_dirs() -> list[Path]:
|
|
135
|
+
"""Profile directories relative to the working directory, in precedence order."""
|
|
136
|
+
cwd = Path.cwd()
|
|
137
|
+
return [cwd / PROFILES_DIR_NAME, cwd / "configs" / PROFILES_DIR_NAME]
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def profiles_search_dirs() -> list[Path]:
|
|
141
|
+
"""Candidate directories for robot profiles, highest precedence first."""
|
|
142
|
+
repo_dir = repo_config_dir()
|
|
143
|
+
return _unique(
|
|
144
|
+
[
|
|
145
|
+
env_profiles_dir(),
|
|
146
|
+
*project_profiles_dirs(),
|
|
147
|
+
repo_dir / PROFILES_DIR_NAME if repo_dir is not None else None,
|
|
148
|
+
]
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def write_profiles_dir() -> Path:
|
|
153
|
+
"""Where a new robot profile should be written: the override, else project-local."""
|
|
154
|
+
return env_profiles_dir() or project_profiles_dirs()[0]
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def robot_profiles_dir() -> Path:
|
|
158
|
+
"""The robot profile directory that will be read, or where to create one."""
|
|
159
|
+
for candidate in profiles_search_dirs():
|
|
160
|
+
if candidate.is_dir():
|
|
161
|
+
return candidate
|
|
162
|
+
return write_profiles_dir()
|