234 lines
7.2 KiB
Python
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
|