Skip to content

Commit e17e0fb

Browse files
davanstrienclaude
andcommitted
Make inspect/head robust to multi-config datasets + dict-shaped split info
- inspect: tolerate SplitInfo objects OR plain dicts (some Hub datasets return {'name':..., 'dataset_name':...} with no counts) and a None dataset_size. - head: auto-resolve config (multi-config datasets with no default) and fall back to the first available split when the requested/default split (e.g. 'train') is absent, with hints. - Add deterministic unit tests (tests/test_cli.py). Found via real-world testing on openbmb/UltraData-SFT-2605. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent dba6665 commit e17e0fb

3 files changed

Lines changed: 237 additions & 185 deletions

File tree

src/hf_ds/cli.py

Lines changed: 41 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,18 @@ def _features_to_str(features) -> dict:
5555
return {name: str(feat) for name, feat in features.items()}
5656

5757

58+
def _split_summary(s) -> dict:
59+
"""Normalize a split value to row/byte counts.
60+
61+
`DatasetInfo.splits` values are usually `SplitInfo` dataclasses (attribute access), but for
62+
some Hub datasets they come back as plain dicts — sometimes without counts at all
63+
(e.g. `{'name': 'think', 'dataset_name': ...}`). Handle every shape, defaulting to None.
64+
"""
65+
if isinstance(s, dict):
66+
return {"num_examples": s.get("num_examples"), "num_bytes": s.get("num_bytes")}
67+
return {"num_examples": getattr(s, "num_examples", None), "num_bytes": getattr(s, "num_bytes", None)}
68+
69+
5870
def _fail(message: str) -> None:
5971
out.error(message)
6072
raise typer.Exit(1)
@@ -169,10 +181,7 @@ def inspect(
169181
"configs": configs,
170182
"config": target,
171183
"features": _features_to_str(info.features),
172-
"splits": {
173-
name: {"num_examples": s.num_examples, "num_bytes": s.num_bytes}
174-
for name, s in (info.splits or {}).items()
175-
},
184+
"splits": {name: _split_summary(s) for name, s in (info.splits or {}).items()},
176185
"dataset_size_bytes": info.dataset_size,
177186
}
178187
out.dict(result)
@@ -194,15 +203,40 @@ def head(
194203
Note you cannot do `ds[0]` on a stream — in current `datasets` that returns a lazy
195204
column accessor, not the first row.
196205
"""
197-
from datasets import load_dataset
206+
from datasets import get_dataset_config_names, get_dataset_split_names, load_dataset
198207

199208
if json_out:
200209
out.set_mode(OutputFormat.json)
210+
211+
# Many Hub datasets have multiple configs and no default — resolve to the first so a bare
212+
# `hf ds head <repo>` just works instead of erroring with "Config name is missing".
213+
if config is None:
214+
try:
215+
cfgs = get_dataset_config_names(repo_id, token=token)
216+
except Exception as exc: # noqa: BLE001
217+
_fail(f"could not resolve configs for '{repo_id}': {type(exc).__name__}: {exc}")
218+
return
219+
if cfgs:
220+
config = cfgs[0]
221+
if len(cfgs) > 1:
222+
out.hint(f"multiple configs — showing '{config}'. others: {', '.join(cfgs[1:])}")
223+
224+
# The requested/default split may not exist (plenty of datasets have no 'train', e.g.
225+
# think/no_think or test-only). Fall back to the first available split.
226+
chosen = split
227+
try:
228+
avail = get_dataset_split_names(repo_id, config_name=config, token=token)
229+
except Exception: # noqa: BLE001 - best-effort; if this fails, just try the requested split
230+
avail = []
231+
if avail and split not in avail:
232+
chosen = avail[0]
233+
out.hint(f"split '{split}' not found — showing '{chosen}'. available: {', '.join(avail)}")
234+
201235
try:
202-
ds = load_dataset(repo_id, name=config, split=split, streaming=True, token=token)
236+
ds = load_dataset(repo_id, name=config, split=chosen, streaming=True, token=token)
203237
rows = list(ds.take(n))
204238
except Exception as exc: # noqa: BLE001
205-
_fail(f"could not stream '{repo_id}' (split='{split}'): {type(exc).__name__}: {exc}")
239+
_fail(f"could not stream '{repo_id}' (config='{config}', split='{chosen}'): {type(exc).__name__}: {exc}")
206240
return
207241
# out.table renders a padded table (human), TSV (agent), or JSON (json), and truncates
208242
# wide cells for humans automatically.

tests/test_cli.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
"""Deterministic unit tests for hf-ds helpers (no network)."""
2+
3+
from hf_ds.cli import _detect_builder, _render_recipe, _split_summary
4+
5+
6+
class _SplitInfoLike:
7+
num_examples = 10
8+
num_bytes = 100
9+
10+
11+
def test_split_summary_object():
12+
assert _split_summary(_SplitInfoLike()) == {"num_examples": 10, "num_bytes": 100}
13+
14+
15+
def test_split_summary_dict_with_counts():
16+
assert _split_summary({"num_examples": 5, "num_bytes": 50}) == {"num_examples": 5, "num_bytes": 50}
17+
18+
19+
def test_split_summary_dict_without_counts():
20+
# Regression: openbmb/UltraData-SFT-2605 returns {'name': ..., 'dataset_name': ...} dicts.
21+
assert _split_summary({"name": "think", "dataset_name": "x"}) == {"num_examples": None, "num_bytes": None}
22+
23+
24+
def test_render_recipe_substitutes_repo():
25+
out = _render_recipe("generator", "me/foo")
26+
assert "me/foo" in out
27+
assert "{repo}" not in out
28+
29+
30+
def test_detect_builder_by_extension(tmp_path):
31+
(tmp_path / "a.csv").write_text("x\n1\n")
32+
assert _detect_builder(tmp_path / "a.csv") == "csv"
33+
(tmp_path / "b.png").write_bytes(b"\x89PNG")
34+
# directory tally: 1 csv + 1 png -> a tie or csv/imagefolder; just assert it returns a known builder
35+
assert _detect_builder(tmp_path) in {"csv", "imagefolder"}

0 commit comments

Comments
 (0)