mirror of https://github.com/razor-ai/soup.git
380 lines
13 KiB
Python
380 lines
13 KiB
Python
"""Data loading from local files and HuggingFace."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from pathlib import Path
|
|
|
|
from rich.console import Console
|
|
|
|
from soup_cli.config.schema import DataConfig
|
|
from soup_cli.data.formats import (
|
|
detect_format,
|
|
format_to_messages,
|
|
is_audio_format,
|
|
is_vision_format,
|
|
)
|
|
|
|
console = Console()
|
|
|
|
# File extensions we support
|
|
SUPPORTED_EXTENSIONS = {".jsonl", ".json", ".csv", ".parquet", ".txt"}
|
|
|
|
|
|
def load_raw_data(path: Path) -> list[dict]:
|
|
"""Load raw data from a file into list of dicts."""
|
|
if not path.exists():
|
|
raise FileNotFoundError(f"Data file not found: {path}")
|
|
|
|
ext = path.suffix.lower()
|
|
if ext not in SUPPORTED_EXTENSIONS:
|
|
raise ValueError(f"Unsupported file format: {ext}. Supported: {SUPPORTED_EXTENSIONS}")
|
|
|
|
if ext == ".jsonl":
|
|
return _load_jsonl(path)
|
|
elif ext == ".json":
|
|
return _load_json(path)
|
|
elif ext == ".csv":
|
|
return _load_csv(path)
|
|
elif ext == ".parquet":
|
|
return _load_parquet(path)
|
|
elif ext == ".txt":
|
|
return _load_txt(path)
|
|
|
|
raise ValueError(f"Unsupported format: {ext}")
|
|
|
|
|
|
def _load_jsonl(path: Path) -> list[dict]:
|
|
data = []
|
|
# v0.40.1 Part E — auto-strip UTF-8 BOM (Windows users overwhelmingly
|
|
# write JSONL via PowerShell `Out-File -Encoding utf8` which adds BOM).
|
|
# The ``utf-8-sig`` codec consumes the BOM transparently if present.
|
|
with open(path, encoding="utf-8-sig") as f:
|
|
for i, line in enumerate(f):
|
|
line = line.strip()
|
|
if not line:
|
|
continue
|
|
try:
|
|
data.append(json.loads(line))
|
|
except json.JSONDecodeError as e:
|
|
console.print(f"[yellow]Warning: invalid JSON on line {i + 1}: {e}[/]")
|
|
return data
|
|
|
|
|
|
def _load_json(path: Path) -> list[dict]:
|
|
with open(path, encoding="utf-8") as f:
|
|
raw = json.load(f)
|
|
if isinstance(raw, list):
|
|
return raw
|
|
raise ValueError("JSON file must contain a list of objects")
|
|
|
|
|
|
def _load_csv(path: Path) -> list[dict]:
|
|
import csv
|
|
|
|
with open(path, encoding="utf-8") as f:
|
|
reader = csv.DictReader(f)
|
|
return list(reader)
|
|
|
|
|
|
def _load_parquet(path: Path) -> list[dict]:
|
|
try:
|
|
import pandas as pd
|
|
except ImportError:
|
|
raise ImportError("Install pandas to read parquet files: pip install pandas pyarrow")
|
|
df = pd.read_parquet(path)
|
|
return df.to_dict(orient="records")
|
|
|
|
|
|
def _load_txt(path: Path) -> list[dict]:
|
|
"""Load a plain text file as a list of {text: ...} dicts.
|
|
|
|
Each non-empty line is treated as a separate document.
|
|
Empty lines are skipped.
|
|
"""
|
|
file_size = path.stat().st_size
|
|
if file_size > 500 * 1024 * 1024: # 500 MB
|
|
console.print(
|
|
f"[yellow]Warning: large text file ({file_size / 1024 / 1024:.0f} MB). "
|
|
f"Consider splitting into smaller files or using JSONL format.[/]"
|
|
)
|
|
with open(path, encoding="utf-8") as f:
|
|
content = f.read()
|
|
|
|
# Split by double newline (paragraph/document separator) or treat each line as a doc
|
|
lines = [line.strip() for line in content.split("\n") if line.strip()]
|
|
if not lines:
|
|
console.print(f"[yellow]Warning: empty text file: {path}[/]")
|
|
return []
|
|
|
|
return [{"text": line} for line in lines]
|
|
|
|
|
|
def load_dataset(data_config: DataConfig) -> dict:
|
|
"""Load dataset for training. Returns dict with 'train' and optionally 'val' keys.
|
|
|
|
Supports:
|
|
- Local files (.jsonl, .json, .csv, .parquet, .txt)
|
|
- HuggingFace dataset names (auto-detected if no file extension)
|
|
- Remote fsspec URIs (s3://, gs://, gcs://, az://, abfs://, abfss://, oci://) — v0.53.8 #85
|
|
"""
|
|
train_path = data_config.train
|
|
|
|
# v0.53.8 #85 — fsspec live remote loader. Schema accepts these URIs
|
|
# since v0.42.0; live loader lands here. Lazy-imports fsspec + the
|
|
# backend driver (s3fs / gcsfs / adlfs / ocifs) and surfaces a
|
|
# friendly Rich panel naming the pip install when the driver is
|
|
# missing.
|
|
if _looks_like_remote_uri(train_path):
|
|
return _load_remote_dataset(train_path, data_config)
|
|
|
|
# Check if it's a HuggingFace dataset
|
|
if not Path(train_path).suffix:
|
|
return _load_hf_dataset(train_path, data_config)
|
|
|
|
# Local file
|
|
path = Path(train_path)
|
|
raw_data = load_raw_data(path)
|
|
|
|
# Detect or use specified format
|
|
fmt = data_config.format
|
|
if fmt == "auto":
|
|
fmt = detect_format(raw_data)
|
|
console.print(f"[dim]Auto-detected format: {fmt}[/]")
|
|
|
|
# Convert to standard message format
|
|
formatted = [format_to_messages(row, fmt) for row in raw_data]
|
|
formatted = [r for r in formatted if r is not None] # filter failed rows
|
|
|
|
# Validate image paths for vision formats
|
|
if is_vision_format(fmt):
|
|
image_dir = Path(data_config.image_dir) if data_config.image_dir else path.parent
|
|
formatted = _validate_vision_images(formatted, image_dir)
|
|
|
|
# Validate audio paths for audio formats
|
|
if is_audio_format(fmt):
|
|
audio_dir = Path(data_config.audio_dir) if data_config.audio_dir else path.parent
|
|
formatted = _validate_audio_files(formatted, audio_dir)
|
|
|
|
# Split into train/val
|
|
if data_config.val_split > 0:
|
|
split_idx = int(len(formatted) * (1 - data_config.val_split))
|
|
return {
|
|
"train": formatted[:split_idx],
|
|
"val": formatted[split_idx:],
|
|
}
|
|
|
|
return {"train": formatted}
|
|
|
|
|
|
def _validate_vision_images(data: list[dict], image_dir: Path) -> list[dict]:
|
|
"""Validate and resolve image paths in vision dataset rows.
|
|
|
|
Each row must have an 'image' key with a filename or path.
|
|
Resolves relative paths against image_dir.
|
|
"""
|
|
valid = []
|
|
missing = 0
|
|
for row in data:
|
|
if "image" not in row or not row["image"]:
|
|
missing += 1
|
|
continue
|
|
image_path = Path(row["image"])
|
|
if not image_path.is_absolute():
|
|
image_path = image_dir / image_path
|
|
row["image"] = str(image_path)
|
|
valid.append(row)
|
|
|
|
if missing > 0:
|
|
console.print(f"[yellow]Warning: {missing} rows skipped (missing image path)[/]")
|
|
return valid
|
|
|
|
|
|
def _validate_audio_files(data: list[dict], audio_dir: Path) -> list[dict]:
|
|
"""Validate and resolve audio file paths in audio dataset rows.
|
|
|
|
Each row must have an 'audio' key with a filename or path.
|
|
Resolves relative paths against audio_dir. Rejects path traversal.
|
|
"""
|
|
valid = []
|
|
missing = 0
|
|
traversal = 0
|
|
resolved_base = audio_dir.resolve()
|
|
for row in data:
|
|
if "audio" not in row or not row["audio"]:
|
|
missing += 1
|
|
continue
|
|
audio_path = Path(row["audio"])
|
|
if not audio_path.is_absolute():
|
|
audio_path = audio_dir / audio_path
|
|
# Path traversal protection: resolved path must stay under audio_dir
|
|
resolved = audio_path.resolve()
|
|
if not resolved.is_relative_to(resolved_base):
|
|
traversal += 1
|
|
continue
|
|
valid.append({**row, "audio": str(resolved)})
|
|
|
|
if missing > 0:
|
|
console.print(f"[yellow]Warning: {missing} rows skipped (missing audio path)[/]")
|
|
if traversal > 0:
|
|
console.print(
|
|
f"[red]Warning: {traversal} rows skipped (audio path traversal blocked)[/]"
|
|
)
|
|
return valid
|
|
|
|
|
|
def _looks_like_remote_uri(value: str) -> bool:
|
|
"""Quick sniff for the fsspec scheme allowlist (v0.42.0 Part B)."""
|
|
if not isinstance(value, str) or "://" not in value:
|
|
return False
|
|
from soup_cli.utils.data_pipeline import is_remote_uri
|
|
|
|
return is_remote_uri(value)
|
|
|
|
|
|
def _load_remote_dataset(train_path: str, data_config: DataConfig) -> dict:
|
|
"""Load JSONL from a remote fsspec URI (s3 / gs / az / oci / etc.).
|
|
|
|
Validates the URI via the v0.42.0 ``validate_remote_uri`` allowlist
|
|
(bucket regex, no userinfo/query/fragment) BEFORE opening any
|
|
connection — defends against URL injection into the fsspec backend.
|
|
|
|
Streaming knobs (``data_config.streaming`` + ``buffer_size`` + ``shards``)
|
|
are honoured via :func:`datasets.load_dataset` when present; otherwise
|
|
the file is streamed as JSONL through :func:`fsspec.open`.
|
|
"""
|
|
from soup_cli.utils.data_pipeline import (
|
|
required_remote_package,
|
|
validate_remote_uri,
|
|
)
|
|
|
|
canonical = validate_remote_uri(train_path)
|
|
scheme = canonical.split("://", 1)[0]
|
|
|
|
try:
|
|
import fsspec # type: ignore[import-not-found]
|
|
except ImportError:
|
|
from rich.panel import Panel
|
|
|
|
pkg = required_remote_package(scheme) or scheme
|
|
console.print(
|
|
Panel(
|
|
f"[bold yellow]Missing dependency:[/] reading from "
|
|
f"[bold]{scheme}://[/] requires the [bold]{pkg}[/] package.\n\n"
|
|
f"Install with:\n [bold]pip install {pkg}[/]",
|
|
title="Remote loader",
|
|
border_style="yellow",
|
|
)
|
|
)
|
|
raise
|
|
|
|
# Cap on rows materialised from a remote URI — matches v0.24.0
|
|
# ``soup data download --samples`` ceiling. Defends against OOM when a
|
|
# crafted / oversized bucket object is pointed at via streaming +
|
|
# eager-materialise.
|
|
max_remote_rows = 1_000_000
|
|
|
|
# Try the HF datasets streaming path first when the user opted in via
|
|
# ``data.streaming=true`` — gives us free interleaving, shuffling, and
|
|
# caching. Falls back to direct fsspec.open when datasets is missing or
|
|
# rejects the URI.
|
|
if data_config.streaming:
|
|
try:
|
|
from datasets import load_dataset as hf_load
|
|
except ImportError as exc:
|
|
raise ImportError(
|
|
"data.streaming=true requires the 'datasets' package: "
|
|
"pip install datasets"
|
|
) from exc
|
|
ds = hf_load(
|
|
"json",
|
|
data_files=canonical,
|
|
split="train",
|
|
streaming=True,
|
|
)
|
|
buf = data_config.buffer_size
|
|
if buf:
|
|
ds = ds.shuffle(buffer_size=buf)
|
|
# Eager materialise capped at max_remote_rows — emit a clear advisory
|
|
# if the cap trips.
|
|
raw_data: list[dict] = []
|
|
for i, row in enumerate(ds):
|
|
if i >= max_remote_rows:
|
|
console.print(
|
|
f"[yellow]Remote dataset truncated at {max_remote_rows:,} "
|
|
f"rows (use a local split for larger jobs).[/]"
|
|
)
|
|
break
|
|
raw_data.append(row)
|
|
else:
|
|
# Non-streaming: open once, read lines, decode JSON.
|
|
raw_data = []
|
|
with fsspec.open(canonical, mode="rt", encoding="utf-8-sig") as fh:
|
|
for i, raw_line in enumerate(fh):
|
|
if i >= max_remote_rows:
|
|
console.print(
|
|
f"[yellow]Remote dataset truncated at "
|
|
f"{max_remote_rows:,} rows.[/]"
|
|
)
|
|
break
|
|
stripped = raw_line.strip()
|
|
if not stripped:
|
|
continue
|
|
try:
|
|
raw_data.append(json.loads(stripped))
|
|
except json.JSONDecodeError as exc:
|
|
console.print(
|
|
f"[yellow]Warning: invalid JSON on line "
|
|
f"{i + 1}: {exc}[/]"
|
|
)
|
|
|
|
fmt = data_config.format
|
|
if fmt == "auto":
|
|
fmt = detect_format(raw_data)
|
|
console.print(f"[dim]Auto-detected format: {fmt}[/]")
|
|
|
|
formatted = [format_to_messages(row, fmt) for row in raw_data]
|
|
formatted = [r for r in formatted if r is not None]
|
|
|
|
if data_config.val_split > 0:
|
|
split_idx = int(len(formatted) * (1 - data_config.val_split))
|
|
return {
|
|
"train": formatted[:split_idx],
|
|
"val": formatted[split_idx:],
|
|
}
|
|
return {"train": formatted}
|
|
|
|
|
|
def _load_hf_dataset(name: str, data_config: DataConfig) -> dict:
|
|
"""Load a dataset from HuggingFace Hub."""
|
|
try:
|
|
from datasets import load_dataset as hf_load
|
|
except ImportError:
|
|
raise ImportError("Install datasets: pip install datasets")
|
|
|
|
console.print(f"[dim]Loading from HuggingFace: {name}[/]")
|
|
ds = hf_load(name)
|
|
|
|
if "train" not in ds:
|
|
raise ValueError(f"Dataset {name} has no 'train' split")
|
|
|
|
raw_data = [dict(row) for row in ds["train"]]
|
|
fmt = data_config.format
|
|
if fmt == "auto":
|
|
fmt = detect_format(raw_data)
|
|
|
|
formatted = [format_to_messages(row, fmt) for row in raw_data]
|
|
formatted = [r for r in formatted if r is not None]
|
|
|
|
if data_config.val_split > 0 and "validation" not in ds:
|
|
split_idx = int(len(formatted) * (1 - data_config.val_split))
|
|
return {"train": formatted[:split_idx], "val": formatted[split_idx:]}
|
|
|
|
result = {"train": formatted}
|
|
if "validation" in ds:
|
|
val_data = [dict(row) for row in ds["validation"]]
|
|
val_formatted = [format_to_messages(row, fmt) for row in val_data]
|
|
result["val"] = [r for r in val_formatted if r is not None]
|
|
|
|
return result
|