"""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()