Files
Ewald 625920846c feat(plugins): introduce Blueprint manifest format (Phase 3.3)
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.
2026-05-21 22:31:15 +02:00

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)