Raise ruff line-length to 100 and extend the local pre-commit format pipeline (def-signature magic-comma normalization, short multi-line assert collapse, kwarg '=' spacing, blank-line-after-short-import removal, adjacent string-literal / f-string+plain merge, redundant-pass pruning). Every transform re-checks the file AST and is dropped if it would differ; the whole-repo reformat is verified AST-identical per file and idempotent.
206 lines
7.4 KiB
Python
206 lines
7.4 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
|
|
|
|
"""Validation endpoints for data recipe."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Any
|
|
|
|
from fastapi import APIRouter
|
|
|
|
from core.data_recipe.service import (
|
|
build_config_builder,
|
|
create_data_designer,
|
|
validate_recipe,
|
|
)
|
|
from loggers import get_logger
|
|
from models.data_recipe import RecipePayload, ValidateError, ValidateResponse
|
|
from utils.utils import safe_error_detail, safe_curated_detail, log_and_http_error
|
|
|
|
logger = get_logger(__name__)
|
|
router = APIRouter()
|
|
|
|
_GITHUB_VALIDATE_NOTE = (
|
|
"Recipe shape is valid. GitHub access and rate limits are checked when the run starts."
|
|
)
|
|
_GITHUB_ITEM_TYPES = {"issues", "pulls", "commits"}
|
|
|
|
|
|
def _github_seed_source(recipe: dict[str, Any]) -> dict[str, Any] | None:
|
|
seed_config = recipe.get("seed_config")
|
|
if not isinstance(seed_config, dict):
|
|
return None
|
|
source = seed_config.get("source")
|
|
if not isinstance(source, dict) or source.get("seed_type") != "github_repo":
|
|
return None
|
|
return source
|
|
|
|
|
|
def _validate_github_seed_static(source: dict[str, Any]) -> list[ValidateError]:
|
|
errors: list[ValidateError] = []
|
|
|
|
repos = source.get("repos")
|
|
if not isinstance(repos, list) or not repos:
|
|
errors.append(ValidateError(message = "GitHub seed requires at least one repo."))
|
|
else:
|
|
for repo in repos:
|
|
if not isinstance(repo, str) or not repo.strip() or "/" not in repo:
|
|
errors.append(ValidateError(message = "GitHub repos must be owner/name strings."))
|
|
break
|
|
|
|
item_types = source.get("item_types")
|
|
if not isinstance(item_types, list) or not item_types:
|
|
errors.append(ValidateError(message = "GitHub seed requires at least one item type."))
|
|
else:
|
|
invalid_items = [item for item in item_types if item not in _GITHUB_ITEM_TYPES]
|
|
if invalid_items:
|
|
errors.append(
|
|
ValidateError(message = "GitHub item types must be issues, pulls, or commits.")
|
|
)
|
|
|
|
try:
|
|
limit = int(source.get("limit"))
|
|
except (TypeError, ValueError):
|
|
limit = 0
|
|
if limit < 1 or limit > 5000:
|
|
errors.append(ValidateError(message = "GitHub limit must be from 1 to 5000."))
|
|
|
|
return errors
|
|
|
|
|
|
def _collect_validation_errors(recipe: dict[str, Any]) -> list[ValidateError]:
|
|
try:
|
|
from data_designer.engine.compiler import (
|
|
_add_internal_row_id_column_if_needed,
|
|
_get_allowed_references,
|
|
_resolve_and_add_seed_columns,
|
|
)
|
|
from data_designer.engine.validation import (
|
|
ViolationLevel,
|
|
validate_data_designer_config,
|
|
)
|
|
except ImportError:
|
|
return []
|
|
|
|
try:
|
|
builder = build_config_builder(recipe)
|
|
designer = create_data_designer(recipe)
|
|
resource_provider = designer._create_resource_provider( # type: ignore[attr-defined]
|
|
"validate-configuration",
|
|
builder,
|
|
)
|
|
config = builder.build()
|
|
_resolve_and_add_seed_columns(config, resource_provider.seed_reader)
|
|
_add_internal_row_id_column_if_needed(config)
|
|
violations = validate_data_designer_config(
|
|
columns = config.columns,
|
|
processor_configs = config.processors or [],
|
|
allowed_references = _get_allowed_references(config),
|
|
)
|
|
except (TypeError, ValueError, AttributeError):
|
|
return []
|
|
|
|
errors: list[ValidateError] = []
|
|
for violation in violations:
|
|
if violation.level != ViolationLevel.ERROR:
|
|
continue
|
|
code = getattr(violation.type, "value", None)
|
|
path = violation.column if violation.column else None
|
|
message = str(violation.message).strip() or "Validation failed."
|
|
errors.append(
|
|
ValidateError(
|
|
message = message,
|
|
path = path,
|
|
code = code,
|
|
)
|
|
)
|
|
return errors
|
|
|
|
|
|
def _patch_local_providers(recipe: dict[str, Any]) -> None:
|
|
"""Strip is_local and fill a dummy endpoint so validation doesn't choke.
|
|
|
|
Uses a strict `is True` check to match _inject_local_providers in
|
|
jobs.py - malformed payloads with truthy but non-boolean is_local
|
|
values should not be treated as local.
|
|
"""
|
|
for provider in recipe.get("model_providers", []):
|
|
if not isinstance(provider, dict):
|
|
continue
|
|
if provider.pop("is_local", None) is True:
|
|
provider["endpoint"] = "http://127.0.0.1"
|
|
|
|
|
|
@router.post("/validate", response_model = ValidateResponse)
|
|
def validate(payload: RecipePayload) -> ValidateResponse:
|
|
recipe = payload.recipe
|
|
if not recipe.get("columns"):
|
|
return ValidateResponse(
|
|
valid = False,
|
|
errors = [ValidateError(message = "Recipe must include columns.")],
|
|
)
|
|
|
|
_patch_local_providers(recipe)
|
|
|
|
github_source = _github_seed_source(recipe)
|
|
if github_source is not None:
|
|
static_errors = _validate_github_seed_static(github_source)
|
|
if static_errors:
|
|
return ValidateResponse(valid = False, errors = static_errors)
|
|
try:
|
|
build_config_builder(recipe)
|
|
except ModuleNotFoundError as exc:
|
|
# data_designer is an optional runtime dep. Static validation
|
|
# already passed; live access + full config validation are
|
|
# deferred to run start (per _GITHUB_VALIDATE_NOTE), so a missing
|
|
# optional import at validate time should not block the recipe.
|
|
# Restrict the bypass to the data_designer module specifically so
|
|
# other ImportErrors (e.g. broken internal imports or missing
|
|
# transitive deps after a package upgrade) still surface as
|
|
# validation failures instead of being silently swallowed.
|
|
if not (exc.name or "").startswith("data_designer"):
|
|
raise
|
|
logger.debug(
|
|
"data_designer not installed; deferring full config validation to run start",
|
|
missing_module = exc.name,
|
|
)
|
|
except Exception as exc:
|
|
logger.error(
|
|
"data_recipe.validate.github_config_failed",
|
|
error = str(exc),
|
|
exc_info = True,
|
|
)
|
|
detail = safe_error_detail(exc, fallback = "Validation failed.")
|
|
return ValidateResponse(
|
|
valid = False,
|
|
errors = [ValidateError(message = detail)],
|
|
raw_detail = detail,
|
|
)
|
|
return ValidateResponse(valid = True, raw_detail = _GITHUB_VALIDATE_NOTE)
|
|
|
|
try:
|
|
validate_recipe(recipe)
|
|
except RuntimeError as exc:
|
|
raise log_and_http_error(
|
|
exc,
|
|
503,
|
|
safe_error_detail(exc),
|
|
event = "data_recipe.validate.service_unavailable",
|
|
log = logger,
|
|
) from exc
|
|
except Exception as exc:
|
|
logger.error(
|
|
"data_recipe.validate.recipe_failed",
|
|
error = str(exc),
|
|
exc_info = True,
|
|
)
|
|
detail = safe_curated_detail(exc, fallback = "Validation failed.")
|
|
parsed_errors = _collect_validation_errors(recipe)
|
|
return ValidateResponse(
|
|
valid = False,
|
|
errors = parsed_errors or [ValidateError(message = detail)],
|
|
raw_detail = detail,
|
|
)
|
|
|
|
return ValidateResponse(valid = True)
|