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,234 @@
|
|
|
1
|
+
"""Tests for the validate-and-upload helpers.
|
|
2
|
+
|
|
3
|
+
``utils/hf_upload.py`` is what stands between a lab and the public dataset, and it had no
|
|
4
|
+
tests at all. Nothing here touches the network: the HuggingFace API is a stand-in object,
|
|
5
|
+
and the only real work is on temporary directories.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
|
|
12
|
+
import pytest
|
|
13
|
+
|
|
14
|
+
from oopsie_data_tools.test.fixtures.make_valid import write_valid_episode
|
|
15
|
+
from oopsie_data_tools.utils import hf_upload
|
|
16
|
+
from oopsie_data_tools.utils.log import setup_logger
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class FakeApi:
|
|
20
|
+
"""Records what an upload would have done, without doing it."""
|
|
21
|
+
|
|
22
|
+
def __init__(self, existing_repos=()):
|
|
23
|
+
self.existing = set(existing_repos)
|
|
24
|
+
self.created: list[str] = []
|
|
25
|
+
self.uploaded: list[tuple[str, str]] = []
|
|
26
|
+
self.remote_files: list[str] = []
|
|
27
|
+
|
|
28
|
+
def repo_info(self, repo_id, repo_type):
|
|
29
|
+
if repo_id not in self.existing:
|
|
30
|
+
raise RuntimeError("404")
|
|
31
|
+
return {"id": repo_id}
|
|
32
|
+
|
|
33
|
+
def create_repo(self, repo_id, repo_type, private):
|
|
34
|
+
self.existing.add(repo_id)
|
|
35
|
+
self.created.append(repo_id)
|
|
36
|
+
|
|
37
|
+
def upload_large_folder(self, folder_path, repo_id, repo_type):
|
|
38
|
+
self.uploaded.append((repo_id, str(folder_path)))
|
|
39
|
+
|
|
40
|
+
def list_repo_files(self, repo_id, repo_type):
|
|
41
|
+
return self.remote_files
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# ── run_validation ─────────────────────────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_validation_passes_for_a_good_session(tmp_path):
|
|
48
|
+
write_valid_episode(tmp_path, "ep_a")
|
|
49
|
+
|
|
50
|
+
assert hf_upload.run_validation(str(tmp_path)) == 0
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_validation_fails_for_a_bad_session(tmp_path):
|
|
54
|
+
write_valid_episode(tmp_path, "ep_a")
|
|
55
|
+
(tmp_path / "broken.h5").write_text("not hdf5", encoding="utf-8")
|
|
56
|
+
|
|
57
|
+
assert hf_upload.run_validation(str(tmp_path)) == 1
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_validation_reports_a_missing_path(tmp_path, caplog):
|
|
61
|
+
assert hf_upload.run_validation(str(tmp_path / "nope")) == 1
|
|
62
|
+
assert "does not exist" in caplog.text
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_validation_of_a_single_episode_by_id(tmp_path):
|
|
66
|
+
write_valid_episode(tmp_path, "000001")
|
|
67
|
+
|
|
68
|
+
assert hf_upload.run_validation(str(tmp_path), episode_id="000001") == 0
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def test_validation_writes_the_log_file_when_asked(tmp_path):
|
|
72
|
+
write_valid_episode(tmp_path, "ep_a")
|
|
73
|
+
log_path = tmp_path / "run.log"
|
|
74
|
+
|
|
75
|
+
hf_upload.run_validation(str(tmp_path), log_path=str(log_path))
|
|
76
|
+
|
|
77
|
+
assert log_path.exists() and log_path.stat().st_size > 0
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
# ── check_folder_size ──────────────────────────────────────────────────────────
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_folder_size_check_passes_for_a_normal_layout(tmp_path):
|
|
84
|
+
write_valid_episode(tmp_path, "ep_a")
|
|
85
|
+
|
|
86
|
+
assert hf_upload.check_folder_size(str(tmp_path)) == []
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def test_folder_size_check_flags_a_directory_over_the_limit(tmp_path, monkeypatch, caplog):
|
|
90
|
+
monkeypatch.setattr(hf_upload, "FILE_LIMIT", 3)
|
|
91
|
+
for i in range(5):
|
|
92
|
+
(tmp_path / f"f{i}.bin").write_text("x", encoding="utf-8")
|
|
93
|
+
|
|
94
|
+
oversized = hf_upload.check_folder_size(str(tmp_path))
|
|
95
|
+
|
|
96
|
+
assert [count for _, count in oversized] == [5]
|
|
97
|
+
assert "exceed" in caplog.text
|
|
98
|
+
# The remedy must be a subcommand, not a path into the source checkout. It used to name
|
|
99
|
+
# scripts/validate_and_upload/restructure_large_folder.py, which does not exist for anyone
|
|
100
|
+
# who pip-installed the package — leaving the precheck's only fix unreachable.
|
|
101
|
+
assert "oopsie-data restructure --source" in caplog.text, "tell the user how to fix it"
|
|
102
|
+
assert "scripts/" not in caplog.text
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def test_folder_size_result_is_falsy_when_fine(tmp_path):
|
|
106
|
+
"""cmd_upload branches on this directly, so its truthiness is load-bearing."""
|
|
107
|
+
assert not hf_upload.check_folder_size(str(tmp_path))
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
# ── resolve_hf_target ──────────────────────────────────────────────────────────
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def test_repo_is_derived_from_the_lab_id(monkeypatch):
|
|
114
|
+
monkeypatch.delenv("HF_TOKEN", raising=False)
|
|
115
|
+
|
|
116
|
+
token, repo = hf_upload.resolve_hf_target()
|
|
117
|
+
|
|
118
|
+
assert repo == "OopsieData-Submissions/test_lab" # conftest's stubbed contributor config
|
|
119
|
+
assert token == "test_token"
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def test_hf_token_env_var_overrides_the_config(monkeypatch):
|
|
123
|
+
monkeypatch.setenv("HF_TOKEN", "hf_from_the_environment")
|
|
124
|
+
|
|
125
|
+
token, _ = hf_upload.resolve_hf_target()
|
|
126
|
+
|
|
127
|
+
assert token == "hf_from_the_environment"
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
# ── ensure_repo / upload_dataset ───────────────────────────────────────────────
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def test_missing_repo_is_created():
|
|
134
|
+
api = FakeApi()
|
|
135
|
+
|
|
136
|
+
hf_upload.ensure_repo(api, "OopsieData-Submissions/lab")
|
|
137
|
+
|
|
138
|
+
assert api.created == ["OopsieData-Submissions/lab"]
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def test_existing_repo_is_left_alone():
|
|
142
|
+
api = FakeApi(existing_repos={"OopsieData-Submissions/lab"})
|
|
143
|
+
|
|
144
|
+
hf_upload.ensure_repo(api, "OopsieData-Submissions/lab")
|
|
145
|
+
|
|
146
|
+
assert api.created == []
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def test_upload_sends_the_whole_folder(tmp_path):
|
|
150
|
+
write_valid_episode(tmp_path, "ep_a")
|
|
151
|
+
api = FakeApi()
|
|
152
|
+
api.remote_files = ["ep_a.h5"]
|
|
153
|
+
|
|
154
|
+
hf_upload.upload_dataset(api, "OopsieData-Submissions/lab", str(tmp_path))
|
|
155
|
+
|
|
156
|
+
assert api.uploaded == [("OopsieData-Submissions/lab", str(tmp_path))]
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def test_upload_warns_when_fewer_episodes_arrive_than_were_sent(tmp_path, caplog):
|
|
160
|
+
write_valid_episode(tmp_path, "ep_a")
|
|
161
|
+
write_valid_episode(tmp_path, "ep_b")
|
|
162
|
+
api = FakeApi()
|
|
163
|
+
api.remote_files = ["ep_a.h5"] # one of the two is missing remotely
|
|
164
|
+
|
|
165
|
+
with caplog.at_level(logging.WARNING):
|
|
166
|
+
hf_upload.upload_dataset(api, "OopsieData-Submissions/lab", str(tmp_path))
|
|
167
|
+
|
|
168
|
+
assert "below local" in caplog.text
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def test_upload_survives_a_repo_listing_failure(tmp_path, caplog):
|
|
172
|
+
"""The post-upload check is a courtesy; it must not fail an upload that worked."""
|
|
173
|
+
write_valid_episode(tmp_path, "ep_a")
|
|
174
|
+
api = FakeApi()
|
|
175
|
+
|
|
176
|
+
def boom(**_):
|
|
177
|
+
raise RuntimeError("listing unavailable")
|
|
178
|
+
|
|
179
|
+
api.list_repo_files = boom
|
|
180
|
+
|
|
181
|
+
hf_upload.upload_dataset(api, "OopsieData-Submissions/lab", str(tmp_path))
|
|
182
|
+
|
|
183
|
+
assert api.uploaded, "the upload itself still happened"
|
|
184
|
+
assert "Could not confirm" in caplog.text
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
# ── utils/log.py ───────────────────────────────────────────────────────────────
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def test_setup_logger_writes_to_the_named_file(tmp_path):
|
|
191
|
+
log_path = tmp_path / "a.log"
|
|
192
|
+
|
|
193
|
+
logger = setup_logger("oopsie_data_tools.test.logging_probe", str(log_path))
|
|
194
|
+
logger.info("hello")
|
|
195
|
+
|
|
196
|
+
assert "hello" in log_path.read_text(encoding="utf-8")
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def test_setup_logger_does_not_stack_handlers(tmp_path):
|
|
200
|
+
"""It is called once per validated file, so repeated calls must not accumulate."""
|
|
201
|
+
name = "oopsie_data_tools.test.logging_probe_repeat"
|
|
202
|
+
|
|
203
|
+
first = setup_logger(name, str(tmp_path / "a.log"))
|
|
204
|
+
handler_count = len(first.handlers)
|
|
205
|
+
for _ in range(3):
|
|
206
|
+
setup_logger(name, str(tmp_path / "a.log"))
|
|
207
|
+
|
|
208
|
+
assert len(first.handlers) == handler_count
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def test_setup_logger_honours_a_second_different_file(tmp_path):
|
|
212
|
+
"""The guard used to be "any handler at all", so a second --log-path wrote nothing."""
|
|
213
|
+
name = "oopsie_data_tools.test.logging_probe_repeat"
|
|
214
|
+
first_file, second_file = tmp_path / "a.log", tmp_path / "b.log"
|
|
215
|
+
|
|
216
|
+
setup_logger(name, str(first_file))
|
|
217
|
+
logger = setup_logger(name, str(second_file))
|
|
218
|
+
logger.info("second run")
|
|
219
|
+
|
|
220
|
+
assert "second run" in second_file.read_text(encoding="utf-8")
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
@pytest.fixture(autouse=True)
|
|
224
|
+
def _detach_probe_loggers():
|
|
225
|
+
"""Loggers are global; leave none behind holding a temp file open."""
|
|
226
|
+
yield
|
|
227
|
+
for name in (
|
|
228
|
+
"oopsie_data_tools.test.logging_probe",
|
|
229
|
+
"oopsie_data_tools.test.logging_probe_repeat",
|
|
230
|
+
):
|
|
231
|
+
logger = logging.getLogger(name)
|
|
232
|
+
for handler in list(logger.handlers):
|
|
233
|
+
handler.close()
|
|
234
|
+
logger.removeHandler(handler)
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"""Tests for ``oopsie-data init`` (``oopsie_data_tools/init_wizard.py``).
|
|
2
|
+
|
|
3
|
+
The wizard is driven by scripted answers: ``builtins.input`` is replaced with a queue and
|
|
4
|
+
``sys.stdin.isatty`` is forced True, which is what the prompt helpers gate on.
|
|
5
|
+
|
|
6
|
+
Note: ``conftest._test_contributor_config`` rebinds ``read_contributor_config`` for the whole
|
|
7
|
+
session, so assertions here read the written YAML directly rather than through that name.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import sys
|
|
13
|
+
|
|
14
|
+
import pytest
|
|
15
|
+
import yaml
|
|
16
|
+
|
|
17
|
+
from oopsie_data_tools import cli
|
|
18
|
+
from oopsie_data_tools.utils import paths
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@pytest.fixture
|
|
22
|
+
def config_dir(tmp_path, monkeypatch):
|
|
23
|
+
"""Send every config read and write into a scratch directory."""
|
|
24
|
+
target = tmp_path / "config"
|
|
25
|
+
monkeypatch.setenv(paths.ENV_CONFIG_DIR, str(target))
|
|
26
|
+
return target
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@pytest.fixture
|
|
30
|
+
def answers(monkeypatch):
|
|
31
|
+
"""Script the wizard's prompts; returns the list to fill in per test."""
|
|
32
|
+
queue: list[str] = []
|
|
33
|
+
|
|
34
|
+
def _fake_input(prompt: str = "") -> str:
|
|
35
|
+
if not queue:
|
|
36
|
+
raise AssertionError(f"wizard asked an unexpected question: {prompt!r}")
|
|
37
|
+
return queue.pop(0)
|
|
38
|
+
|
|
39
|
+
monkeypatch.setattr("builtins.input", _fake_input)
|
|
40
|
+
monkeypatch.setattr(sys.stdin, "isatty", lambda: True)
|
|
41
|
+
return queue
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _read_yaml(path):
|
|
45
|
+
return yaml.safe_load(path.read_text(encoding="utf-8"))
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# ── credentials ───────────────────────────────────────────────────────────────
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_credentials_written_and_readable(config_dir, answers):
|
|
52
|
+
answers.extend(["MyLab", "hf_secret_token"])
|
|
53
|
+
|
|
54
|
+
assert cli.main(["init", "--no-verify-token"]) == 0
|
|
55
|
+
|
|
56
|
+
path = config_dir / "contributor_config.yaml"
|
|
57
|
+
assert _read_yaml(path) == {"lab_id": "MyLab", "huggingface_token": "hf_secret_token"}
|
|
58
|
+
assert path.stat().st_mode & 0o777 == 0o600, "the token file must not be world readable"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_credentials_from_flags_need_no_prompts(config_dir, answers):
|
|
62
|
+
# `answers` is empty: any prompt raises. Fully-flagged runs must be unattended.
|
|
63
|
+
assert (
|
|
64
|
+
cli.main(
|
|
65
|
+
["init", "--lab-id", "FlagLab", "--hf-token", "t", "--no-verify-token"]
|
|
66
|
+
)
|
|
67
|
+
== 0
|
|
68
|
+
)
|
|
69
|
+
assert _read_yaml(config_dir / "contributor_config.yaml")["lab_id"] == "FlagLab"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def test_placeholder_lab_id_is_rejected(config_dir, answers):
|
|
73
|
+
answers.extend(["your_lab_id", "RealLab", ""]) # re-asked after the placeholder
|
|
74
|
+
|
|
75
|
+
assert cli.main(["init", "--no-verify-token"]) == 0
|
|
76
|
+
assert _read_yaml(config_dir / "contributor_config.yaml")["lab_id"] == "RealLab"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def test_placeholder_lab_id_from_flag_aborts(config_dir, answers):
|
|
80
|
+
assert cli.main(["init", "--lab-id", "your_lab_id"]) == 1
|
|
81
|
+
assert not (config_dir / "contributor_config.yaml").exists()
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def test_existing_config_is_not_overwritten_without_consent(config_dir, answers):
|
|
85
|
+
config_dir.mkdir(parents=True)
|
|
86
|
+
path = config_dir / "contributor_config.yaml"
|
|
87
|
+
path.write_text("lab_id: Original\nhuggingface_token: keep\n")
|
|
88
|
+
answers.append("n") # "Update it?" -> no
|
|
89
|
+
|
|
90
|
+
assert cli.main(["init", "--no-verify-token"]) == 0
|
|
91
|
+
assert _read_yaml(path)["lab_id"] == "Original"
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def test_existing_config_without_force_fails_non_interactively(config_dir, monkeypatch):
|
|
95
|
+
"""Nothing was written, so the exit code must say so rather than report success."""
|
|
96
|
+
config_dir.mkdir(parents=True)
|
|
97
|
+
path = config_dir / "contributor_config.yaml"
|
|
98
|
+
path.write_text("lab_id: Original\nhuggingface_token: keep\n")
|
|
99
|
+
monkeypatch.setattr(sys.stdin, "isatty", lambda: False)
|
|
100
|
+
|
|
101
|
+
assert cli.main(["init", "--lab-id", "New", "--no-verify-token"]) == 1
|
|
102
|
+
assert _read_yaml(path)["lab_id"] == "Original"
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def test_force_overwrites_without_asking(config_dir, answers):
|
|
106
|
+
config_dir.mkdir(parents=True)
|
|
107
|
+
path = config_dir / "contributor_config.yaml"
|
|
108
|
+
path.write_text("lab_id: Original\nhuggingface_token: keep\n")
|
|
109
|
+
|
|
110
|
+
assert (
|
|
111
|
+
cli.main(
|
|
112
|
+
["init", "--force", "--lab-id", "New", "--hf-token", "t2",
|
|
113
|
+
"--no-verify-token"]
|
|
114
|
+
)
|
|
115
|
+
== 0
|
|
116
|
+
)
|
|
117
|
+
assert _read_yaml(path)["lab_id"] == "New"
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def test_token_is_verified_when_asked_for(config_dir, answers, monkeypatch):
|
|
121
|
+
seen = {}
|
|
122
|
+
monkeypatch.setattr(
|
|
123
|
+
"huggingface_hub.whoami", lambda token=None: seen.update(token=token) or {"name": "someone"}
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
assert cli.main(["init", "--lab-id", "L", "--hf-token", "good"]) == 0
|
|
127
|
+
assert seen["token"] == "good"
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def test_token_verification_failure_is_not_fatal(config_dir, answers, monkeypatch):
|
|
131
|
+
def _boom(token=None):
|
|
132
|
+
raise RuntimeError("Invalid user token.")
|
|
133
|
+
|
|
134
|
+
monkeypatch.setattr("huggingface_hub.whoami", _boom)
|
|
135
|
+
|
|
136
|
+
assert cli.main(["init", "--lab-id", "L", "--hf-token", "bad"]) == 0
|
|
137
|
+
assert _read_yaml(config_dir / "contributor_config.yaml")["huggingface_token"] == "bad"
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
# ── persisting a custom config dir ────────────────────────────────────────────
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def test_config_dir_flag_prints_the_export_line(tmp_path, monkeypatch, capsys, caplog):
|
|
144
|
+
"""--config-dir only lives for one process, so init must say how to keep it."""
|
|
145
|
+
custom = tmp_path / "custom"
|
|
146
|
+
monkeypatch.delenv(paths.ENV_CONFIG_DIR, raising=False)
|
|
147
|
+
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "xdg"))
|
|
148
|
+
monkeypatch.setattr(sys.stdin, "isatty", lambda: False)
|
|
149
|
+
|
|
150
|
+
assert (
|
|
151
|
+
cli.main(
|
|
152
|
+
["--config-dir", str(custom), "init", "--lab-id", "L", "--hf-token", "t",
|
|
153
|
+
"--no-verify-token"]
|
|
154
|
+
)
|
|
155
|
+
== 0
|
|
156
|
+
)
|
|
157
|
+
logged = caplog.text + capsys.readouterr().err
|
|
158
|
+
assert f'export {paths.ENV_CONFIG_DIR}="{custom}"' in logged
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def test_exported_env_dir_needs_no_advice(config_dir, monkeypatch, capsys, caplog):
|
|
162
|
+
"""A dir already exported in the environment is found next run; stay quiet."""
|
|
163
|
+
monkeypatch.setattr(sys.stdin, "isatty", lambda: False)
|
|
164
|
+
|
|
165
|
+
assert (
|
|
166
|
+
cli.main(["init", "--lab-id", "L", "--hf-token", "t", "--no-verify-token"]) == 0
|
|
167
|
+
)
|
|
168
|
+
assert f"export {paths.ENV_CONFIG_DIR}" not in (caplog.text + capsys.readouterr().err)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
# ── non-interactive behaviour ─────────────────────────────────────────────────
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def test_non_tty_without_flags_exits_one(config_dir, monkeypatch):
|
|
175
|
+
monkeypatch.setattr(sys.stdin, "isatty", lambda: False)
|
|
176
|
+
monkeypatch.setattr(
|
|
177
|
+
"builtins.input", lambda prompt="": pytest.fail("must not prompt without a terminal")
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
assert cli.main(["init"]) == 1
|
|
181
|
+
assert not (config_dir / "contributor_config.yaml").exists()
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def test_fully_flagged_run_needs_no_terminal(config_dir, monkeypatch):
|
|
185
|
+
monkeypatch.setattr(sys.stdin, "isatty", lambda: False)
|
|
186
|
+
monkeypatch.setattr(
|
|
187
|
+
"builtins.input", lambda prompt="": pytest.fail("must not prompt without a terminal")
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
assert (
|
|
191
|
+
cli.main(["init", "--lab-id", "L", "--hf-token", "t", "--no-verify-token"]) == 0
|
|
192
|
+
)
|
|
193
|
+
assert (config_dir / "contributor_config.yaml").is_file()
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Tests for ``oopsie-data install-skill``.
|
|
2
|
+
|
|
3
|
+
The command is a directory copy, so what is worth pinning down is the destination for
|
|
4
|
+
each scope, that an existing directory is never clobbered without --force, and that the
|
|
5
|
+
payload really is inside the package rather than only in a source checkout.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import pytest
|
|
11
|
+
|
|
12
|
+
from oopsie_data_tools import cli
|
|
13
|
+
from oopsie_data_tools.utils import claude_skill
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@pytest.fixture
|
|
17
|
+
def home(tmp_path, monkeypatch):
|
|
18
|
+
"""An empty home directory, with an empty project as the working directory."""
|
|
19
|
+
fake_home = tmp_path / "home"
|
|
20
|
+
fake_home.mkdir()
|
|
21
|
+
project = tmp_path / "project"
|
|
22
|
+
project.mkdir()
|
|
23
|
+
monkeypatch.setenv("HOME", str(fake_home))
|
|
24
|
+
monkeypatch.setattr(claude_skill.Path, "home", classmethod(lambda cls: fake_home))
|
|
25
|
+
monkeypatch.chdir(project)
|
|
26
|
+
return fake_home
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_payload_ships_inside_the_package():
|
|
30
|
+
"""The skill must resolve through the installed package, not the repo layout."""
|
|
31
|
+
assert (claude_skill.bundled_skill_dir() / "SKILL.md").is_file()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_payload_ships_the_reference_files_skill_md_points_at():
|
|
35
|
+
"""SKILL.md is a router; the pages it defers to have to be there to be read."""
|
|
36
|
+
reference = claude_skill.bundled_skill_dir() / "reference"
|
|
37
|
+
linked = {
|
|
38
|
+
"setup.md",
|
|
39
|
+
"robot-profile.md",
|
|
40
|
+
"format.md",
|
|
41
|
+
"conversion.md",
|
|
42
|
+
"troubleshooting.md",
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
assert {p.name for p in reference.glob("*.md")} == linked
|
|
46
|
+
|
|
47
|
+
skill_md = (claude_skill.bundled_skill_dir() / "SKILL.md").read_text(encoding="utf-8")
|
|
48
|
+
for name in linked:
|
|
49
|
+
assert f"reference/{name}" in skill_md
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_skill_declares_frontmatter_claude_can_discover():
|
|
53
|
+
text = (claude_skill.bundled_skill_dir() / "SKILL.md").read_text(encoding="utf-8")
|
|
54
|
+
assert text.startswith("---\n")
|
|
55
|
+
frontmatter = text.split("---\n")[1]
|
|
56
|
+
assert f"name: {claude_skill.SKILL_NAME}\n" in frontmatter
|
|
57
|
+
assert "description:" in frontmatter
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_installs_into_a_visible_project_directory_by_default(home, tmp_path):
|
|
61
|
+
"""Default scope is the project, and deliberately not a hidden .claude directory."""
|
|
62
|
+
assert cli.main(["install-skill"]) == 0
|
|
63
|
+
|
|
64
|
+
assert (tmp_path / "project" / "skills" / "oopsie-data" / "SKILL.md").is_file()
|
|
65
|
+
assert not (tmp_path / "project" / ".claude").exists()
|
|
66
|
+
assert not (home / ".claude").exists()
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_install_copies_the_whole_payload_tree(home, tmp_path):
|
|
70
|
+
"""Subdirectories install too, so a page added to the payload cannot go missing."""
|
|
71
|
+
assert cli.main(["install-skill"]) == 0
|
|
72
|
+
|
|
73
|
+
source = claude_skill.bundled_skill_dir()
|
|
74
|
+
dest = tmp_path / "project" / "skills" / "oopsie-data"
|
|
75
|
+
expected = {p.relative_to(source) for p in source.rglob("*") if p.is_file()}
|
|
76
|
+
|
|
77
|
+
assert expected == {p.relative_to(dest) for p in dest.rglob("*") if p.is_file()}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def test_user_scope_installs_where_claude_code_scans(home, tmp_path):
|
|
81
|
+
assert cli.main(["install-skill", "--user"]) == 0
|
|
82
|
+
|
|
83
|
+
assert (home / ".claude" / "skills" / "oopsie-data" / "SKILL.md").is_file()
|
|
84
|
+
assert not (tmp_path / "project" / "skills").exists()
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def test_default_install_explains_how_to_activate_the_skill(home, caplog):
|
|
88
|
+
"""./skills is not scanned by Claude Code, so the command must not imply it is live."""
|
|
89
|
+
with caplog.at_level("INFO"):
|
|
90
|
+
assert cli.main(["install-skill"]) == 0
|
|
91
|
+
|
|
92
|
+
assert ".claude/skills" in caplog.text
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def test_refuses_to_overwrite_without_force(home, tmp_path):
|
|
96
|
+
assert cli.main(["install-skill"]) == 0
|
|
97
|
+
edited = tmp_path / "project" / "skills" / "oopsie-data" / "SKILL.md"
|
|
98
|
+
edited.write_text("local edits", encoding="utf-8")
|
|
99
|
+
|
|
100
|
+
assert cli.main(["install-skill"]) == 1
|
|
101
|
+
assert edited.read_text(encoding="utf-8") == "local edits"
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def test_force_replaces_the_existing_installation(home, tmp_path):
|
|
105
|
+
assert cli.main(["install-skill"]) == 0
|
|
106
|
+
installed = tmp_path / "project" / "skills" / "oopsie-data"
|
|
107
|
+
(installed / "SKILL.md").write_text("local edits", encoding="utf-8")
|
|
108
|
+
stale = installed / "stale.md"
|
|
109
|
+
stale.write_text("removed by --force", encoding="utf-8")
|
|
110
|
+
|
|
111
|
+
assert cli.main(["install-skill", "--force"]) == 0
|
|
112
|
+
|
|
113
|
+
assert "local edits" not in (installed / "SKILL.md").read_text(encoding="utf-8")
|
|
114
|
+
assert not stale.exists()
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def test_nothing_is_written_when_the_command_is_not_run(home, tmp_path):
|
|
118
|
+
assert cli.main(["show-config"]) == 0
|
|
119
|
+
assert not (home / ".claude").exists()
|
|
120
|
+
assert not (tmp_path / "project" / "skills").exists()
|