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,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()