Source code for yapcad.package.validator

"""Validation utilities for `.ycpkg` packages."""

from __future__ import annotations

import json
from pathlib import Path
from typing import Any, Dict, List, Tuple

from yapcad.io.geometry_json import geometry_from_json
from .core import PackageManifest, _compute_hash

# Valid source types per material schema spec
VALID_SOURCE_TYPES = {"standard", "vendor", "custom", "tested"}


def _check_file(path: Path, expected_hash: str | None) -> Tuple[bool, List[str]]:
    messages: List[str] = []
    if not path.exists():
        messages.append(f"ERROR: missing file {path}")
        return False, messages
    actual_hash = _compute_hash(path)
    if expected_hash and expected_hash.lower() != actual_hash.lower():
        messages.append(
            f"ERROR: hash mismatch for {path} (expected {expected_hash}, got {actual_hash})"
        )
        return False, messages
    return True, messages


def _validate_material(mat_id: str, mat_def: Dict[str, Any], strict: bool) -> Tuple[bool, List[str]]:
    """Validate a single material definition per the schema spec."""
    messages: List[str] = []
    ok = True

    # source.type is required
    source = mat_def.get("source")
    if not source:
        messages.append(f"ERROR: material '{mat_id}' missing required 'source' field")
        return False, messages

    source_type = source.get("type")
    if not source_type:
        messages.append(f"ERROR: material '{mat_id}' missing required 'source.type' field")
        ok = False
    elif source_type not in VALID_SOURCE_TYPES:
        messages.append(f"ERROR: material '{mat_id}' has invalid source.type '{source_type}'")
        ok = False
    else:
        # Validate source type-specific fields
        if source_type == "standard":
            std = source.get("standard", {})
            if not std.get("body"):
                messages.append(f"WARNING: material '{mat_id}' (type=standard) missing source.standard.body")
            if not std.get("designation"):
                messages.append(f"WARNING: material '{mat_id}' (type=standard) missing source.standard.designation")

    # Validate visual properties if present
    visual = mat_def.get("visual", {})
    color = visual.get("color")
    if color:
        if not isinstance(color, list) or len(color) != 3:
            messages.append(f"ERROR: material '{mat_id}' visual.color must be [R, G, B] array")
            ok = False
        elif not all(isinstance(c, (int, float)) and 0 <= c <= 1 for c in color):
            messages.append(f"WARNING: material '{mat_id}' visual.color values should be in [0, 1] range")

    metallic = visual.get("metallic")
    if metallic is not None:
        if not isinstance(metallic, (int, float)) or not (0 <= metallic <= 1):
            messages.append(f"WARNING: material '{mat_id}' visual.metallic should be in [0, 1] range")

    roughness = visual.get("roughness")
    if roughness is not None:
        if not isinstance(roughness, (int, float)) or not (0 <= roughness <= 1):
            messages.append(f"WARNING: material '{mat_id}' visual.roughness should be in [0, 1] range")

    # In strict mode, warn about missing engineering properties
    if strict and not mat_def.get("properties"):
        messages.append(f"WARNING: material '{mat_id}' has no physical properties defined")

    return ok, messages


def _validate_materials(materials: Dict[str, Any], strict: bool) -> Tuple[bool, List[str]]:
    """Validate all materials in the manifest."""
    messages: List[str] = []
    overall_ok = True

    if not isinstance(materials, dict):
        return False, ["ERROR: materials section must be a dictionary"]

    for mat_id, mat_def in materials.items():
        if not isinstance(mat_def, dict):
            messages.append(f"ERROR: material '{mat_id}' definition must be a dictionary")
            overall_ok = False
            continue
        ok, mat_messages = _validate_material(mat_id, mat_def, strict)
        messages.extend(mat_messages)
        if not ok:
            overall_ok = False

    return overall_ok, messages


[docs] def validate_package(path: Path | str, *, strict: bool = False) -> Tuple[bool, List[str]]: """Validate manifest and referenced artefacts. Returns (is_valid, messages). Messages are strings with severity prefixes. """ pkg_path = Path(path) messages: List[str] = [] try: manifest = PackageManifest.load(pkg_path) except Exception as exc: return False, [f"ERROR: failed to load manifest: {exc}"] data = manifest.data if data.get("schema") != "ycpkg-spec-v0.1": messages.append(f"ERROR: unsupported package schema {data.get('schema')}") # Geometry primary try: primary_path = manifest.geometry_primary_path() except Exception as exc: messages.append(f"ERROR: geometry.primary missing: {exc}") return False, messages ok, file_messages = _check_file(primary_path, data.get("geometry", {}).get("primary", {}).get("hash")) messages.extend(file_messages) if ok: try: with primary_path.open("r", encoding="utf-8") as fp: doc = json.load(fp) geometry_from_json(doc) except Exception as exc: messages.append(f"ERROR: invalid geometry JSON: {exc}") ok = False overall_ok = ok and not any(msg.startswith("ERROR") for msg in messages) # Derived geometry derived_entries = data.get("geometry", {}).get("derived", []) or [] for entry in derived_entries: derived_path = manifest.root / entry["path"] ok_entry, msgs = _check_file(derived_path, entry.get("hash")) messages.extend(msgs) overall_ok = overall_ok and ok_entry # Exports and attachments for section in ("exports", "attachments"): for entry in data.get(section, []) or []: file_path = manifest.root / entry["path"] ok_entry, msgs = _check_file(file_path, entry.get("hash")) messages.extend(msgs) overall_ok = overall_ok and ok_entry if strict and entry.get("hash") is None: messages.append(f"WARNING: {section} entry {entry.get('id')} missing hash") # Materials validation materials = data.get("materials") if materials: mat_ok, mat_msgs = _validate_materials(materials, strict) messages.extend(mat_msgs) overall_ok = overall_ok and mat_ok if overall_ok: messages.insert(0, f"OK: {pkg_path} passed validation") else: messages.insert(0, f"FAILED: {pkg_path} has validation errors") return overall_ok, messages
__all__ = ["validate_package"]