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
oopsie_data_tools/cli.py
ADDED
|
@@ -0,0 +1,894 @@
|
|
|
1
|
+
"""``oopsie-data`` command line interface.
|
|
2
|
+
|
|
3
|
+
Single entry point for the contributor workflow; every capability lives here rather than
|
|
4
|
+
in a parallel collection of scripts.
|
|
5
|
+
|
|
6
|
+
Credentials and robot profiles are looked up through separate chains (see
|
|
7
|
+
:mod:`oopsie_data_tools.utils.paths`); ``--config-dir`` overrides the credential location
|
|
8
|
+
for one invocation, and ``oopsie-data show-config`` prints what is in effect right now.
|
|
9
|
+
|
|
10
|
+
Note that this docstring is *not* the ``--help`` epilog: help text is user-facing and lives
|
|
11
|
+
in :data:`_EPILOG` and the per-command ``description``/``epilog`` strings, so developer
|
|
12
|
+
notes and reStructuredText markup cannot leak into a terminal.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import argparse
|
|
18
|
+
import difflib
|
|
19
|
+
import logging
|
|
20
|
+
import os
|
|
21
|
+
import re
|
|
22
|
+
import sys
|
|
23
|
+
import textwrap
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
from oopsie_data_tools.utils.hf_limits import BATCH_SIZE, FILE_LIMIT
|
|
27
|
+
from oopsie_data_tools.utils.paths import ENV_CONFIG_DIR
|
|
28
|
+
|
|
29
|
+
logger = logging.getLogger(__name__)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _version() -> str:
|
|
33
|
+
"""Installed package version, for ``--version``.
|
|
34
|
+
|
|
35
|
+
A source tree that was never installed has no distribution metadata, and reporting the
|
|
36
|
+
version is never worth failing an invocation over.
|
|
37
|
+
"""
|
|
38
|
+
try:
|
|
39
|
+
from importlib.metadata import version
|
|
40
|
+
|
|
41
|
+
return version("oopsie-data-tools")
|
|
42
|
+
except Exception:
|
|
43
|
+
return "unknown (not installed)"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# ── show-config ───────────────────────────────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _describe_credential_dir(candidate: Path) -> str:
|
|
50
|
+
if (candidate / "contributor_config.yaml").is_file():
|
|
51
|
+
return "has contributor_config.yaml"
|
|
52
|
+
if candidate.is_dir():
|
|
53
|
+
return "exists, no contributor_config.yaml"
|
|
54
|
+
return "does not exist"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _describe_profiles_dir(candidate: Path) -> str:
|
|
58
|
+
if not candidate.is_dir():
|
|
59
|
+
return "does not exist"
|
|
60
|
+
count = len(list(candidate.glob("*.yaml")))
|
|
61
|
+
if not count:
|
|
62
|
+
return "exists, no profiles"
|
|
63
|
+
return f"{count} profile{'' if count == 1 else 's'}"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _print_chain(candidates: list[Path], active: Path | None, describe) -> None:
|
|
67
|
+
"""Print a lookup chain as two aligned columns, marking the entry that wins."""
|
|
68
|
+
print(" Searched in order:")
|
|
69
|
+
width = max(len(str(candidate)) for candidate in candidates)
|
|
70
|
+
for candidate in candidates:
|
|
71
|
+
marker = "->" if candidate == active else " "
|
|
72
|
+
print(f" {marker} {str(candidate).ljust(width)} {describe(candidate)}")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
_FIELD_INDENT = 14 # two leading spaces + a 12-wide label column
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _print_field(label: str, value: str) -> None:
|
|
79
|
+
print(f" {(label + ':').ljust(_FIELD_INDENT - 2)}{value}")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _print_wrapped_field(label: str, value: str) -> None:
|
|
83
|
+
"""A field whose value may be long (a list of file names), wrapped under the label."""
|
|
84
|
+
lines = textwrap.wrap(value, width=88, subsequent_indent=" " * _FIELD_INDENT)
|
|
85
|
+
_print_field(label, lines[0] if lines else "")
|
|
86
|
+
for line in lines[1:]:
|
|
87
|
+
print(line)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def cmd_show_config(args: argparse.Namespace) -> int:
|
|
91
|
+
"""Show where every config is read from right now, and what is in it. Read-only."""
|
|
92
|
+
import yaml
|
|
93
|
+
|
|
94
|
+
from oopsie_data_tools.init_wizard import _mask
|
|
95
|
+
from oopsie_data_tools.utils import paths
|
|
96
|
+
|
|
97
|
+
config_path = paths.contributor_config_path()
|
|
98
|
+
profiles_dir = paths.robot_profiles_dir()
|
|
99
|
+
|
|
100
|
+
print("\nCredentials — contributor_config.yaml")
|
|
101
|
+
print(f" ${paths.ENV_CONFIG_DIR} = {os.environ.get(paths.ENV_CONFIG_DIR) or '(not set)'}\n")
|
|
102
|
+
_print_chain(
|
|
103
|
+
paths.config_search_dirs(),
|
|
104
|
+
config_path.parent if config_path.is_file() else None,
|
|
105
|
+
_describe_credential_dir,
|
|
106
|
+
)
|
|
107
|
+
print()
|
|
108
|
+
if config_path.is_file():
|
|
109
|
+
_print_field("Reading", str(config_path))
|
|
110
|
+
else:
|
|
111
|
+
print(f" No config found. 'oopsie-data init' would write {config_path}")
|
|
112
|
+
|
|
113
|
+
# Read the YAML directly: read_contributor_config raises on a missing or placeholder
|
|
114
|
+
# lab_id, and this command must still report what it found.
|
|
115
|
+
data = {}
|
|
116
|
+
if config_path.is_file():
|
|
117
|
+
try:
|
|
118
|
+
loaded = yaml.safe_load(config_path.read_text(encoding="utf-8"))
|
|
119
|
+
data = loaded if isinstance(loaded, dict) else {}
|
|
120
|
+
except yaml.YAMLError as e:
|
|
121
|
+
print(f" Could not parse the file: {e}")
|
|
122
|
+
|
|
123
|
+
lab_id = str(data.get("lab_id") or "").strip()
|
|
124
|
+
config_token = str(data.get("huggingface_token") or "").strip()
|
|
125
|
+
env_token = os.environ.get("HF_TOKEN", "").strip()
|
|
126
|
+
token = env_token or config_token
|
|
127
|
+
|
|
128
|
+
_print_field("lab_id", lab_id or "(not set)")
|
|
129
|
+
if token:
|
|
130
|
+
shown = token if args.show_token else _mask(token)
|
|
131
|
+
source = "$HF_TOKEN, overrides the config" if env_token else "contributor_config.yaml"
|
|
132
|
+
_print_field("hf_token", f"{shown} (from {source})")
|
|
133
|
+
else:
|
|
134
|
+
_print_field("hf_token", "(not set — set one in the config or export $HF_TOKEN)")
|
|
135
|
+
if lab_id:
|
|
136
|
+
_print_field("uploads to", f"OopsieData-Submissions/{lab_id}")
|
|
137
|
+
|
|
138
|
+
print("\nRobot profiles")
|
|
139
|
+
print(
|
|
140
|
+
f" ${paths.ENV_PROFILES_DIR} = "
|
|
141
|
+
f"{os.environ.get(paths.ENV_PROFILES_DIR) or '(not set)'}\n"
|
|
142
|
+
)
|
|
143
|
+
_print_chain(
|
|
144
|
+
paths.profiles_search_dirs(),
|
|
145
|
+
profiles_dir if profiles_dir.is_dir() else None,
|
|
146
|
+
_describe_profiles_dir,
|
|
147
|
+
)
|
|
148
|
+
print()
|
|
149
|
+
if profiles_dir.is_dir():
|
|
150
|
+
_print_field("Reading", str(profiles_dir))
|
|
151
|
+
names = sorted(profile.name for profile in profiles_dir.glob("*.yaml"))
|
|
152
|
+
if names:
|
|
153
|
+
_print_wrapped_field("profiles", ", ".join(names))
|
|
154
|
+
else:
|
|
155
|
+
print(f" No profile directory found. A new one belongs in {paths.write_profiles_dir()}")
|
|
156
|
+
print(
|
|
157
|
+
"\n Profiles are usually loaded by explicit path — load_robot_profile(<path>) —\n"
|
|
158
|
+
" which ignores the lookup above. Paths here are relative to the current directory."
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
if not args.show_token and token:
|
|
162
|
+
print("\nRe-run with --show-token to print the token in full.")
|
|
163
|
+
return 0
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
# ── init ──────────────────────────────────────────────────────────────────────
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def cmd_init(args: argparse.Namespace) -> int:
|
|
170
|
+
from oopsie_data_tools.init_wizard import run_init
|
|
171
|
+
|
|
172
|
+
return run_init(
|
|
173
|
+
# --config-dir only lives for this process, so init should offer to persist it.
|
|
174
|
+
config_dir_from_flag=args.config_dir is not None,
|
|
175
|
+
lab_id=args.lab_id,
|
|
176
|
+
hf_token=args.hf_token,
|
|
177
|
+
verify_token=not args.no_verify_token,
|
|
178
|
+
force=args.force,
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
# ── new-profile ───────────────────────────────────────────────────────────────
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def cmd_new_profile(args: argparse.Namespace) -> int:
|
|
186
|
+
"""Write a starter robot profile next to the user's robot code."""
|
|
187
|
+
from oopsie_data_tools.utils import paths
|
|
188
|
+
from oopsie_data_tools.utils.robot_profile.template import PROFILE_TEMPLATE
|
|
189
|
+
|
|
190
|
+
target_dir = Path(args.dir) if args.dir is not None else paths.write_profiles_dir()
|
|
191
|
+
target = target_dir / f"{args.name}.yaml"
|
|
192
|
+
|
|
193
|
+
if target.exists() and not args.force:
|
|
194
|
+
logger.error("%s already exists. Pass --force to overwrite it.", target)
|
|
195
|
+
return 1
|
|
196
|
+
|
|
197
|
+
target_dir.mkdir(parents=True, exist_ok=True)
|
|
198
|
+
target.write_text(PROFILE_TEMPLATE, encoding="utf-8")
|
|
199
|
+
|
|
200
|
+
logger.info("Wrote %s", target)
|
|
201
|
+
logger.info(
|
|
202
|
+
"\nEvery required field is blank, so the profile will not load until you fill it in —\n"
|
|
203
|
+
"that is deliberate, so a half-edited profile cannot stamp placeholder metadata into\n"
|
|
204
|
+
"your recorded episodes. Check your work with:\n\n"
|
|
205
|
+
" python -c \"from oopsie_data_tools.utils.robot_profile.robot_profile import "
|
|
206
|
+
'load_robot_profile; load_robot_profile(\'%s\')"',
|
|
207
|
+
target,
|
|
208
|
+
)
|
|
209
|
+
return 0
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
# ── annotate ──────────────────────────────────────────────────────────────────
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _prompt_annotator_name() -> str | None:
|
|
216
|
+
"""Ask for the annotator name interactively; None if unavailable or left blank."""
|
|
217
|
+
if not sys.stdin.isatty():
|
|
218
|
+
logger.error("--annotator-name is required when stdin is not a terminal.")
|
|
219
|
+
return None
|
|
220
|
+
for _ in range(3):
|
|
221
|
+
name = input("Annotator name (stamped into saved annotations): ").strip()
|
|
222
|
+
if name:
|
|
223
|
+
return name
|
|
224
|
+
print("Please enter a non-empty name.")
|
|
225
|
+
logger.error("No annotator name given.")
|
|
226
|
+
return None
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def cmd_annotate(args: argparse.Namespace) -> int:
|
|
230
|
+
from oopsie_data_tools.annotation_tool.annotator_server import run_server
|
|
231
|
+
|
|
232
|
+
annotator_name = (args.annotator_name or "").strip() or _prompt_annotator_name()
|
|
233
|
+
if not annotator_name:
|
|
234
|
+
return 1
|
|
235
|
+
|
|
236
|
+
return run_server(
|
|
237
|
+
samples_dir=args.samples_dir,
|
|
238
|
+
annotator_name=annotator_name,
|
|
239
|
+
port=args.port,
|
|
240
|
+
open_browser=not args.no_browser,
|
|
241
|
+
with_rollouts=args.with_rollouts,
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
# ── validate ──────────────────────────────────────────────────────────────────
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def cmd_validate(args: argparse.Namespace) -> int:
|
|
249
|
+
from oopsie_data_tools.utils.hf_upload import run_validation
|
|
250
|
+
|
|
251
|
+
target = os.path.abspath(os.path.normpath(args.path))
|
|
252
|
+
if not os.path.exists(target):
|
|
253
|
+
logger.error("Path does not exist: %s", target)
|
|
254
|
+
return 1
|
|
255
|
+
return run_validation(target, args.episode_id, args.log_path)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
# ── upload ────────────────────────────────────────────────────────────────────
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _restructure_for_upload(samples_dir: str) -> str | None:
|
|
262
|
+
"""Split an oversized session so it can be uploaded. Returns the copy, or None.
|
|
263
|
+
|
|
264
|
+
Passing --with-restructure is the consent the standalone command prompts for, so this
|
|
265
|
+
does not ask again — but ``run_restructure`` still logs the size estimate first, and
|
|
266
|
+
the source is left untouched either way.
|
|
267
|
+
"""
|
|
268
|
+
from oopsie_data_tools.utils.hf_upload import check_folder_size
|
|
269
|
+
from oopsie_data_tools.utils.restructure import run_restructure
|
|
270
|
+
|
|
271
|
+
source = Path(samples_dir)
|
|
272
|
+
output = source.parent / f"{source.name}_restructured"
|
|
273
|
+
logger.info(
|
|
274
|
+
"[restructure] --with-restructure is set. Writing a restructured copy to\n"
|
|
275
|
+
" %s\n"
|
|
276
|
+
" The original is not modified. Run 'oopsie-data restructure' "
|
|
277
|
+
"yourself to choose a different destination.",
|
|
278
|
+
output,
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
if run_restructure(source, output, assume_yes=True) != 0:
|
|
282
|
+
logger.error("Aborting: the session could not be restructured.")
|
|
283
|
+
return None
|
|
284
|
+
|
|
285
|
+
# Confirm the layout is actually fixed rather than assuming it. A directory holding no
|
|
286
|
+
# episodes cannot be split by episode, so it survives the copy still oversized — and
|
|
287
|
+
# uploading it would fail at the Hub for the reason we just tried to fix.
|
|
288
|
+
if check_folder_size(str(output), suggest_fix=False):
|
|
289
|
+
logger.error(
|
|
290
|
+
"Aborting: %s is still over the file limit after restructuring. "
|
|
291
|
+
"Reorganise the directories listed above by hand.",
|
|
292
|
+
output,
|
|
293
|
+
)
|
|
294
|
+
return None
|
|
295
|
+
|
|
296
|
+
logger.info("[restructure] Continuing with %s", output)
|
|
297
|
+
return str(output)
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def cmd_upload(args: argparse.Namespace) -> int:
|
|
301
|
+
from oopsie_data_tools.utils.hf_upload import (
|
|
302
|
+
check_folder_size,
|
|
303
|
+
ensure_repo,
|
|
304
|
+
hf_login,
|
|
305
|
+
resolve_hf_target,
|
|
306
|
+
run_validation,
|
|
307
|
+
upload_dataset,
|
|
308
|
+
)
|
|
309
|
+
from oopsie_data_tools.utils.validation.diversity import check_diversity
|
|
310
|
+
|
|
311
|
+
logger.info("=" * 60)
|
|
312
|
+
logger.info(" Robotic Failure Dataset — End-to-End Upload Pipeline")
|
|
313
|
+
logger.info("=" * 60)
|
|
314
|
+
|
|
315
|
+
samples_dir = os.path.abspath(os.path.normpath(args.path))
|
|
316
|
+
if not os.path.isdir(samples_dir):
|
|
317
|
+
logger.error("Not a directory: %s", samples_dir)
|
|
318
|
+
return 1
|
|
319
|
+
|
|
320
|
+
# Pre-upload folder-size check (HF enforces a per-directory file limit). Only relevant
|
|
321
|
+
# when something is actually going to be uploaded — with --skip-upload this is a
|
|
322
|
+
# validation run, and a layout HF would reject is not a reason to fail it. Asking for
|
|
323
|
+
# --with-restructure is asking for the layout to be fixed, so it runs either way.
|
|
324
|
+
if not args.skip_upload or args.with_restructure:
|
|
325
|
+
if check_folder_size(samples_dir, suggest_fix=not args.with_restructure):
|
|
326
|
+
if not args.with_restructure:
|
|
327
|
+
return 1
|
|
328
|
+
restructured = _restructure_for_upload(samples_dir)
|
|
329
|
+
if restructured is None:
|
|
330
|
+
return 1
|
|
331
|
+
# Everything downstream must see the copy: validation reads the rewritten
|
|
332
|
+
# video paths, and it is the copy that gets uploaded.
|
|
333
|
+
samples_dir = restructured
|
|
334
|
+
|
|
335
|
+
if not args.skip_validate:
|
|
336
|
+
if run_validation(samples_dir, args.episode_id, args.log_path) != 0:
|
|
337
|
+
logger.error(
|
|
338
|
+
"Aborting upload due to validation failure. Fix the dataset format and retry."
|
|
339
|
+
)
|
|
340
|
+
return 1
|
|
341
|
+
else:
|
|
342
|
+
logger.info("[validate] Skipped.")
|
|
343
|
+
|
|
344
|
+
# Low-diversity warning (advisory unless --strict-diversity). Reads attrs only.
|
|
345
|
+
diversity_warnings = check_diversity(samples_dir)
|
|
346
|
+
if diversity_warnings and args.strict_diversity:
|
|
347
|
+
logger.error("Aborting: low-diversity warnings present and --strict-diversity is set.")
|
|
348
|
+
return 1
|
|
349
|
+
|
|
350
|
+
if args.skip_upload:
|
|
351
|
+
logger.info("[upload] Skipped (--skip-upload).")
|
|
352
|
+
return 0
|
|
353
|
+
|
|
354
|
+
from huggingface_hub import HfApi
|
|
355
|
+
|
|
356
|
+
hf_token, hf_repo = resolve_hf_target()
|
|
357
|
+
try:
|
|
358
|
+
hf_login(hf_token)
|
|
359
|
+
except Exception as e:
|
|
360
|
+
logger.error(
|
|
361
|
+
"HuggingFace authentication failed: %s\n"
|
|
362
|
+
"Check the huggingface_token in your contributor config (or $HF_TOKEN).",
|
|
363
|
+
e,
|
|
364
|
+
)
|
|
365
|
+
return 1
|
|
366
|
+
api = HfApi(token=hf_token)
|
|
367
|
+
ensure_repo(api, hf_repo)
|
|
368
|
+
upload_dataset(api, hf_repo, samples_dir)
|
|
369
|
+
return 0
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
# ── submissions ───────────────────────────────────────────────────────────────
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def cmd_submissions(args: argparse.Namespace) -> int:
|
|
376
|
+
from oopsie_data_tools.utils.hf_upload import query_submissions
|
|
377
|
+
|
|
378
|
+
return query_submissions(args.lab_id.strip() if args.lab_id else None)
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
# ── inspect ───────────────────────────────────────────────────────────────────
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def cmd_inspect(args: argparse.Namespace) -> int:
|
|
385
|
+
from oopsie_data_tools.utils.h5_inspect import inspect_h5
|
|
386
|
+
|
|
387
|
+
if not os.path.isfile(args.path):
|
|
388
|
+
logger.error("Not a file: %s", args.path)
|
|
389
|
+
return 1
|
|
390
|
+
inspect_h5(args.path)
|
|
391
|
+
return 0
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
# ── restructure ───────────────────────────────────────────────────────────────
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def cmd_restructure(args: argparse.Namespace) -> int:
|
|
398
|
+
from oopsie_data_tools.utils.restructure import run_restructure
|
|
399
|
+
|
|
400
|
+
return run_restructure(args.source, args.output, assume_yes=args.yes)
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
# ── install-skill ─────────────────────────────────────────────────────────────
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def cmd_install_skill(args: argparse.Namespace) -> int:
|
|
407
|
+
from oopsie_data_tools.utils.claude_skill import install_skill
|
|
408
|
+
|
|
409
|
+
return install_skill(user=args.user, force=args.force)
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
# ── parser ────────────────────────────────────────────────────────────────────
|
|
413
|
+
|
|
414
|
+
#: Shown under every top-level ``--help``. argparse already lists the commands and what each
|
|
415
|
+
#: one does, so this adds only what that list cannot: the order they go in, and the two
|
|
416
|
+
#: things people get stuck on.
|
|
417
|
+
_EPILOG = """\
|
|
418
|
+
A typical session, in order:
|
|
419
|
+
|
|
420
|
+
oopsie-data init # once: lab id + HuggingFace token
|
|
421
|
+
oopsie-data new-profile # once per robot, then fill the file in
|
|
422
|
+
oopsie-data annotate --samples-dir ./samples --annotator-name "your name"
|
|
423
|
+
oopsie-data validate --path ./samples
|
|
424
|
+
oopsie-data upload --path ./samples
|
|
425
|
+
|
|
426
|
+
If upload refuses because a directory holds too many files for HuggingFace, split it with
|
|
427
|
+
'oopsie-data restructure --source ./samples', or pass --with-restructure to upload. Run
|
|
428
|
+
'oopsie-data show-config' to see where credentials and robot profiles are being read from,
|
|
429
|
+
and 'oopsie-data <command> --help' for one command's options.
|
|
430
|
+
"""
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
class _HelpFormatter(argparse.RawDescriptionHelpFormatter):
|
|
434
|
+
"""Wrap help prose to the terminal, but leave indented example blocks alone.
|
|
435
|
+
|
|
436
|
+
``RawDescriptionHelpFormatter`` would print every description as one unwrapped line,
|
|
437
|
+
and the plain formatter would reflow the example blocks into paragraphs. A paragraph
|
|
438
|
+
is treated as preformatted if any of its lines is indented.
|
|
439
|
+
"""
|
|
440
|
+
|
|
441
|
+
def _fill_text(self, text: str, width: int, indent: str) -> str:
|
|
442
|
+
chunks = []
|
|
443
|
+
for para in text.split("\n\n"):
|
|
444
|
+
if any(line[:1].isspace() for line in para.splitlines()):
|
|
445
|
+
chunks.append(textwrap.indent(para.strip("\n"), indent))
|
|
446
|
+
else:
|
|
447
|
+
chunks.append(
|
|
448
|
+
textwrap.fill(
|
|
449
|
+
" ".join(para.split()),
|
|
450
|
+
width,
|
|
451
|
+
initial_indent=indent,
|
|
452
|
+
subsequent_indent=indent,
|
|
453
|
+
)
|
|
454
|
+
)
|
|
455
|
+
return "\n\n".join(chunks)
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
class _ArgumentParser(argparse.ArgumentParser):
|
|
459
|
+
"""Replaces argparse's invalid-choice wall of text with a typo suggestion."""
|
|
460
|
+
|
|
461
|
+
def error(self, message: str): # noqa: D102 - argparse API
|
|
462
|
+
match = re.search(r"argument <command>: invalid choice: '([^']*)'", message)
|
|
463
|
+
if match:
|
|
464
|
+
given = match.group(1)
|
|
465
|
+
close = difflib.get_close_matches(given, _COMMANDS, n=1)
|
|
466
|
+
hint = (
|
|
467
|
+
f"did you mean '{close[0]}'?"
|
|
468
|
+
if close
|
|
469
|
+
else "run 'oopsie-data --help' for the list of commands."
|
|
470
|
+
)
|
|
471
|
+
message = f"unknown command '{given}' — {hint}"
|
|
472
|
+
super().error(message)
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
def _add_command(
|
|
476
|
+
sub, name: str, *, summary: str, description: str, examples: str = ""
|
|
477
|
+
) -> argparse.ArgumentParser:
|
|
478
|
+
"""Add a subcommand whose help renders consistently with every other one."""
|
|
479
|
+
return sub.add_parser(
|
|
480
|
+
name,
|
|
481
|
+
help=summary,
|
|
482
|
+
description=description,
|
|
483
|
+
epilog=f"Examples:\n\n{examples}" if examples else None,
|
|
484
|
+
formatter_class=_HelpFormatter,
|
|
485
|
+
)
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
#: Kept next to the parser so the typo suggestion and the metavar cannot drift apart.
|
|
489
|
+
_COMMANDS = [
|
|
490
|
+
"init",
|
|
491
|
+
"show-config",
|
|
492
|
+
"new-profile",
|
|
493
|
+
"annotate",
|
|
494
|
+
"validate",
|
|
495
|
+
"upload",
|
|
496
|
+
"submissions",
|
|
497
|
+
"inspect",
|
|
498
|
+
"restructure",
|
|
499
|
+
"install-skill",
|
|
500
|
+
]
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
504
|
+
parser = _ArgumentParser(
|
|
505
|
+
prog="oopsie-data",
|
|
506
|
+
description=(
|
|
507
|
+
"Record, annotate, validate and publish robotic manipulation rollouts. "
|
|
508
|
+
"Start with 'oopsie-data init'."
|
|
509
|
+
),
|
|
510
|
+
formatter_class=_HelpFormatter,
|
|
511
|
+
epilog=_EPILOG,
|
|
512
|
+
)
|
|
513
|
+
parser.add_argument(
|
|
514
|
+
"--version", action="version", version=f"oopsie-data {_version()}"
|
|
515
|
+
)
|
|
516
|
+
parser.add_argument(
|
|
517
|
+
"--config-dir",
|
|
518
|
+
type=Path,
|
|
519
|
+
default=None,
|
|
520
|
+
metavar="DIR",
|
|
521
|
+
help=(
|
|
522
|
+
"Directory holding contributor_config.yaml "
|
|
523
|
+
f"(overrides ${ENV_CONFIG_DIR} for this invocation; robot profiles are unaffected)"
|
|
524
|
+
),
|
|
525
|
+
)
|
|
526
|
+
# Not required=True: a bare 'oopsie-data' should print the full help, which lists the
|
|
527
|
+
# commands, rather than a usage line that only says a command was missing.
|
|
528
|
+
sub = parser.add_subparsers(dest="command", metavar="<command>")
|
|
529
|
+
|
|
530
|
+
# init
|
|
531
|
+
p_init = _add_command(
|
|
532
|
+
sub,
|
|
533
|
+
"init",
|
|
534
|
+
summary="Set up the contributor config (lab id + HuggingFace token)",
|
|
535
|
+
description=(
|
|
536
|
+
"Interactive setup: picks a config directory and writes contributor_config.yaml "
|
|
537
|
+
"with your lab id and HuggingFace token, verifying the token against the "
|
|
538
|
+
"HuggingFace API. Values passed as flags are not prompted for, so a fully-flagged "
|
|
539
|
+
"invocation runs unattended."
|
|
540
|
+
),
|
|
541
|
+
examples=(
|
|
542
|
+
" oopsie-data init\n"
|
|
543
|
+
" oopsie-data init --lab-id my_lab --hf-token hf_xxx # unattended\n"
|
|
544
|
+
" oopsie-data --config-dir ./cfg init # somewhere else\n"
|
|
545
|
+
),
|
|
546
|
+
)
|
|
547
|
+
p_init.add_argument("--lab-id", default=None, help="Lab id from the registration form")
|
|
548
|
+
p_init.add_argument("--hf-token", default=None, help="HuggingFace token to store")
|
|
549
|
+
p_init.add_argument(
|
|
550
|
+
"--no-verify-token",
|
|
551
|
+
action="store_true",
|
|
552
|
+
help="Do not check the token against the HuggingFace API",
|
|
553
|
+
)
|
|
554
|
+
p_init.add_argument(
|
|
555
|
+
"--force", action="store_true", help="Overwrite an existing config without asking"
|
|
556
|
+
)
|
|
557
|
+
p_init.set_defaults(func=cmd_init)
|
|
558
|
+
|
|
559
|
+
# show-config
|
|
560
|
+
p_config = _add_command(
|
|
561
|
+
sub,
|
|
562
|
+
"show-config",
|
|
563
|
+
summary="Show where configs are read from, and the current lab id and token",
|
|
564
|
+
description=(
|
|
565
|
+
"Print every location searched for the contributor config and for robot profiles, "
|
|
566
|
+
"which one is currently being used, and the lab id and HuggingFace token in effect. "
|
|
567
|
+
"Read-only: use 'oopsie-data init' to change anything. The token is masked unless "
|
|
568
|
+
"--show-token is given."
|
|
569
|
+
),
|
|
570
|
+
examples=(
|
|
571
|
+
" oopsie-data show-config\n"
|
|
572
|
+
" oopsie-data --config-dir ./cfg show-config # try a different location\n"
|
|
573
|
+
),
|
|
574
|
+
)
|
|
575
|
+
p_config.add_argument(
|
|
576
|
+
"--show-token", action="store_true", help="Print the HuggingFace token in full"
|
|
577
|
+
)
|
|
578
|
+
p_config.set_defaults(func=cmd_show_config)
|
|
579
|
+
|
|
580
|
+
# new-profile
|
|
581
|
+
p_new_profile = _add_command(
|
|
582
|
+
sub,
|
|
583
|
+
"new-profile",
|
|
584
|
+
summary="Write a starter robot profile you can fill in",
|
|
585
|
+
description=(
|
|
586
|
+
"Write a commented robot-profile skeleton into your project, by default "
|
|
587
|
+
"./robot_profiles/. Profiles belong next to the robot code that loads them, not in "
|
|
588
|
+
"your user config directory, and are never read out of the installed package. The "
|
|
589
|
+
"skeleton does not load until you fill in the required fields — that is deliberate."
|
|
590
|
+
),
|
|
591
|
+
examples=(
|
|
592
|
+
" oopsie-data new-profile\n"
|
|
593
|
+
" oopsie-data new-profile --name franka_pick --dir ./configs/robot_profiles\n"
|
|
594
|
+
),
|
|
595
|
+
)
|
|
596
|
+
p_new_profile.add_argument(
|
|
597
|
+
"--name", default="robot_profile", help="File name without .yaml (default: robot_profile)"
|
|
598
|
+
)
|
|
599
|
+
p_new_profile.add_argument(
|
|
600
|
+
"--dir",
|
|
601
|
+
type=Path,
|
|
602
|
+
default=None,
|
|
603
|
+
help="Directory to write into (default: ./robot_profiles, or $OOPSIE_ROBOT_PROFILES_DIR)",
|
|
604
|
+
)
|
|
605
|
+
p_new_profile.add_argument(
|
|
606
|
+
"--force", action="store_true", help="Overwrite an existing profile of the same name"
|
|
607
|
+
)
|
|
608
|
+
p_new_profile.set_defaults(func=cmd_new_profile)
|
|
609
|
+
|
|
610
|
+
# annotate
|
|
611
|
+
p_annotate = _add_command(
|
|
612
|
+
sub,
|
|
613
|
+
"annotate",
|
|
614
|
+
summary="Launch the web annotation UI over a directory of episodes",
|
|
615
|
+
description=(
|
|
616
|
+
"Serve the annotation UI for the episodes in --samples-dir and open it in a "
|
|
617
|
+
"browser. Annotations are written back into the HDF5 files under your annotator "
|
|
618
|
+
"name, so the same session can be annotated by several people. Runs until you "
|
|
619
|
+
"stop it with Ctrl-C."
|
|
620
|
+
),
|
|
621
|
+
examples=(
|
|
622
|
+
" oopsie-data annotate --samples-dir ./samples --annotator-name alex\n"
|
|
623
|
+
" oopsie-data annotate --samples-dir ./samples --port 8080 --no-browser\n"
|
|
624
|
+
),
|
|
625
|
+
)
|
|
626
|
+
p_annotate.add_argument(
|
|
627
|
+
"--samples-dir",
|
|
628
|
+
"--path",
|
|
629
|
+
type=Path,
|
|
630
|
+
default=Path("samples"),
|
|
631
|
+
metavar="DIR",
|
|
632
|
+
help="Directory containing saved MP4s (and HDF5 episodes) (default: ./samples)",
|
|
633
|
+
)
|
|
634
|
+
p_annotate.add_argument(
|
|
635
|
+
"--annotator-name",
|
|
636
|
+
type=str,
|
|
637
|
+
default=None,
|
|
638
|
+
metavar="NAME",
|
|
639
|
+
help="Annotator name to stamp into saved annotations (prompted for if omitted)",
|
|
640
|
+
)
|
|
641
|
+
p_annotate.add_argument(
|
|
642
|
+
"--port", type=int, default=5001, metavar="N", help="Server port (default: 5001)"
|
|
643
|
+
)
|
|
644
|
+
p_annotate.add_argument(
|
|
645
|
+
"--no-browser", action="store_true", help="Do not open a browser window on start"
|
|
646
|
+
)
|
|
647
|
+
p_annotate.add_argument(
|
|
648
|
+
"--with-rollouts",
|
|
649
|
+
action="store_true",
|
|
650
|
+
help="Enable in-the-loop rollout mode (HDF5 browser + annotation form + rollouts)",
|
|
651
|
+
)
|
|
652
|
+
p_annotate.set_defaults(func=cmd_annotate)
|
|
653
|
+
|
|
654
|
+
# validate
|
|
655
|
+
p_validate = _add_command(
|
|
656
|
+
sub,
|
|
657
|
+
"validate",
|
|
658
|
+
summary="Validate episodes against the oopsiedata schema",
|
|
659
|
+
description=(
|
|
660
|
+
"Check a single .h5 episode, or every episode in a session directory, against the "
|
|
661
|
+
"oopsiedata_format_v1 schema — the same check 'oopsie-data upload' runs before "
|
|
662
|
+
"publishing. Every failure is reported with the episode and field that caused it. "
|
|
663
|
+
"Exits 0 if everything passed, 1 otherwise, so it is usable in a script."
|
|
664
|
+
),
|
|
665
|
+
examples=(
|
|
666
|
+
" oopsie-data validate --path ./samples\n"
|
|
667
|
+
" oopsie-data validate --path ./samples --episode-id 000001\n"
|
|
668
|
+
" oopsie-data validate --path ./samples --log-path validate.log\n"
|
|
669
|
+
),
|
|
670
|
+
)
|
|
671
|
+
p_validate.add_argument(
|
|
672
|
+
"--path",
|
|
673
|
+
"-p",
|
|
674
|
+
"--samples-dir",
|
|
675
|
+
required=True,
|
|
676
|
+
metavar="PATH",
|
|
677
|
+
help="A single .h5 file, or a session directory containing .h5 files",
|
|
678
|
+
)
|
|
679
|
+
p_validate.add_argument(
|
|
680
|
+
"--episode-id",
|
|
681
|
+
"--episode_id",
|
|
682
|
+
"-e",
|
|
683
|
+
default=None,
|
|
684
|
+
metavar="ID",
|
|
685
|
+
help="Zero-padded episode id (e.g. 000001) to validate just that episode inside --path",
|
|
686
|
+
)
|
|
687
|
+
p_validate.add_argument(
|
|
688
|
+
"--log-path", "-l", default=None, metavar="FILE", help="Also write logs to this file"
|
|
689
|
+
)
|
|
690
|
+
p_validate.set_defaults(func=cmd_validate)
|
|
691
|
+
|
|
692
|
+
# upload
|
|
693
|
+
p_upload = _add_command(
|
|
694
|
+
sub,
|
|
695
|
+
"upload",
|
|
696
|
+
summary="Validate and upload a session directory to HuggingFace",
|
|
697
|
+
description=(
|
|
698
|
+
"Validate a session directory and upload it to your lab's HuggingFace dataset repo, "
|
|
699
|
+
"OopsieData-Submissions/<lab_id>, creating the repo on first use. Credentials come "
|
|
700
|
+
"from contributor_config.yaml, with $HF_TOKEN overriding the stored token. Nothing "
|
|
701
|
+
"is uploaded unless validation passes and the directory layout is within "
|
|
702
|
+
"HuggingFace's per-directory file limit."
|
|
703
|
+
),
|
|
704
|
+
examples=(
|
|
705
|
+
" oopsie-data upload --path ./samples\n"
|
|
706
|
+
" oopsie-data upload --path ./samples --skip-upload # dry run: checks only\n"
|
|
707
|
+
" oopsie-data upload --path ./samples --with-restructure\n"
|
|
708
|
+
),
|
|
709
|
+
)
|
|
710
|
+
p_upload.add_argument(
|
|
711
|
+
"--path",
|
|
712
|
+
"-p",
|
|
713
|
+
"--samples-dir",
|
|
714
|
+
required=True,
|
|
715
|
+
metavar="DIR",
|
|
716
|
+
help="Session directory containing formatted episode files",
|
|
717
|
+
)
|
|
718
|
+
p_upload.add_argument(
|
|
719
|
+
"--episode-id",
|
|
720
|
+
"--episode_id",
|
|
721
|
+
"-e",
|
|
722
|
+
default=None,
|
|
723
|
+
metavar="ID",
|
|
724
|
+
help="Zero-padded episode id to validate; if omitted, every *.h5 in --path is validated",
|
|
725
|
+
)
|
|
726
|
+
p_upload.add_argument(
|
|
727
|
+
"--skip-validate",
|
|
728
|
+
"--skip_validate",
|
|
729
|
+
action="store_true",
|
|
730
|
+
help="Skip validation before uploading",
|
|
731
|
+
)
|
|
732
|
+
p_upload.add_argument(
|
|
733
|
+
"--skip-upload",
|
|
734
|
+
"--skip_upload",
|
|
735
|
+
action="store_true",
|
|
736
|
+
help="Run validation and checks only, do not upload",
|
|
737
|
+
)
|
|
738
|
+
p_upload.add_argument(
|
|
739
|
+
"--with-restructure",
|
|
740
|
+
action="store_true",
|
|
741
|
+
help=(
|
|
742
|
+
"If a directory exceeds the HuggingFace file limit, write a restructured copy "
|
|
743
|
+
"to <path>_restructured and upload that instead of aborting (the original is "
|
|
744
|
+
"left untouched, so this needs room for a second copy)"
|
|
745
|
+
),
|
|
746
|
+
)
|
|
747
|
+
p_upload.add_argument(
|
|
748
|
+
"--log-path", "-l", default=None, metavar="FILE", help="Also write logs to this file"
|
|
749
|
+
)
|
|
750
|
+
p_upload.add_argument(
|
|
751
|
+
"--strict-diversity",
|
|
752
|
+
action="store_true",
|
|
753
|
+
help="Treat low task/annotation diversity warnings as a hard error",
|
|
754
|
+
)
|
|
755
|
+
p_upload.set_defaults(func=cmd_upload)
|
|
756
|
+
|
|
757
|
+
# submissions
|
|
758
|
+
p_submissions = _add_command(
|
|
759
|
+
sub,
|
|
760
|
+
"submissions",
|
|
761
|
+
summary="Show what your lab has already uploaded to HuggingFace",
|
|
762
|
+
description=(
|
|
763
|
+
"Report episode, video and file counts in OopsieData-Submissions/<lab_id> without "
|
|
764
|
+
"downloading anything. A repo that does not exist yet is not an error — it is "
|
|
765
|
+
"created on your first successful upload."
|
|
766
|
+
),
|
|
767
|
+
examples=(
|
|
768
|
+
" oopsie-data submissions\n"
|
|
769
|
+
" oopsie-data submissions --lab-id some_other_lab\n"
|
|
770
|
+
),
|
|
771
|
+
)
|
|
772
|
+
p_submissions.add_argument(
|
|
773
|
+
"--lab-id",
|
|
774
|
+
default=None,
|
|
775
|
+
metavar="ID",
|
|
776
|
+
help="Lab id to query (default: the lab_id in your contributor config)",
|
|
777
|
+
)
|
|
778
|
+
p_submissions.set_defaults(func=cmd_submissions)
|
|
779
|
+
|
|
780
|
+
# inspect
|
|
781
|
+
p_inspect = _add_command(
|
|
782
|
+
sub,
|
|
783
|
+
"inspect",
|
|
784
|
+
summary="Dump the structure of an HDF5 episode",
|
|
785
|
+
description=(
|
|
786
|
+
"Print every group, dataset, shape, dtype and attribute in an HDF5 file. This is a "
|
|
787
|
+
"debugging aid, not a validator: it makes no assumptions about the schema, so it "
|
|
788
|
+
"works just as well on a file that 'oopsie-data validate' rejects — which is "
|
|
789
|
+
"usually how you find out why it was rejected."
|
|
790
|
+
),
|
|
791
|
+
examples=(
|
|
792
|
+
" oopsie-data inspect ./samples/000000.h5\n"
|
|
793
|
+
" oopsie-data inspect ./samples/000000.h5 | less\n"
|
|
794
|
+
),
|
|
795
|
+
)
|
|
796
|
+
p_inspect.add_argument("path", metavar="FILE", help="Path to a .h5 / .hdf5 file")
|
|
797
|
+
p_inspect.set_defaults(func=cmd_inspect)
|
|
798
|
+
|
|
799
|
+
# restructure
|
|
800
|
+
p_restructure = _add_command(
|
|
801
|
+
sub,
|
|
802
|
+
"restructure",
|
|
803
|
+
summary="Split an oversized session directory into numbered subfolders",
|
|
804
|
+
description=(
|
|
805
|
+
"Copy a session into a new folder in which every directory that exceeds "
|
|
806
|
+
f"HuggingFace's per-directory limit of {FILE_LIMIT:,} files — the one "
|
|
807
|
+
"'oopsie-data upload' refuses on — has been split into numbered subfolders of at "
|
|
808
|
+
f"most {BATCH_SIZE} episodes each, wherever in the tree that directory sits. "
|
|
809
|
+
"Directories already under the limit are copied through unchanged.\n\n"
|
|
810
|
+
"Non-destructive: the source is never modified, not even the HDF5 files, whose "
|
|
811
|
+
"stored video paths are rewritten only in the copy. That means you need room for "
|
|
812
|
+
"a second copy of the session, and you delete the original yourself once you have "
|
|
813
|
+
"checked the output. 'oopsie-data upload --with-restructure' does all of this as "
|
|
814
|
+
"one step."
|
|
815
|
+
),
|
|
816
|
+
examples=(
|
|
817
|
+
" oopsie-data restructure --source ./samples\n"
|
|
818
|
+
" oopsie-data restructure --source ./samples --output /big/disk/samples_split\n"
|
|
819
|
+
" oopsie-data restructure --source ./samples --yes # no prompt, for scripts\n"
|
|
820
|
+
),
|
|
821
|
+
)
|
|
822
|
+
p_restructure.add_argument(
|
|
823
|
+
"--source", "-s", type=Path, required=True, metavar="DIR",
|
|
824
|
+
help="Session directory to restructure (its whole tree is scanned)",
|
|
825
|
+
)
|
|
826
|
+
p_restructure.add_argument(
|
|
827
|
+
"--output", "-o", type=Path, default=None, metavar="DIR",
|
|
828
|
+
help="Destination for the restructured copy (default: <source>_restructured alongside it)",
|
|
829
|
+
)
|
|
830
|
+
p_restructure.add_argument(
|
|
831
|
+
"--yes", "-y", action="store_true",
|
|
832
|
+
help="Skip the confirmation prompt (this copies the whole dataset)",
|
|
833
|
+
)
|
|
834
|
+
p_restructure.set_defaults(func=cmd_restructure)
|
|
835
|
+
|
|
836
|
+
# install-skill
|
|
837
|
+
p_skill = _add_command(
|
|
838
|
+
sub,
|
|
839
|
+
"install-skill",
|
|
840
|
+
summary="Install the bundled Claude Code skill for oopsie-data",
|
|
841
|
+
description=(
|
|
842
|
+
"Copy the skill that ships with this package into your project, so Claude Code "
|
|
843
|
+
"knows how to drive the contributor workflow. Entirely optional: nothing else in "
|
|
844
|
+
"oopsie-data needs Claude, and no files are written anywhere unless you run this "
|
|
845
|
+
"command. Installs into ./skills/oopsie-data/, a plain directory you can read, "
|
|
846
|
+
"edit and commit; the command prints how to link it into .claude/skills/ so Claude "
|
|
847
|
+
"Code picks it up. Pass --user to install it into ~/.claude/skills/ instead, where "
|
|
848
|
+
"it is active in every project straight away."
|
|
849
|
+
),
|
|
850
|
+
examples=(
|
|
851
|
+
" oopsie-data install-skill\n"
|
|
852
|
+
" oopsie-data install-skill --user # available in every project\n"
|
|
853
|
+
),
|
|
854
|
+
)
|
|
855
|
+
p_skill.add_argument(
|
|
856
|
+
"--user",
|
|
857
|
+
action="store_true",
|
|
858
|
+
help="Install into ~/.claude/skills/ instead of ./skills/",
|
|
859
|
+
)
|
|
860
|
+
p_skill.add_argument(
|
|
861
|
+
"--force", action="store_true", help="Overwrite an existing installation of the skill"
|
|
862
|
+
)
|
|
863
|
+
p_skill.set_defaults(func=cmd_install_skill)
|
|
864
|
+
|
|
865
|
+
return parser
|
|
866
|
+
|
|
867
|
+
|
|
868
|
+
def main(argv: list[str] | None = None) -> int:
|
|
869
|
+
logging.basicConfig(level=logging.INFO, format="%(message)s")
|
|
870
|
+
parser = build_parser()
|
|
871
|
+
args = parser.parse_args(argv)
|
|
872
|
+
|
|
873
|
+
# A bare 'oopsie-data' is someone looking for the commands, so show them: the full help
|
|
874
|
+
# answers that, where argparse's "the following arguments are required" does not.
|
|
875
|
+
if getattr(args, "func", None) is None:
|
|
876
|
+
parser.print_help()
|
|
877
|
+
return 2
|
|
878
|
+
|
|
879
|
+
# Export before any command runs, so library code resolving config paths sees it.
|
|
880
|
+
if args.config_dir is not None:
|
|
881
|
+
os.environ[ENV_CONFIG_DIR] = str(Path(args.config_dir).expanduser().resolve())
|
|
882
|
+
try:
|
|
883
|
+
return args.func(args)
|
|
884
|
+
except KeyboardInterrupt:
|
|
885
|
+
logger.info("Interrupted.")
|
|
886
|
+
return 130
|
|
887
|
+
except RuntimeError as e:
|
|
888
|
+
# Config/auth problems already carry an actionable message; don't dump a traceback.
|
|
889
|
+
logger.error("%s", e)
|
|
890
|
+
return 1
|
|
891
|
+
|
|
892
|
+
|
|
893
|
+
if __name__ == "__main__":
|
|
894
|
+
sys.exit(main())
|