#!/usr/bin/env python3 """ Plugin Validator Validates plugin manifests and configuration for ComfyUI-Doctor. Usage: python scripts/plugin_validator.py python scripts/plugin_validator.py community.example python scripts/plugin_validator.py --check-config Exit codes: 0 = All valid 1 = Validation failed """ import argparse import hashlib import json import re import sys from pathlib import Path from dataclasses import dataclass from typing import List, Optional # Constants PLUGIN_DIR = Path(__file__).parent.parent / 'pipeline' / 'plugins' / 'community' CONFIG_PATH = Path(__file__).parent.parent / 'config.json' VALID_FILENAME_PATTERN = re.compile(r'^[A-Za-z0-9_]+\.py$') PROJECT_ROOT = Path(__file__).resolve().parents[1] @dataclass class ValidationResult: """Result of plugin validation.""" plugin_id: str plugin_name: str passed: bool checks: List[tuple[str, bool, str]] # (check_name, passed, message) def parse_version(version_str: str) -> tuple: """Parse version string to tuple for comparison.""" parts = re.split(r'[.-]', version_str) return tuple(int(p) for p in parts if p.isdigit()) def get_doctor_version() -> str: """Read the canonical Doctor version from pyproject.toml.""" pyproject = PROJECT_ROOT / "pyproject.toml" try: match = re.search( r'(?m)^version\s*=\s*["\']([^"\']+)["\']', pyproject.read_text(encoding="utf-8", errors="ignore"), ) if match: return match.group(1).strip() except Exception: pass return "0.0.0" def validate_plugin(plugin_path: Path, manifest_path: Path) -> ValidationResult: """Validate a single plugin and its manifest.""" plugin_name = plugin_path.stem checks = [] # Check 1: Manifest exists if not manifest_path.exists(): return ValidationResult( plugin_id="", plugin_name=plugin_name, passed=False, checks=[("Manifest found", False, f"No manifest at {manifest_path}")] ) checks.append(("Manifest found", True, "OK")) # Load manifest try: with manifest_path.open('r', encoding='utf-8') as f: manifest = json.load(f) except Exception as e: checks.append(("Manifest parse", False, str(e))) return ValidationResult("", plugin_name, False, checks) checks.append(("Manifest parse", True, "OK")) # Check 2: Required fields required_fields = ['id', 'name', 'version', 'author', 'min_doctor_version', 'sha256'] missing = [f for f in required_fields if f not in manifest] if missing: checks.append(("Required fields", False, f"Missing: {', '.join(missing)}")) else: checks.append(("Required fields", True, "All present")) plugin_id = manifest.get('id', '') # Check 3: SHA256 match actual_sha256 = hashlib.sha256(plugin_path.read_bytes()).hexdigest() manifest_sha256 = manifest.get('sha256', '') if actual_sha256 == manifest_sha256: checks.append(("SHA256 match", True, "OK")) else: checks.append(("SHA256 match", False, f"Mismatch: file changed after manifest creation")) # Check 4: Version valid version = manifest.get('version', '') try: parsed_version = parse_version(version) checks.append(("Version valid", True, f"{version}")) except Exception as e: checks.append(("Version valid", False, str(e))) # Check 5: Min Doctor version compatible min_version = manifest.get('min_doctor_version', '') try: parsed_min = parse_version(min_version) current = parse_version(get_doctor_version()) compatible = parsed_min <= current checks.append(("Min Doctor version", compatible, f"{min_version} ({'compatible' if compatible else 'incompatible'})")) except Exception as e: checks.append(("Min Doctor version", False, str(e))) # Check 6: Signature (optional) if 'signature' in manifest: sig = manifest['signature'] sig_alg = manifest.get('signature_alg', 'hmac-sha256') checks.append(("Signature", True, f"Present ({sig_alg})")) else: checks.append(("Signature", None, "Not present (optional)")) # Determine overall pass/fail passed = all(c[1] for c in checks if c[1] is not None) return ValidationResult( plugin_id=plugin_id, plugin_name=plugin_name, passed=passed, checks=checks ) def validate_config() -> dict: """Validate config.json consistency.""" if not CONFIG_PATH.exists(): return {"exists": False, "valid": False, "message": "config.json not found"} try: with CONFIG_PATH.open('r', encoding='utf-8') as f: config = json.load(f) except Exception as e: return {"exists": True, "valid": False, "message": f"Parse error: {e}"} checks = {} # Check enable flag enabled = config.get('enable_community_plugins', False) checks['enable_community_plugins'] = enabled # Check allowlist allowlist = config.get('plugin_allowlist', []) checks['plugin_allowlist'] = f"{len(allowlist)} entry" + ("" if len(allowlist) == 1 else "ies") # Check signature settings sig_required = config.get('plugin_signature_required', False) checks['plugin_signature_required'] = sig_required return {"exists": True, "valid": True, "checks": checks} def print_validation_result(result: ValidationResult): """Print validation result for a plugin.""" icon = "PASS" if result.passed else "FAIL" print(f"{icon} {result.plugin_name}:") for check_name, passed, message in result.checks: if passed is None: icon = "INFO" elif passed: icon = "OK" else: icon = "X" print(f" {icon} {check_name}: {message}") if not result.passed: print(f" -> Run: python scripts/plugin_manifest.py {PLUGIN_DIR}/{result.plugin_name}.py --write") print() def main(): parser = argparse.ArgumentParser(description="Plugin Validator") parser.add_argument("plugin_id", nargs='?', help="Specific plugin ID to validate") parser.add_argument("--check-config", action="store_true", help="Check config.json consistency") args = parser.parse_args() print("PASS Plugin Validation Report\n") if args.check_config: # Validate config config_result = validate_config() print("Config check:") if not config_result['exists']: print(f" X {config_result['message']}") return 1 if not config_result['valid']: print(f" X {config_result['message']}") return 1 for key, value in config_result['checks'].items(): print(f" OK {key}: {value}") print() # Validate plugins if args.plugin_id: # Validate specific plugin plugin_path = PLUGIN_DIR / f"{args.plugin_id.replace('community.', '')}.py" manifest_path = plugin_path.with_suffix('.json') if not plugin_path.exists(): print(f"FAIL Plugin not found: {plugin_path}") return 1 result = validate_plugin(plugin_path, manifest_path) print_validation_result(result) return 0 if result.passed else 1 else: # Validate all plugins if not PLUGIN_DIR.exists(): print(f"FAIL Plugin directory not found: {PLUGIN_DIR}") return 1 plugins = list(PLUGIN_DIR.glob('*.py')) if not plugins: print(f"WARN No plugins found in {PLUGIN_DIR}") return 0 results = [] for plugin_path in plugins: if not VALID_FILENAME_PATTERN.match(plugin_path.name): continue manifest_path = plugin_path.with_suffix('.json') result = validate_plugin(plugin_path, manifest_path) results.append(result) print_validation_result(result) # Summary passed_count = sum(1 for r in results if r.passed) print(f"Summary: {passed_count}/{len(results)} plugin(s) passed validation") return 0 if passed_count == len(results) else 1 if __name__ == '__main__': sys.exit(main())