"""Run checks and keep the last report for the UI.""" from __future__ import annotations import enum import time import traceback from dataclasses import dataclass, field import bpy from . import contexts from .check import CheckContext, Issue, Severity, SkipCheck, checks_for_context, get_check class Status(enum.Enum): NOT_RUN = "Not run" PASSED = "Passed" INFO = "Info" WARNING = "Warning" ERROR = "Error" SKIPPED = "Skipped" CRASHED = "Crashed" _SEVERITY_STATUS = {Severity.INFO: Status.INFO, Severity.WARNING: Status.WARNING, Severity.ERROR: Status.ERROR} @dataclass class CheckResult: check_id: str status: Status = Status.NOT_RUN issues: list[Issue] = field(default_factory=list) message: str = "" # skip reason or traceback duration: float = 0.0 @property def failed(self): return self.status in {Status.WARNING, Status.ERROR} def to_dict(self): return { "check_id": self.check_id, "status": self.status.name, "message": self.message, "duration": round(self.duration, 4), "issues": [issue.to_dict() for issue in self.issues], } @dataclass class Report: context_id: str filepath: str results: dict[str, CheckResult] = field(default_factory=dict) def counts(self): counts = {status: 0 for status in Status} for result in self.results.values(): counts[result.status] += 1 return counts @property def has_errors(self): return any(r.status in {Status.ERROR, Status.CRASHED} for r in self.results.values()) def to_dict(self): return { "context": self.context_id, "filepath": self.filepath, "summary": {s.name: n for s, n in self.counts().items() if n}, "results": [r.to_dict() for r in self.results.values()], } def to_text(self): context = contexts.get_context(self.context_id) lines = [ "WTD Sanity Check report", f"File: {self.filepath or ''}", f"Context: {context.label if context else self.context_id}", "Summary: " + ", ".join(f"{s.value}: {n}" for s, n in self.counts().items() if n), "", ] for result in self.results.values(): cls = get_check(result.check_id) label = cls.label if cls else result.check_id lines.append(f"[{result.status.value.upper():8}] {label}") if result.message: lines.extend(" " + line for line in result.message.splitlines()) for issue in result.issues: severity = (issue.severity or (cls.severity if cls else Severity.ERROR)).name lines.append(f" - ({severity.lower()}) {issue.message}") return "\n".join(lines) # Last report, read by the UI. Reset on file load. LAST_REPORT: Report | None = None def get_result(check_id): if LAST_REPORT is None: return None return LAST_REPORT.results.get(check_id) def clear(): global LAST_REPORT LAST_REPORT = None def disabled_check_ids(scene): settings = getattr(scene, "wtd_sanity", None) if settings is None or not settings.disabled_checks: return set() return {name for name in settings.disabled_checks.split(";") if name} def _enter_object_mode(scene): """Leave edit/paint modes so mesh data is complete. Returns the mode to restore.""" context = bpy.context active = context.view_layer.objects.active if context.scene != scene or active is None or active.mode == "OBJECT": return None mode = active.mode bpy.ops.object.mode_set(mode="OBJECT") return mode def _restore_mode(mode): if mode is None: return try: bpy.ops.object.mode_set(mode=mode) except (RuntimeError, TypeError): pass # the active object may have been removed by a fix def _prepare(scene): # World matrices of objects changed by scripts are stale until evaluated. if bpy.context.scene == scene: bpy.context.view_layer.update() def run_check(check_cls, ctx): result = CheckResult(check_cls.id) start = time.perf_counter() try: issues = list(check_cls().run(ctx) or []) except SkipCheck as exc: result.status = Status.SKIPPED result.message = str(exc) except Exception: result.status = Status.CRASHED result.message = traceback.format_exc() traceback.print_exc() else: result.issues = issues if issues: worst = max(issue.severity or check_cls.severity for issue in issues) result.status = _SEVERITY_STATUS[worst] else: result.status = Status.PASSED result.duration = time.perf_counter() - start return result def run_checks(scene=None, context_id=None, check_ids=None, include_disabled=False): """Run the checks of a context and store the report in ``LAST_REPORT``. ``check_ids`` limits the run to some checks; their results are merged into the previous report when it was made for the same context. """ scene = scene or bpy.context.scene if context_id is None: context_id = scene.wtd_sanity.context previous_mode = _enter_object_mode(scene) try: report = _run(scene, context_id, check_ids, include_disabled) finally: _restore_mode(previous_mode) return report def _run(scene, context_id, check_ids, include_disabled): global LAST_REPORT _prepare(scene) ctx = CheckContext(scene, context_id) classes = checks_for_context(context_id) if check_ids is not None: classes = [cls for cls in classes if cls.id in check_ids] disabled = set() if include_disabled else disabled_check_ids(scene) if check_ids is not None and LAST_REPORT is not None and LAST_REPORT.context_id == context_id: report = LAST_REPORT report.filepath = bpy.data.filepath else: report = Report(context_id, bpy.data.filepath) # Keep the context ordering stable in the report. for cls in checks_for_context(context_id): report.results[cls.id] = CheckResult(cls.id) for cls in classes: if cls.id in disabled: report.results[cls.id] = CheckResult(cls.id, Status.SKIPPED, message="Disabled in this file") continue report.results[cls.id] = run_check(cls, ctx) LAST_REPORT = report return report def fix_check(check_id, scene=None): """Run the fix of a check on its last issues, then re-run it. Returns messages for issues that could not be fixed. """ scene = scene or bpy.context.scene cls = get_check(check_id) result = get_result(check_id) if cls is None or not cls.can_fix() or result is None or not result.issues: return [] context_id = LAST_REPORT.context_id previous_mode = _enter_object_mode(scene) try: messages = list(cls().fix(CheckContext(scene, context_id), result.issues) or []) except Exception as exc: traceback.print_exc() messages = [f"{cls.label}: fix failed ({exc.__class__.__name__}: {exc})"] finally: _restore_mode(previous_mode) run_checks(scene, context_id, check_ids={check_id}) return messages