Repository navigation
Expand file tree
/
Copy pathtest_wizard_byod.py
More file actions
397 lines (327 loc) · 16.2 KB
/
Copy pathtest_wizard_byod.py
File metadata and controls
397 lines (327 loc) · 16.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
"""Tests for the BYOD (bring-your-own-data) input loop in ``wizard.py``.
Covers the path-validation block in ``_maybe_run_quickstart_template``:
nonexistent paths, directories, malformed JSONL, valid JSONL, HF Hub dataset
IDs, and ``~`` expansion. These are unit tests — no subprocess, no GPU,
no network. ``input()`` is stubbed via ``builtins.input`` patching, and
``run_quickstart`` is patched to keep the tests hermetic.
"""
from __future__ import annotations
from pathlib import Path
from unittest.mock import patch
import pytest
from forgelm import wizard
def _make_input(answers):
"""Return a side_effect callable that returns each answer in order."""
answers = list(answers)
def _impl(_prompt_text=""):
if not answers:
raise AssertionError("input() called more times than answers provided")
return answers.pop(0)
return _impl
# Inputs we feed to the wizard before the BYOD prompt:
# 1. "y" — accept the quickstart-template offer
# 2. "domain-expert" — pick the BYOD template
# After that the BYOD loop consumes its own answers.
_PRELUDE = ["y", "domain-expert"]
def test_byod_rejects_nonexistent_path(capsys):
answers = _PRELUDE + ["/no/such/file.jsonl", "cancel"]
with patch("builtins.input", side_effect=_make_input(answers)):
result = wizard._maybe_run_quickstart_template()
assert result is None
captured = capsys.readouterr().out
assert "Path not found or not a regular file: /no/such/file.jsonl" in captured
def test_byod_rejects_empty_directory(tmp_path, capsys):
# An empty directory has no ingestible documents — the wizard's
# Phase 11.5 ingest-first hook surfaces a clear "no supported files"
# message and re-prompts. Pre-Phase-11.5 wording ("not a JSONL file")
# was deliberately replaced once the wizard learned to ingest inline.
answers = _PRELUDE + [str(tmp_path), "cancel"]
with patch("builtins.input", side_effect=_make_input(answers)):
result = wizard._maybe_run_quickstart_template()
assert result is None
captured = capsys.readouterr().out
assert "doesn't contain any" in captured
def test_byod_directory_with_docs_offers_ingest_then_cancels(tmp_path, capsys):
# Phase 11.5: when the directory has ingestible files, the wizard offers
# to run ingestion inline. We answer "no" to the offer; the decline path
# surfaces the manual-ingest hint with the resolved directory.
(tmp_path / "doc.txt").write_text("alpha\n\nbeta", encoding="utf-8")
# Inputs after prelude: directory path → "n" (decline ingest offer) → "cancel"
answers = _PRELUDE + [str(tmp_path), "n", "cancel"]
with patch("builtins.input", side_effect=_make_input(answers)):
result = wizard._maybe_run_quickstart_template()
assert result is None
captured = capsys.readouterr().out
assert "Skipped" in captured
assert "forgelm ingest" in captured
def test_byod_rejects_malformed_jsonl(tmp_path, capsys):
bad = tmp_path / "bad.jsonl"
bad.write_text("{bad json\n", encoding="utf-8")
answers = _PRELUDE + [str(bad), "cancel"]
with patch("builtins.input", side_effect=_make_input(answers)):
result = wizard._maybe_run_quickstart_template()
assert result is None
captured = capsys.readouterr().out
assert "not valid JSONL" in captured
def test_byod_accepts_valid_jsonl(tmp_path):
good = tmp_path / "good.jsonl"
good.write_text('{"messages":[]}\n', encoding="utf-8")
# Phase 12.5: after a JSONL is validated, the wizard offers to run an
# audit on it. Decline ("n") so this test remains scoped to the BYOD
# validation path; the audit-offer behaviour has its own coverage in
# test_phase12_5.py::TestWizardAuditFirstOffer.
answers = _PRELUDE + [str(good), "n"]
fake_result = type(
"R",
(),
{
"config_path": tmp_path / "generated.yaml",
"chosen_model": "test-model",
"selection_reason": "test",
"dataset_path": str(good),
},
)()
with (
patch("builtins.input", side_effect=_make_input(answers)),
patch("forgelm.quickstart.run_quickstart", return_value=fake_result) as mock_run,
):
result = wizard._maybe_run_quickstart_template()
assert result == str(tmp_path / "generated.yaml")
mock_run.assert_called_once()
# The dataset_override should be the resolved absolute path.
_args, kwargs = mock_run.call_args
assert kwargs["dataset_override"] == str(good)
def test_byod_run_quickstart_file_exists_error_falls_back(capsys):
# LOW finding: forgelm.quickstart._resolve_dataset raises
# FileExistsError by design when the per-run scratch dataset path
# already exists (an OSError subclass distinct from
# FileNotFoundError). Pre-fix, the prelude's except tuple was
# (FileNotFoundError, ValueError) only, so FileExistsError propagated
# uncaught and crashed the interactive wizard instead of hitting the
# friendly fallback message.
hub_id = "tatsu-lab/alpaca"
answers = _PRELUDE + [hub_id]
with (
patch("builtins.input", side_effect=_make_input(answers)),
patch(
"forgelm.quickstart.run_quickstart",
side_effect=FileExistsError("Dataset already exists at /x/y.jsonl"),
),
):
result = wizard._maybe_run_quickstart_template()
assert result is None
captured = capsys.readouterr().out
assert "Quickstart failed" in captured
assert "Falling back to the full wizard" in captured
def test_byod_accepts_hf_hub_id(tmp_path, capsys):
hub_id = "tatsu-lab/alpaca"
answers = _PRELUDE + [hub_id]
fake_result = type(
"R",
(),
{
"config_path": tmp_path / "generated.yaml",
"chosen_model": "test-model",
"selection_reason": "test",
"dataset_path": hub_id,
},
)()
with (
patch("builtins.input", side_effect=_make_input(answers)),
patch("forgelm.quickstart.run_quickstart", return_value=fake_result) as mock_run,
):
result = wizard._maybe_run_quickstart_template()
assert result == str(tmp_path / "generated.yaml")
captured = capsys.readouterr().out
assert f"Treating '{hub_id}' as an HF Hub dataset ID" in captured
# The HF ID must be passed through unchanged — not turned into a Path.
_args, kwargs = mock_run.call_args
assert kwargs["dataset_override"] == hub_id
def test_prompt_dataset_path_accepts_bare_canonical_hub_id(capsys):
# LOW finding: _HF_HUB_ID_RE required exactly one slash, rejecting
# legitimate no-namespace canonical HF Hub datasets (e.g. squad, glue,
# imdb). The regex now also accepts a bare <name> form.
with patch("builtins.input", side_effect=_make_input(["squad"])):
result = wizard._prompt_dataset_path_with_ingest_offer("Dataset")
assert result == "squad"
captured = capsys.readouterr().out
assert "Treating 'squad' as an HF Hub dataset ID" in captured
def test_prompt_dataset_path_still_rejects_multi_slash(capsys):
# Regression guard: broadening the regex to accept bare names must not
# accidentally accept multi-segment paths that aren't a real local
# file/directory.
answers = ["a/b/c", "cancel"]
with patch("builtins.input", side_effect=_make_input(answers)):
with pytest.raises(wizard.WizardCancel):
wizard._prompt_dataset_path_with_ingest_offer("Dataset")
captured = capsys.readouterr().out
assert "not a valid HF Hub ID" in captured
def test_prompt_dataset_path_rejection_message_covers_both_accepted_shapes(capsys):
# LOW doc-drift fix: after the regex broadening accepted bare
# no-namespace canonical ids (squad, imdb — see the test above), the
# rejection message still told the operator the only valid shape was
# '<org>/<name>', understating what the acceptance branch actually
# allows. It must now name both accepted shapes.
answers = ["a/b/c", "cancel"]
with patch("builtins.input", side_effect=_make_input(answers)):
with pytest.raises(wizard.WizardCancel):
wizard._prompt_dataset_path_with_ingest_offer("Dataset")
captured = capsys.readouterr().out
assert "'<org>/<name>'" in captured
assert "squad" in captured, "rejection message must also mention the canonical no-namespace shape"
def test_offer_ingest_pii_mask_defaults_to_true_on_bare_enter(tmp_path):
# LOW finding: the prompt calls masking "Recommended for shared
# corpora" but pre-fix defaulted to False — an operator who reads
# "Recommended" and presses Enter got the non-recommended, less-
# protective outcome. The default now matches the copy.
(tmp_path / "doc.txt").write_text("alpha\n\nbeta", encoding="utf-8")
fake_result = type("R", (), {"chunk_count": 1, "files_processed": 1})()
answers = [
"y", # run ingestion now?
"", # output JSONL path — bare Enter (default)
"", # mask PII — bare Enter (default)
"n", # decline the post-ingest audit offer
]
with (
patch("builtins.input", side_effect=_make_input(answers)),
patch("forgelm.ingestion.ingest_path", return_value=fake_result) as mock_ingest,
):
result = wizard._offer_ingest_for_directory(tmp_path)
assert result is not None
_args, kwargs = mock_ingest.call_args
assert kwargs["pii_mask"] is True
def test_byod_expands_user_home(tmp_path, monkeypatch):
good = tmp_path / "data.jsonl"
good.write_text('{"messages":[]}\n', encoding="utf-8")
# Pretend the user's home is tmp_path so "~/data.jsonl" expands to our file.
# ``Path.expanduser()`` reads ``$HOME`` on POSIX and ``%USERPROFILE%`` on
# Windows (with ``%HOMEDRIVE%%HOMEPATH%`` as a secondary fallback) —
# set all four so the test passes on every cross-OS publish-matrix combo.
monkeypatch.setenv("HOME", str(tmp_path))
monkeypatch.setenv("USERPROFILE", str(tmp_path))
monkeypatch.setenv("HOMEDRIVE", "")
monkeypatch.setenv("HOMEPATH", str(tmp_path))
typed = "~/data.jsonl"
# Phase 12.5: extra "n" declines the post-validation audit offer.
answers = _PRELUDE + [typed, "n"]
fake_result = type(
"R",
(),
{
"config_path": tmp_path / "generated.yaml",
"chosen_model": "test-model",
"selection_reason": "test",
"dataset_path": str(good),
},
)()
with (
patch("builtins.input", side_effect=_make_input(answers)),
patch("forgelm.quickstart.run_quickstart", return_value=fake_result) as mock_run,
):
result = wizard._maybe_run_quickstart_template()
assert result == str(tmp_path / "generated.yaml")
_args, kwargs = mock_run.call_args
# The override must be the expanded absolute path, not the literal "~/...".
# _validate_local_jsonl applies .resolve() after .expanduser(), so on
# macOS where /tmp is a symlink to /private/tmp the override may carry
# the canonical form — compare via Path equality instead of string match.
expanded = Path(typed).expanduser().resolve()
assert Path(kwargs["dataset_override"]) == expanded
# And it should resolve to our actual tmp file.
assert Path(kwargs["dataset_override"]).is_file()
def test_byod_relative_local_path_not_misclassified_as_hub_id(tmp_path, monkeypatch, capsys):
"""``data/train.jsonl`` matches the HF Hub regex shape but is a local file.
Regression: an earlier version of the loop tested the HF Hub regex
*before* the local-file probe, so a relative path that happened to look
like ``<org>/<name>`` (single slash, safe-name char class) silently
became a Hub ID and bypassed JSONL validation. The fixed loop tries
local first, falls back to Hub semantics only when the path is missing.
"""
nested = tmp_path / "data"
nested.mkdir()
real_file = nested / "train.jsonl"
real_file.write_text('{"messages":[]}\n', encoding="utf-8")
monkeypatch.chdir(tmp_path) # so "data/train.jsonl" resolves under tmp_path
typed = "data/train.jsonl"
# Phase 12.5: decline the post-validation audit offer.
answers = _PRELUDE + [typed, "n"]
fake_result = type(
"R",
(),
{
"config_path": tmp_path / "generated.yaml",
"chosen_model": "test-model",
"selection_reason": "test",
"dataset_path": str(real_file),
},
)()
with (
patch("builtins.input", side_effect=_make_input(answers)),
patch("forgelm.quickstart.run_quickstart", return_value=fake_result) as mock_run,
):
result = wizard._maybe_run_quickstart_template()
assert result == str(tmp_path / "generated.yaml")
captured = capsys.readouterr().out
# Must NOT have been treated as an HF Hub ID.
assert "Treating" not in captured, "relative local path was misclassified as a Hub ID"
_args, kwargs = mock_run.call_args
# The override must point to the actual on-disk file, expanded to absolute.
assert Path(kwargs["dataset_override"]) == real_file.resolve()
def test_byod_cancel_at_nested_audit_prompt_falls_back(tmp_path, capsys):
"""F-H-11: cancel typed at the audit-offer prompt inside _validate_local_jsonl
must be caught by _resolve_byod_dataset_path and return None (fall back to
the full wizard), NOT propagate WizardCancel up through the call stack.
Regression: before the fix the try/except WizardCancel in
_resolve_byod_dataset_path only wrapped the initial _prompt() call, so any
WizardCancel raised from the four secondary prompts inside
_offer_audit_for_jsonl / _offer_ingest_for_directory escaped uncaught and
terminated the wizard entirely instead of falling back.
"""
from forgelm.wizard._byod import _resolve_byod_dataset_path
good = tmp_path / "good.jsonl"
good.write_text('{"messages": []}\n', encoding="utf-8")
# Inputs: JSONL path (valid) → "cancel" at the audit-offer prompt.
# "cancel" triggers WizardCancel inside _offer_audit_for_jsonl's
# _prompt_yes_no, which must be caught inside _resolve_byod_dataset_path.
answers = [str(good), "cancel"]
with patch("builtins.input", side_effect=_make_input(answers)):
result = _resolve_byod_dataset_path()
# Must return None (fall back) — not raise WizardCancel.
assert result is None
captured = capsys.readouterr().out
assert "Cancelled" in captured
def test_byod_cancel_at_nested_audit_prompt_via_full_path(tmp_path, capsys):
"""F-H-11 (via full wizard path): cancel at the nested audit prompt inside
_validate_local_jsonl returns None from _maybe_run_quickstart_template,
not a clean wizard exit. Verifies the fix applies end-to-end.
"""
good = tmp_path / "good.jsonl"
good.write_text('{"messages": []}\n', encoding="utf-8")
# After the prelude, answer with the JSONL path, then "cancel" at the
# audit-offer prompt. The wizard should fall back gracefully.
answers = _PRELUDE + [str(good), "cancel"]
with patch("builtins.input", side_effect=_make_input(answers)):
result = wizard._maybe_run_quickstart_template()
assert result is None
captured = capsys.readouterr().out
assert "Cancelled" in captured
def test_audit_failure_logs_warning(tmp_path, caplog):
"""F-P7-OPUS-41: the BYOD audit-offer BLE001 handler previously surfaced a
failure only on stdout via ``_print``; a CI/log pipeline tailing logs saw
nothing. The wide-tail handler now logs at WARNING (per error-handling.md's
mandatory BLE001 hygiene) while keeping the offer advisory — the helper
still returns ``False`` so a failed audit does not abort BYOD selection."""
import logging
good = tmp_path / "good.jsonl"
good.write_text('{"messages": []}\n', encoding="utf-8")
with (
patch("builtins.input", side_effect=_make_input(["y"])), # accept the audit offer
patch("forgelm.data_audit.audit_dataset", side_effect=RuntimeError("boom")),
caplog.at_level(logging.WARNING, logger="forgelm.wizard"),
):
result = wizard._offer_audit_for_jsonl(good)
assert result is False
warnings = [r for r in caplog.records if r.levelno == logging.WARNING]
assert any(str(good) in r.getMessage() and "could not run" in r.getMessage() for r in warnings), (
f"expected a WARNING naming the dataset path; got {[r.getMessage() for r in warnings]}"
)