unsloth/studio/backend/routes/data_recipe/seed.py
Daniel Han 797ddd201e
Fix Studio silently exiting on Windows without error output (#4527)
* Fix Studio silently exiting on Windows without error output

On Windows, `unsloth studio` launches a child process via
subprocess.Popen to run the server in the studio venv. If the child
crashes (e.g. due to a missing package), the parent just calls
typer.Exit(rc) with no message -- the user sees "Launching Unsloth
Studio... Please wait..." and then the prompt returns with zero
feedback.

Root cause: `data_designer_unstructured_seed` is imported at the top
level in seed.py. If this package is not installed in the studio venv,
the entire import chain (seed.py -> routes/__init__.py -> main.py ->
run_server()) crashes with ModuleNotFoundError. Since run.py has no
try/except around run_server() and studio.py does not report nonzero
exit codes, the failure is completely silent.

Changes:
- run.py: wrap run_server() in try/except, print clear error with
  traceback to stderr. Also reconfigure stderr encoding on Windows so
  tracebacks with non-ASCII paths do not cause secondary failures.
- studio.py: print an error message when the child process exits with
  a nonzero code on Windows, so the user knows something went wrong.
- seed.py: make data_designer_unstructured_seed import optional with
  a try/except fallback. The server starts normally and only returns
  HTTP 500 if the unstructured seed endpoints are actually called.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Skip Anaconda/Miniconda Python when creating Studio venv on Windows

Conda-bundled CPython ships modified DLL search paths that prevent
torch from loading c10.dll on Windows. The Studio server fails
silently at startup because the venv was created with conda's Python.

Standalone CPython (python.org, winget, uv) does not have this issue.

Both install.ps1 and setup.ps1 now skip any Python binary whose path
contains conda, miniconda, anaconda, miniforge, or mambaforge when
selecting the interpreter for the studio venv. If only conda Python
is available, the scripts print an error with instructions to install
standalone CPython.

* Fix multi-file preview crash and improve setup.ps1 Python discovery

Addresses review findings [10/10] and [8/10]:

1. seed.py: _read_preview_rows_from_multi_files() had a hard import
   of build_multi_file_preview_rows inside the function body, bypassing
   the optional-plugin guard. Moved it into the top-level try/except
   block and added a None guard matching the other functions.

2. setup.ps1: Python discovery now probes py.exe (Python Launcher)
   first, uses Get-Command -All to look past conda entries that shadow
   standalone CPython further down PATH, skips WindowsApps stubs, and
   resolves the actual executable path so venv creation does not
   re-resolve back to a conda interpreter.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Check sys.base_prefix to catch venvs created from conda Python

A venv created from conda Python (e.g. C:\Users\danie\.venv) has a
path that does not contain "conda", but sys.base_prefix still points
to the conda install (e.g. C:\Users\danie\miniconda3). The previous
path-only check missed this case entirely.

Both install.ps1 and setup.ps1 now use a Test-IsConda helper that
checks both the executable path AND sys.base_prefix against the
conda/miniconda/anaconda/miniforge/mambaforge pattern. This catches:
- Direct conda Python executables
- Venvs created from conda Python (base_prefix reveals the origin)

* Fix install.ps1 passing version string to uv venv instead of resolved path

Find-CompatiblePython returned a bare version string (e.g. "3.13")
which was passed to `uv venv --python 3.13`. uv performs its own
interpreter discovery and can resolve that version string back to a
conda Python, defeating the entire conda-skip logic.

Now Find-CompatiblePython returns a hashtable with both .Version (for
display) and .Path (the resolved absolute executable path). The venv
is created with `uv venv --python <absolute-path>`, ensuring uv uses
the exact interpreter we validated.

* Quote resolved Python path in uv venv call for paths with spaces

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-03-22 08:23:03 -07:00

629 lines
21 KiB
Python

# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""Seed inspect endpoints for data recipe."""
from __future__ import annotations
import base64
import binascii
import json
import re
from itertools import islice
from pathlib import Path
from typing import Any
from uuid import uuid4
from fastapi import APIRouter, HTTPException, UploadFile, File as FastAPIFile, Form
try:
from data_designer_unstructured_seed.chunking import (
build_multi_file_preview_rows,
build_unstructured_preview_rows,
normalize_unstructured_text,
resolve_chunking,
)
except ImportError:
build_multi_file_preview_rows = None
build_unstructured_preview_rows = None
normalize_unstructured_text = None
resolve_chunking = None
from core.data_recipe.jsonable import to_preview_jsonable
from utils.paths import ensure_dir, seed_uploads_root, unstructured_uploads_root
from models.data_recipe import (
SeedInspectRequest,
SeedInspectResponse,
SeedInspectUploadRequest,
UnstructuredFileUploadResponse,
)
router = APIRouter()
DATA_EXTS = (".parquet", ".jsonl", ".json", ".csv")
DEFAULT_SPLIT = "train"
LOCAL_UPLOAD_EXTS = {".csv", ".json", ".jsonl"}
UNSTRUCTURED_ALLOWED_EXTS = {".pdf", ".docx", ".txt", ".md"}
SEED_UPLOAD_DIR = seed_uploads_root()
UNSTRUCTURED_UPLOAD_ROOT = unstructured_uploads_root()
MAX_FILE_SIZE = 50 * 1024 * 1024 # 50MB
MAX_TOTAL_SIZE = 100 * 1024 * 1024 # 100MB
_SAFE_ID_RE = re.compile(r"^[a-zA-Z0-9_-]+$")
def _validate_safe_id(value: str, label: str) -> str:
if not value or not _SAFE_ID_RE.match(value):
raise HTTPException(
400, f"Invalid {label}: must be alphanumeric/dash/underscore only"
)
return value
def _serialize_preview_value(value: Any) -> Any:
return to_preview_jsonable(value)
def _serialize_preview_rows(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
return [
{str(key): _serialize_preview_value(value) for key, value in row.items()}
for row in rows
]
def _normalize_optional_text(value: str | None) -> str | None:
if value is None:
return None
trimmed = value.strip()
return trimmed if trimmed else None
def _list_hf_data_files(*, dataset_name: str, token: str | None) -> list[str]:
try:
from huggingface_hub import HfApi
from huggingface_hub.utils import HfHubHTTPError
except ImportError:
return []
try:
api = HfApi()
repo_files = api.list_repo_files(dataset_name, repo_type = "dataset", token = token)
return [file for file in repo_files if file.lower().endswith(DATA_EXTS)]
except (HfHubHTTPError, OSError, ValueError):
return []
def _select_best_file(data_files: list[str], split: str = DEFAULT_SPLIT) -> str | None:
if not data_files:
return None
split_lower = split.lower()
def score(path: str) -> tuple[int, int]:
name = path.lower()
if f"/{split_lower}/" in name:
return (0, len(path))
if (
f"_{split_lower}." in name
or f"-{split_lower}." in name
or f"/{split_lower}." in name
or f"/{split_lower}_" in name
or f"/{split_lower}-" in name
):
return (1, len(path))
return (2, len(path))
return sorted(data_files, key = score)[0]
def _resolve_seed_hf_path(
dataset_name: str, data_files: list[str], split: str = DEFAULT_SPLIT
) -> str | None:
selected = _select_best_file(data_files, split)
if not selected:
return None
ext = Path(selected).suffix.lower()
if ext not in DATA_EXTS:
return f"datasets/{dataset_name}/{selected}"
parent = Path(selected).parent.as_posix()
if not parent or parent == ".":
return f"datasets/{dataset_name}/**/*{ext}"
return f"datasets/{dataset_name}/{parent}/**/*{ext}"
def _build_stream_load_kwargs(
*,
dataset_name: str,
split: str,
subset: str | None,
token: str | None,
data_file: str | None = None,
) -> dict[str, Any]:
kwargs: dict[str, Any] = {
"path": dataset_name,
"split": split,
"streaming": True,
"trust_remote_code": False,
}
if data_file:
kwargs["data_files"] = [data_file]
if subset:
kwargs["name"] = subset
if token:
kwargs["token"] = token
return kwargs
def _load_preview_rows(
*,
load_dataset_fn,
load_kwargs: dict[str, Any],
preview_size: int,
) -> list[dict[str, Any]]:
streamed_ds = load_dataset_fn(**load_kwargs)
return [row for row in islice(streamed_ds, preview_size)]
def _extract_columns(rows: list[dict[str, Any]]) -> list[str]:
columns_seen: dict[str, None] = {}
for row in rows:
for key in row.keys():
columns_seen[str(key)] = None
return list(columns_seen.keys())
def _sanitize_filename(filename: str) -> str:
name = Path(filename).name.strip().replace("\x00", "")
if not name:
return "seed_upload"
return name
def _decode_base64_payload(content_base64: str) -> bytes:
raw = content_base64.strip()
if "," in raw and raw.lower().startswith("data:"):
raw = raw.split(",", 1)[1]
try:
return base64.b64decode(raw, validate = True)
except binascii.Error as exc:
raise HTTPException(status_code = 400, detail = "invalid base64 payload") from exc
def _read_preview_rows_from_local_file(
path: Path, preview_size: int
) -> list[dict[str, Any]]:
try:
import pandas as pd
except ImportError as exc:
raise HTTPException(
status_code = 500, detail = f"seed inspect dependencies unavailable: {exc}"
) from exc
ext = path.suffix.lower()
try:
if ext == ".csv":
df = pd.read_csv(path, nrows = preview_size, encoding = "utf-8-sig")
df.columns = df.columns.str.strip()
unnamed = [c for c in df.columns if c == "" or c.startswith("Unnamed:")]
if unnamed:
df = df.drop(columns = unnamed)
full_df = pd.read_csv(path, encoding = "utf-8-sig")
full_df.columns = full_df.columns.str.strip()
full_df = full_df.drop(columns = unnamed)
tmp_csv = path.with_suffix(".tmp.csv")
full_df.to_csv(tmp_csv, index = False, encoding = "utf-8")
tmp_csv.replace(path)
elif ext == ".jsonl":
df = pd.read_json(path, lines = True).head(preview_size)
elif ext == ".json":
try:
df = pd.read_json(path).head(preview_size)
except ValueError:
df = pd.read_json(path, lines = True).head(preview_size)
else:
raise HTTPException(status_code = 422, detail = f"unsupported file type: {ext}")
except HTTPException:
raise
except (ValueError, OSError) as exc:
raise HTTPException(
status_code = 422, detail = f"seed inspect failed: {exc}"
) from exc
rows = df.to_dict(orient = "records")
return _serialize_preview_rows(rows)
def _read_preview_rows_from_unstructured_file(
*,
path: Path,
preview_size: int,
chunk_size: int | None,
chunk_overlap: int | None,
) -> list[dict[str, Any]]:
if resolve_chunking is None or build_unstructured_preview_rows is None:
raise HTTPException(
500,
"Unstructured seed support not available (missing data_designer_unstructured_seed)",
)
size, overlap = resolve_chunking(chunk_size, chunk_overlap)
try:
rows = build_unstructured_preview_rows(
source_path = path,
preview_size = preview_size,
chunk_size = size,
chunk_overlap = overlap,
)
except (FileNotFoundError, RuntimeError, ValueError, OSError) as exc:
raise HTTPException(
status_code = 422, detail = f"seed inspect failed: {exc}"
) from exc
return _serialize_preview_rows(rows)
def _read_preview_rows_from_multi_files(
*,
block_id: str,
file_ids: list[str],
file_names: list[str],
preview_size: int,
chunk_size: int | None,
chunk_overlap: int | None,
) -> list[dict[str, str]]:
if build_multi_file_preview_rows is None:
raise HTTPException(
500,
"Unstructured seed support not available (missing data_designer_unstructured_seed)",
)
_validate_safe_id(block_id, "block_id")
block_dir = UNSTRUCTURED_UPLOAD_ROOT / block_id
file_entries: list[tuple[Path, str]] = []
for fid, fname in zip(file_ids, file_names):
extracted = block_dir / f"{fid}.extracted.txt"
if not extracted.exists():
raise HTTPException(
404, f"Extracted text not found for file: {fname} (id: {fid})"
)
file_entries.append((extracted, fname))
return build_multi_file_preview_rows(
file_entries = file_entries,
preview_size = preview_size,
chunk_size = chunk_size,
chunk_overlap = chunk_overlap,
)
@router.post("/seed/inspect", response_model = SeedInspectResponse)
def inspect_seed_dataset(payload: SeedInspectRequest) -> SeedInspectResponse:
dataset_name = payload.dataset_name.strip()
if not dataset_name or dataset_name.count("/") < 1:
raise HTTPException(
status_code = 400,
detail = "dataset_name must be a Hugging Face repo id like org/repo",
)
try:
from datasets import load_dataset
except ImportError as exc:
raise HTTPException(
status_code = 500, detail = f"seed inspect dependencies unavailable: {exc}"
) from exc
split = _normalize_optional_text(payload.split) or DEFAULT_SPLIT
subset = _normalize_optional_text(payload.subset)
token = _normalize_optional_text(payload.hf_token)
preview_size = int(payload.preview_size)
preview_rows: list[dict[str, Any]] = []
data_files = _list_hf_data_files(dataset_name = dataset_name, token = token)
selected_file = _select_best_file(data_files, split)
if selected_file:
try:
single_file_kwargs = _build_stream_load_kwargs(
dataset_name = dataset_name,
split = split,
subset = subset,
token = token,
data_file = selected_file,
)
preview_rows = _load_preview_rows(
load_dataset_fn = load_dataset,
load_kwargs = single_file_kwargs,
preview_size = preview_size,
)
except (ValueError, OSError, RuntimeError):
preview_rows = []
if not preview_rows:
try:
split_kwargs = _build_stream_load_kwargs(
dataset_name = dataset_name,
split = split,
subset = subset,
token = token,
)
preview_rows = _load_preview_rows(
load_dataset_fn = load_dataset,
load_kwargs = split_kwargs,
preview_size = preview_size,
)
except (ValueError, OSError, RuntimeError) as exc:
raise HTTPException(
status_code = 422, detail = f"seed inspect failed: {exc}"
) from exc
if not preview_rows:
raise HTTPException(
status_code = 422, detail = "dataset appears empty or unreadable"
)
preview_rows = _serialize_preview_rows(preview_rows)
columns = _extract_columns(preview_rows)
if not data_files:
resolved_path = f"datasets/{dataset_name}/**/*.parquet"
else:
resolved_path = _resolve_seed_hf_path(dataset_name, data_files, split)
if not resolved_path:
raise HTTPException(
status_code = 422, detail = "unable to resolve seed dataset path"
)
return SeedInspectResponse(
dataset_name = dataset_name,
resolved_path = resolved_path,
columns = columns,
preview_rows = preview_rows,
split = split,
subset = subset,
)
def _extract_text_from_file(file_path: Path, ext: str) -> str:
"""Extract text from uploaded file based on extension, converting to markdown where possible."""
if ext in {".txt", ".md"}:
raw = file_path.read_text(encoding = "utf-8", errors = "ignore")
elif ext == ".pdf":
import pymupdf4llm
raw = pymupdf4llm.to_markdown(
str(file_path), write_images = False, show_progress = False
)
elif ext == ".docx":
import mammoth
with open(str(file_path), "rb") as f:
result = mammoth.convert_to_markdown(f)
raw = result.value
else:
raise ValueError(f"Unsupported file type: {ext}")
if normalize_unstructured_text is None:
return raw
return normalize_unstructured_text(raw)
def _get_block_total_size(block_dir: Path, file_ids: list[str]) -> int:
"""Sum raw upload sizes for tracked file IDs only."""
if not block_dir.exists() or not file_ids:
return 0
id_set = set(file_ids)
total = 0
for f in block_dir.iterdir():
if not f.is_file():
continue
if f.name.endswith(".extracted.txt") or f.name.endswith(".meta.json"):
continue
stem = f.name.split(".")[0]
if stem in id_set:
total += f.stat().st_size
return total
@router.post("/seed/upload-unstructured-file")
async def upload_unstructured_file(
file: UploadFile = FastAPIFile(...),
block_id: str = Form(...),
existing_file_ids: str = Form(""),
) -> UnstructuredFileUploadResponse:
_validate_safe_id(block_id, "block_id")
tracked_ids = [fid.strip() for fid in existing_file_ids.split(",") if fid.strip()]
original_filename = file.filename or "upload"
ext = Path(original_filename).suffix.lower()
if ext not in UNSTRUCTURED_ALLOWED_EXTS:
raise HTTPException(
400,
f"Unsupported file type: {ext}. Allowed: {', '.join(sorted(UNSTRUCTURED_ALLOWED_EXTS))}",
)
content = await file.read()
size_bytes = len(content)
if size_bytes == 0:
raise HTTPException(400, "Empty file not allowed")
if size_bytes > MAX_FILE_SIZE:
raise HTTPException(
413, f"File too large ({size_bytes} bytes). Maximum is 50MB."
)
block_dir = UNSTRUCTURED_UPLOAD_ROOT / block_id
ensure_dir(block_dir)
current_total = _get_block_total_size(block_dir, file_ids = tracked_ids)
if current_total + size_bytes > MAX_TOTAL_SIZE:
raise HTTPException(
413, f"Total upload limit ({MAX_TOTAL_SIZE // (1024 * 1024)}MB) exceeded"
)
file_id = uuid4().hex
raw_path = block_dir / f"{file_id}{ext}"
raw_path.write_bytes(content)
extracted_path = block_dir / f"{file_id}.extracted.txt"
try:
extracted_text = _extract_text_from_file(raw_path, ext)
if not extracted_text or not extracted_text.strip():
raw_path.unlink(missing_ok = True)
return UnstructuredFileUploadResponse(
file_id = file_id,
filename = original_filename,
size_bytes = size_bytes,
status = "error",
error = "No extractable text found in file",
)
extracted_path.write_text(extracted_text, encoding = "utf-8")
except Exception as e:
raw_path.unlink(missing_ok = True)
extracted_path.unlink(missing_ok = True)
return UnstructuredFileUploadResponse(
file_id = file_id,
filename = original_filename,
size_bytes = size_bytes,
status = "error",
error = f"Text extraction failed: {type(e).__name__}: {e}",
)
try:
meta_path = block_dir / f"{file_id}.meta.json"
meta_path.write_text(
json.dumps(
{"original_filename": original_filename, "size_bytes": size_bytes}
),
encoding = "utf-8",
)
except OSError:
raw_path.unlink(missing_ok = True)
extracted_path.unlink(missing_ok = True)
return UnstructuredFileUploadResponse(
file_id = file_id,
filename = original_filename,
size_bytes = size_bytes,
status = "error",
error = "Failed to save file metadata",
)
return UnstructuredFileUploadResponse(
file_id = file_id,
filename = original_filename,
size_bytes = size_bytes,
status = "ok",
)
@router.delete("/seed/unstructured-file/{block_id}/{file_id}")
async def remove_unstructured_file(block_id: str, file_id: str):
_validate_safe_id(block_id, "block_id")
_validate_safe_id(file_id, "file_id")
block_dir = UNSTRUCTURED_UPLOAD_ROOT / block_id
if not block_dir.exists():
raise HTTPException(404, "Block not found")
deleted = False
for f in block_dir.iterdir():
stem = f.name.split(".")[0]
if stem == file_id:
f.unlink(missing_ok = True)
deleted = True
if not deleted:
raise HTTPException(404, "File not found")
try:
if not any(block_dir.iterdir()):
block_dir.rmdir()
except OSError:
pass
return {"status": "ok"}
@router.post("/seed/inspect-upload", response_model = SeedInspectResponse)
def inspect_seed_upload(payload: SeedInspectUploadRequest) -> SeedInspectResponse:
if payload.file_ids is not None:
if len(payload.file_ids) == 0:
raise HTTPException(400, "file_ids must not be empty")
_validate_safe_id(payload.block_id, "block_id")
for fid in payload.file_ids:
_validate_safe_id(fid, "file_id")
preview_rows = _read_preview_rows_from_multi_files(
block_id = payload.block_id,
file_ids = payload.file_ids,
file_names = payload.file_names,
preview_size = payload.preview_size,
chunk_size = payload.unstructured_chunk_size,
chunk_overlap = payload.unstructured_chunk_overlap,
)
columns = ["chunk_text", "source_file"] if preview_rows else []
resolved_paths = [
str(UNSTRUCTURED_UPLOAD_ROOT / payload.block_id / f"{fid}.extracted.txt")
for fid in payload.file_ids
]
return SeedInspectResponse(
dataset_name = "unstructured_seed",
resolved_path = resolved_paths[0] if resolved_paths else "",
resolved_paths = resolved_paths,
columns = columns,
preview_rows = _serialize_preview_rows(preview_rows),
)
seed_source_type = _normalize_optional_text(payload.seed_source_type) or "local"
filename = _sanitize_filename(payload.filename)
ext = Path(filename).suffix.lower()
# Legacy single-file unstructured path only supports .txt/.md
# PDF/DOCX extraction uses the multi-file upload endpoint instead
_LEGACY_UNSTRUCTURED_EXTS = {".txt", ".md"}
if seed_source_type == "unstructured":
if ext not in _LEGACY_UNSTRUCTURED_EXTS:
allowed = ", ".join(sorted(_LEGACY_UNSTRUCTURED_EXTS))
raise HTTPException(
status_code = 400,
detail = f"unsupported file type: {ext}. allowed: {allowed}",
)
else:
if ext not in LOCAL_UPLOAD_EXTS:
allowed = ", ".join(sorted(LOCAL_UPLOAD_EXTS))
raise HTTPException(
status_code = 400,
detail = f"unsupported file type: {ext}. allowed: {allowed}",
)
file_bytes = _decode_base64_payload(payload.content_base64)
if not file_bytes:
raise HTTPException(status_code = 400, detail = "empty upload payload")
if len(file_bytes) > MAX_FILE_SIZE:
raise HTTPException(status_code = 413, detail = "file too large (max 50MB)")
ensure_dir(SEED_UPLOAD_DIR)
stored_name = f"{uuid4().hex}_{filename}"
stored_path = SEED_UPLOAD_DIR / stored_name
stored_path.write_bytes(file_bytes)
if seed_source_type == "unstructured":
preview_rows = _read_preview_rows_from_unstructured_file(
path = stored_path,
preview_size = int(payload.preview_size),
chunk_size = payload.unstructured_chunk_size,
chunk_overlap = payload.unstructured_chunk_overlap,
)
else:
preview_rows = _read_preview_rows_from_local_file(
stored_path,
int(payload.preview_size),
)
if not preview_rows:
raise HTTPException(
status_code = 422, detail = "dataset appears empty or unreadable"
)
columns = _extract_columns(preview_rows)
return SeedInspectResponse(
dataset_name = filename,
resolved_path = str(stored_path),
columns = columns,
preview_rows = preview_rows,
split = None,
subset = None,
)