Added sanity check baseline made by claude.
Also changed the location to a panel in VIEW_3D instead of menu.
This commit is contained in:
1 parent
71b1a61b5c
commit
4617c47ad0
33 files changed
+2771
-52
No files matched your search
+233
@@ -0,0 +1,233 @@
|
||||
"""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
|
||||
Reference in new issue
Block a user