257 lines
7.5 KiB
Python
257 lines
7.5 KiB
Python
"""Base classes for sanity checks.
|
|
|
|
Writing a check
|
|
---------------
|
|
Subclass ``Check`` in any module under ``checks/``. A class that defines its
|
|
own ``id`` is registered automatically::
|
|
|
|
class NoSuzanne(Check):
|
|
id = "no_suzanne"
|
|
label = "No Suzanne"
|
|
description = "Monkeys are not allowed in production files."
|
|
category = "Objects"
|
|
contexts = contexts.ASSET
|
|
|
|
def run(self, ctx):
|
|
for ob in ctx.objects("MESH"):
|
|
if "Suzanne" in ob.name:
|
|
yield Issue.for_id(f"{ob.name} looks like a monkey", ob)
|
|
|
|
# Optional. Receives the issues returned by run().
|
|
def fix(self, ctx, issues):
|
|
for issue in issues:
|
|
bpy.data.objects.remove(issue.get_id())
|
|
|
|
``run`` yields (or returns a list of) ``Issue``. No issues means passed.
|
|
Raise ``SkipCheck("reason")`` when the check cannot be evaluated.
|
|
``fix`` may return messages describing what could not be fixed.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import enum
|
|
import functools
|
|
import os
|
|
import re
|
|
from dataclasses import dataclass, field
|
|
from typing import Any, ClassVar, Iterable
|
|
|
|
import bpy
|
|
|
|
from .. import config
|
|
|
|
ADDON_ID = __package__.rpartition(".")[0]
|
|
|
|
|
|
class Severity(enum.IntEnum):
|
|
INFO = 1
|
|
WARNING = 2
|
|
ERROR = 3
|
|
|
|
|
|
class SkipCheck(Exception):
|
|
"""Raised from ``Check.run`` when the check cannot be evaluated."""
|
|
|
|
|
|
# bpy.types.ID.id_type -> bpy.data collection name
|
|
_ID_COLLECTIONS = {
|
|
"ACTION": "actions",
|
|
"ARMATURE": "armatures",
|
|
"CAMERA": "cameras",
|
|
"COLLECTION": "collections",
|
|
"CURVE": "curves",
|
|
"GREASEPENCIL": "grease_pencils",
|
|
"GREASEPENCIL_V3": "grease_pencils",
|
|
"IMAGE": "images",
|
|
"KEY": "shape_keys",
|
|
"LATTICE": "lattices",
|
|
"LIBRARY": "libraries",
|
|
"LIGHT": "lights",
|
|
"MATERIAL": "materials",
|
|
"MESH": "meshes",
|
|
"NODETREE": "node_groups",
|
|
"OBJECT": "objects",
|
|
"SCENE": "scenes",
|
|
"SOUND": "sounds",
|
|
"TEXT": "texts",
|
|
"WORLD": "worlds",
|
|
}
|
|
|
|
|
|
def id_collection_name(id_block):
|
|
return _ID_COLLECTIONS.get(getattr(id_block, "id_type", ""), None)
|
|
|
|
|
|
@dataclass
|
|
class Issue:
|
|
"""One problem found by a check.
|
|
|
|
``id_type``/``id_name`` point at the offending datablock (stored by name so
|
|
the issue survives undo). ``elements`` are mesh element indices of
|
|
``element_type`` ("VERT", "EDGE" or "FACE") used by the Select button.
|
|
``data`` is free storage for the check's own ``fix``.
|
|
"""
|
|
|
|
message: str
|
|
severity: Severity | None = None
|
|
id_type: str | None = None
|
|
id_name: str | None = None
|
|
elements: list[int] = field(default_factory=list)
|
|
element_type: str | None = None
|
|
data: dict[str, Any] = field(default_factory=dict)
|
|
|
|
@classmethod
|
|
def for_id(cls, message, id_block, **kwargs):
|
|
return cls(message, id_type=id_collection_name(id_block), id_name=id_block.name, **kwargs)
|
|
|
|
def get_id(self):
|
|
if not self.id_type or not self.id_name:
|
|
return None
|
|
collection = getattr(bpy.data, self.id_type, None)
|
|
if collection is None:
|
|
return None
|
|
for id_block in collection:
|
|
if id_block.name == self.id_name and id_block.library is None:
|
|
return id_block
|
|
return collection.get(self.id_name)
|
|
|
|
def to_dict(self):
|
|
return {
|
|
"message": self.message,
|
|
"severity": self.severity.name if self.severity else None,
|
|
"id_type": self.id_type,
|
|
"id_name": self.id_name,
|
|
"element_type": self.element_type,
|
|
"element_count": len(self.elements),
|
|
}
|
|
|
|
|
|
def is_local(id_block):
|
|
"""True for datablocks owned by this file (not linked, not overridden)."""
|
|
return id_block.library is None and id_block.override_library is None
|
|
|
|
|
|
@dataclass
|
|
class CheckContext:
|
|
"""What a check receives: the scene and the selected production context."""
|
|
|
|
scene: bpy.types.Scene
|
|
context_id: str
|
|
|
|
@property
|
|
def filepath(self):
|
|
return bpy.data.filepath
|
|
|
|
@property
|
|
def filename(self):
|
|
return os.path.basename(bpy.data.filepath)
|
|
|
|
@property
|
|
def settings(self):
|
|
return getattr(self.scene, "wtd_sanity", None)
|
|
|
|
@property
|
|
def prefs(self):
|
|
addon = bpy.context.preferences.addons.get(ADDON_ID)
|
|
return addon.preferences if addon else None
|
|
|
|
@functools.cached_property
|
|
def filename_match(self):
|
|
pattern = config.FILENAME_PATTERNS.get(self.context_id)
|
|
if not pattern or not self.filename:
|
|
return None
|
|
return re.match(pattern, self.filename)
|
|
|
|
@functools.cached_property
|
|
def asset_name(self):
|
|
"""Asset name from the panel override, else from the file name."""
|
|
settings = self.settings
|
|
if settings is not None and settings.asset_name.strip():
|
|
return settings.asset_name.strip()
|
|
match = self.filename_match
|
|
if match and "asset" in match.groupdict() and match.group("asset"):
|
|
return match.group("asset")
|
|
return None
|
|
|
|
def objects(self, *types, local=True):
|
|
"""Scene objects, optionally filtered by type and excluding linked ones."""
|
|
result = []
|
|
for ob in self.scene.objects:
|
|
if types and ob.type not in types:
|
|
continue
|
|
if local and not is_local(ob):
|
|
continue
|
|
result.append(ob)
|
|
return result
|
|
|
|
|
|
class Check:
|
|
"""Base class of every sanity check. See module docstring."""
|
|
|
|
id: ClassVar[str] = ""
|
|
label: ClassVar[str] = ""
|
|
description: ClassVar[str] = ""
|
|
category: ClassVar[str] = "General"
|
|
contexts: ClassVar[frozenset] = frozenset()
|
|
severity: ClassVar[Severity] = Severity.ERROR
|
|
# Sort key inside the category.
|
|
order: ClassVar[int] = 100
|
|
# Tooltip of the Fix button.
|
|
fix_description: ClassVar[str] = "Fix the reported issues"
|
|
# Show an inline name field for the issue datablock (renaming in the panel).
|
|
editable_names: ClassVar[bool] = False
|
|
|
|
def __init_subclass__(cls, **kwargs):
|
|
super().__init_subclass__(**kwargs)
|
|
# Only classes declaring their own id are concrete checks; this lets a
|
|
# check subclass another one without registering twice.
|
|
if cls.__dict__.get("id"):
|
|
_register(cls)
|
|
|
|
def run(self, ctx: CheckContext) -> Iterable[Issue] | None:
|
|
raise NotImplementedError
|
|
|
|
def fix(self, ctx: CheckContext, issues: list[Issue]) -> Iterable[str] | None:
|
|
raise NotImplementedError
|
|
|
|
@classmethod
|
|
def can_fix(cls):
|
|
return cls.fix is not Check.fix
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Registry
|
|
# --------------------------------------------------------------------------- #
|
|
_REGISTRY: dict[str, type[Check]] = {}
|
|
|
|
|
|
def _register(cls):
|
|
if not cls.label:
|
|
cls.label = cls.id.replace("_", " ").capitalize()
|
|
previous = _REGISTRY.get(cls.id)
|
|
if previous is not None and previous.__qualname__ != cls.__qualname__:
|
|
print(f"[WTD Sanity] Check id '{cls.id}' of {cls.__qualname__} replaces {previous.__qualname__}")
|
|
_REGISTRY[cls.id] = cls
|
|
|
|
|
|
def _sort_key(cls):
|
|
order = config.CATEGORY_ORDER
|
|
category_index = order.index(cls.category) if cls.category in order else len(order)
|
|
return (category_index, cls.category, cls.order, cls.label)
|
|
|
|
|
|
def all_checks():
|
|
return sorted(_REGISTRY.values(), key=_sort_key)
|
|
|
|
|
|
def checks_for_context(context_id):
|
|
return [cls for cls in all_checks() if context_id in cls.contexts]
|
|
|
|
|
|
def get_check(check_id):
|
|
return _REGISTRY.get(check_id)
|
|
|
|
|
|
def clear_registry():
|
|
_REGISTRY.clear()
|