Files
Nero Frame 13 4617c47ad0 Added sanity check baseline made by claude.
Also changed the location to a panel in VIEW_3D instead of menu.
2026-10-13 16:33:03 +02:00

234 lines
7.2 KiB
Python

"""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 '<unsaved>'}",
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