Adds a declarative blueprint.{yaml,json} manifest layer on top of the existing dynamic-module plugin loader. Each plugin can now ship as a self-contained package with a manifest describing name, version, author, type, entrypoint class, and capabilities. The legacy flat .py plugin format remains fully supported.
Manifest schema v1 with fields: schema_version, name (slug), version (semver), author, type (metadata_extractor), entrypoint.{module,class}, optional capabilities/tags/license/homepage/description/min_majoor_version. Path-traversal and module-name injection are rejected at parse time. YAML manifests require PyYAML (already shipped by ComfyUI core); JSON manifests need no extra deps.
Loader: PluginLoader.discover_plugins now treats subdirectories with a blueprint manifest as blueprint packages (taking precedence over plain Python-package scanning). New PluginLoader.blueprints property and get_stats[blueprints] field expose loaded manifests for listing endpoints.
Migrated bundled examples to blueprint layout: plugins/examples/wanvideo/, plugins/examples/custom_node_template/, plugins/examples/rgthree/ (YAML variant).
Tests: 11 new tests in tests/features/test_blueprint_plugins.py covering manifest parsing edge cases (bad name, bad semver, unknown type, path traversal, schema mismatch), YAML support, discovery skipping invalid manifests, and loader integration including a missing-entrypoint warning path.
217 lines
6.8 KiB
Python
217 lines
6.8 KiB
Python
"""Tests for the blueprint manifest parser and loader integration."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import textwrap
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
from mjr_am_backend.features.metadata.plugin_system.blueprint import (
|
|
BLUEPRINT_SCHEMA_VERSION,
|
|
BlueprintError,
|
|
discover_blueprints,
|
|
find_manifest,
|
|
parse_manifest,
|
|
)
|
|
from mjr_am_backend.features.metadata.plugin_system.loader import PluginLoader
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Helpers
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
_MIN_MANIFEST = {
|
|
"schema_version": BLUEPRINT_SCHEMA_VERSION,
|
|
"name": "demo_plugin",
|
|
"version": "1.0.0",
|
|
"author": "tester",
|
|
"type": "metadata_extractor",
|
|
"entrypoint": {"module": "demo_extractor", "class": "DemoExtractor"},
|
|
}
|
|
|
|
|
|
_ENTRYPOINT_CODE = textwrap.dedent(
|
|
'''
|
|
from mjr_am_backend.features.metadata.plugin_system.base import (
|
|
ExtractionResult,
|
|
MetadataExtractorPlugin,
|
|
)
|
|
|
|
|
|
class DemoExtractor(MetadataExtractorPlugin):
|
|
@property
|
|
def name(self) -> str:
|
|
return "demo_plugin"
|
|
|
|
@property
|
|
def supported_extensions(self) -> list[str]:
|
|
return [".png"]
|
|
|
|
@property
|
|
def priority(self) -> int:
|
|
return 10
|
|
|
|
async def extract(self, filepath: str) -> ExtractionResult:
|
|
return ExtractionResult(success=True, data={"ok": True}, extractor_name=self.name)
|
|
'''
|
|
)
|
|
|
|
|
|
def _write_blueprint(root: Path, manifest: dict, *, manifest_name: str = "blueprint.json") -> Path:
|
|
pkg = root / manifest["name"]
|
|
pkg.mkdir(parents=True, exist_ok=True)
|
|
(pkg / f"{manifest['entrypoint']['module']}.py").write_text(_ENTRYPOINT_CODE)
|
|
manifest_path = pkg / manifest_name
|
|
manifest_path.write_text(json.dumps(manifest))
|
|
return manifest_path
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Manifest parsing
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
def test_parse_manifest_minimal_ok(tmp_path: Path) -> None:
|
|
manifest = _write_blueprint(tmp_path, dict(_MIN_MANIFEST))
|
|
bp = parse_manifest(manifest)
|
|
assert bp.name == "demo_plugin"
|
|
assert bp.version == "1.0.0"
|
|
assert bp.plugin_type == "metadata_extractor"
|
|
assert bp.entrypoint.module == "demo_extractor"
|
|
assert bp.entrypoint.class_name == "DemoExtractor"
|
|
assert bp.package_dir == manifest.parent
|
|
|
|
|
|
def test_parse_manifest_rejects_bad_name(tmp_path: Path) -> None:
|
|
bad = dict(_MIN_MANIFEST, name="Bad-Name")
|
|
pkg = tmp_path / "pkg"
|
|
pkg.mkdir()
|
|
path = pkg / "blueprint.json"
|
|
path.write_text(json.dumps(bad))
|
|
with pytest.raises(BlueprintError, match="name"):
|
|
parse_manifest(path)
|
|
|
|
|
|
def test_parse_manifest_rejects_unsupported_schema(tmp_path: Path) -> None:
|
|
bad = dict(_MIN_MANIFEST, schema_version=999)
|
|
path = tmp_path / "blueprint.json"
|
|
path.write_text(json.dumps(bad))
|
|
with pytest.raises(BlueprintError, match="schema_version"):
|
|
parse_manifest(path)
|
|
|
|
|
|
def test_parse_manifest_rejects_unknown_type(tmp_path: Path) -> None:
|
|
bad = dict(_MIN_MANIFEST, type="something_else")
|
|
path = tmp_path / "blueprint.json"
|
|
path.write_text(json.dumps(bad))
|
|
with pytest.raises(BlueprintError, match="plugin type"):
|
|
parse_manifest(path)
|
|
|
|
|
|
def test_parse_manifest_rejects_path_traversal_in_module(tmp_path: Path) -> None:
|
|
bad = dict(_MIN_MANIFEST)
|
|
bad["entrypoint"] = {"module": "../evil", "class": "X"}
|
|
path = tmp_path / "blueprint.json"
|
|
path.write_text(json.dumps(bad))
|
|
with pytest.raises(BlueprintError, match="entrypoint.module"):
|
|
parse_manifest(path)
|
|
|
|
|
|
def test_parse_manifest_rejects_non_semver(tmp_path: Path) -> None:
|
|
bad = dict(_MIN_MANIFEST, version="not.a.version")
|
|
path = tmp_path / "blueprint.json"
|
|
path.write_text(json.dumps(bad))
|
|
with pytest.raises(BlueprintError, match="semver"):
|
|
parse_manifest(path)
|
|
|
|
|
|
def test_parse_manifest_yaml_when_pyyaml_available(tmp_path: Path) -> None:
|
|
pytest.importorskip("yaml")
|
|
pkg = tmp_path / "demo_plugin"
|
|
pkg.mkdir()
|
|
(pkg / "demo_extractor.py").write_text(_ENTRYPOINT_CODE)
|
|
yaml_text = textwrap.dedent(
|
|
"""
|
|
schema_version: 1
|
|
name: demo_plugin
|
|
version: "1.0.0"
|
|
author: tester
|
|
type: metadata_extractor
|
|
entrypoint:
|
|
module: demo_extractor
|
|
class: DemoExtractor
|
|
capabilities:
|
|
priority: 10
|
|
"""
|
|
)
|
|
manifest = pkg / "blueprint.yaml"
|
|
manifest.write_text(yaml_text)
|
|
bp = parse_manifest(manifest)
|
|
assert bp.name == "demo_plugin"
|
|
assert bp.capabilities == {"priority": 10}
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Discovery
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
def test_find_manifest_prefers_yaml_over_json(tmp_path: Path) -> None:
|
|
pkg = tmp_path / "p"
|
|
pkg.mkdir()
|
|
(pkg / "blueprint.yaml").write_text("ignored")
|
|
(pkg / "blueprint.json").write_text("ignored")
|
|
found = find_manifest(pkg)
|
|
assert found is not None
|
|
assert found.name == "blueprint.yaml"
|
|
|
|
|
|
def test_discover_blueprints_skips_invalid(tmp_path: Path) -> None:
|
|
_write_blueprint(tmp_path, dict(_MIN_MANIFEST))
|
|
# Add an invalid one alongside.
|
|
bad = tmp_path / "broken"
|
|
bad.mkdir()
|
|
(bad / "blueprint.json").write_text("{ not json")
|
|
|
|
blueprints = discover_blueprints(tmp_path)
|
|
names = [b.name for b in blueprints]
|
|
assert "demo_plugin" in names
|
|
assert all(n != "broken" for n in names)
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# PluginLoader integration
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
def test_loader_loads_blueprint_extractor(tmp_path: Path) -> None:
|
|
_write_blueprint(tmp_path, dict(_MIN_MANIFEST))
|
|
loader = PluginLoader(
|
|
plugin_dirs=[tmp_path],
|
|
auto_discover=True,
|
|
validation_mode="disabled",
|
|
)
|
|
assert "demo_plugin" in loader.extractor_names
|
|
bp = loader.blueprints.get("demo_plugin")
|
|
assert bp is not None
|
|
assert bp.version == "1.0.0"
|
|
stats = loader.get_stats()
|
|
assert "demo_plugin" in stats["blueprints"]
|
|
|
|
|
|
def test_loader_warns_on_missing_entrypoint_module(tmp_path: Path) -> None:
|
|
manifest = dict(_MIN_MANIFEST, name="ghost")
|
|
manifest["entrypoint"] = {"module": "missing_module", "class": "GhostExtractor"}
|
|
pkg = tmp_path / "ghost"
|
|
pkg.mkdir()
|
|
(pkg / "blueprint.json").write_text(json.dumps(manifest))
|
|
|
|
loader = PluginLoader(
|
|
plugin_dirs=[tmp_path],
|
|
auto_discover=True,
|
|
validation_mode="disabled",
|
|
)
|
|
assert "ghost" not in loader.extractor_names
|
|
assert any("ghost" in entry[0] or "missing_module" in entry[1] for entry in loader.load_errors)
|