mirror of
https://github.com/denizsafak/abogen.git
synced 2026-07-18 21:50:28 +02:00
Implement plugin loading and validation: - loader.py: discover, import, validate plugins - validate PLUGIN_MANIFEST, MODEL_REQUIREMENTS, create_engine - validate api_version compatibility (major must match) - validate capabilities (reject unknown) - diagnostic messages for all error cases - no partial registration after error Test plugins: - fake_plugin: minimal valid plugin for testing - missing_manifest: no PLUGIN_MANIFEST - invalid_api_version: major version mismatch - invalid_capabilities: unknown capabilities - missing_create_engine: no create_engine function - import_error: raises ImportError during import - missing_model_requirements: no MODEL_REQUIREMENTS 39 new tests covering all loader functionality.
437 lines
19 KiB
Python
437 lines
19 KiB
Python
"""Comprehensive tests for the plugin loader infrastructure.
|
|
|
|
These tests verify that the loader correctly:
|
|
- Discovers plugins in directories
|
|
- Imports plugin modules
|
|
- Validates PLUGIN_MANIFEST, MODEL_REQUIREMENTS, create_engine
|
|
- Validates api_version compatibility
|
|
- Validates capabilities
|
|
- Provides diagnostic messages for errors
|
|
- Rejects invalid plugins
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from abogen.tts_plugin.loader import (
|
|
HOST_API_VERSION,
|
|
PluginLoadError,
|
|
PluginLoadResult,
|
|
_check_api_version_compatibility,
|
|
_parse_api_version,
|
|
_validate_api_version,
|
|
_validate_capabilities,
|
|
_validate_manifest,
|
|
discover_plugins,
|
|
load_plugin,
|
|
load_plugin_from_dir,
|
|
)
|
|
from abogen.tts_plugin.manifest import (
|
|
EngineManifest,
|
|
ModelManifest,
|
|
PluginManifest,
|
|
)
|
|
|
|
|
|
# ──────────────────────────────────────────────────────────────
|
|
# Path fixtures
|
|
# ──────────────────────────────────────────────────────────────
|
|
|
|
@pytest.fixture
|
|
def plugins_dir() -> Path:
|
|
return Path(__file__).parent.parent / "plugins"
|
|
|
|
|
|
@pytest.fixture
|
|
def fake_plugin_dir(plugins_dir: Path) -> Path:
|
|
return plugins_dir / "fake_plugin"
|
|
|
|
|
|
@pytest.fixture
|
|
def missing_manifest_dir(plugins_dir: Path) -> Path:
|
|
return plugins_dir / "missing_manifest"
|
|
|
|
|
|
@pytest.fixture
|
|
def invalid_api_version_dir(plugins_dir: Path) -> Path:
|
|
return plugins_dir / "invalid_api_version"
|
|
|
|
|
|
@pytest.fixture
|
|
def invalid_capabilities_dir(plugins_dir: Path) -> Path:
|
|
return plugins_dir / "invalid_capabilities"
|
|
|
|
|
|
@pytest.fixture
|
|
def missing_create_engine_dir(plugins_dir: Path) -> Path:
|
|
return plugins_dir / "missing_create_engine"
|
|
|
|
|
|
@pytest.fixture
|
|
def import_error_dir(plugins_dir: Path) -> Path:
|
|
return plugins_dir / "import_error"
|
|
|
|
|
|
@pytest.fixture
|
|
def missing_model_requirements_dir(plugins_dir: Path) -> Path:
|
|
return plugins_dir / "missing_model_requirements"
|
|
|
|
|
|
# ──────────────────────────────────────────────────────────────
|
|
# Unit tests: _parse_api_version
|
|
# ──────────────────────────────────────────────────────────────
|
|
|
|
class TestParseApiVersion:
|
|
def test_valid_version(self) -> None:
|
|
assert _parse_api_version("1.0") == (1, 0)
|
|
assert _parse_api_version("2.5") == (2, 5)
|
|
assert _parse_api_version("10.20") == (10, 20)
|
|
|
|
def test_invalid_format(self) -> None:
|
|
assert _parse_api_version("1") is None
|
|
assert _parse_api_version("1.0.0") is None
|
|
assert _parse_api_version("abc") is None
|
|
assert _parse_api_version("") is None
|
|
assert _parse_api_version("1.x") is None
|
|
|
|
|
|
# ──────────────────────────────────────────────────────────────
|
|
# Unit tests: _check_api_version_compatibility
|
|
# ──────────────────────────────────────────────────────────────
|
|
|
|
class TestCheckApiVersionCompatibility:
|
|
def test_compatible_version(self) -> None:
|
|
assert _check_api_version_compatibility("1.0") is None
|
|
assert _check_api_version_compatibility("1.5") is None
|
|
|
|
def test_major_mismatch(self) -> None:
|
|
error = _check_api_version_compatibility("2.0")
|
|
assert error is not None
|
|
assert "major mismatch" in error
|
|
|
|
def test_invalid_format(self) -> None:
|
|
error = _check_api_version_compatibility("invalid")
|
|
assert error is not None
|
|
assert "Invalid api_version format" in error
|
|
|
|
|
|
# ──────────────────────────────────────────────────────────────
|
|
# Unit tests: _validate_manifest
|
|
# ──────────────────────────────────────────────────────────────
|
|
|
|
class TestValidateManifest:
|
|
def test_valid_manifest(self) -> None:
|
|
class FakeModule:
|
|
PLUGIN_MANIFEST = PluginManifest(
|
|
id="test", name="Test", version="1.0.0",
|
|
api_version="1.0", description="Test", author="Test",
|
|
)
|
|
MODEL_REQUIREMENTS: list = []
|
|
create_engine = lambda *a, **kw: None
|
|
|
|
errors = _validate_manifest(FakeModule(), Path("/tmp"))
|
|
assert errors == []
|
|
|
|
def test_missing_manifest(self) -> None:
|
|
class FakeModule:
|
|
MODEL_REQUIREMENTS: list = []
|
|
create_engine = lambda *a, **kw: None
|
|
|
|
errors = _validate_manifest(FakeModule(), Path("/tmp"))
|
|
assert any("Missing PLUGIN_MANIFEST" in e for e in errors)
|
|
|
|
def test_wrong_manifest_type(self) -> None:
|
|
class FakeModule:
|
|
PLUGIN_MANIFEST = "not a manifest"
|
|
MODEL_REQUIREMENTS: list = []
|
|
create_engine = lambda *a, **kw: None
|
|
|
|
errors = _validate_manifest(FakeModule(), Path("/tmp"))
|
|
assert any("PluginManifest instance" in e for e in errors)
|
|
|
|
def test_missing_model_requirements(self) -> None:
|
|
class FakeModule:
|
|
PLUGIN_MANIFEST = PluginManifest(
|
|
id="test", name="Test", version="1.0.0",
|
|
api_version="1.0", description="Test", author="Test",
|
|
)
|
|
create_engine = lambda *a, **kw: None
|
|
|
|
errors = _validate_manifest(FakeModule(), Path("/tmp"))
|
|
assert any("Missing MODEL_REQUIREMENTS" in e for e in errors)
|
|
|
|
def test_wrong_model_requirements_type(self) -> None:
|
|
class FakeModule:
|
|
PLUGIN_MANIFEST = PluginManifest(
|
|
id="test", name="Test", version="1.0.0",
|
|
api_version="1.0", description="Test", author="Test",
|
|
)
|
|
MODEL_REQUIREMENTS = "not a list"
|
|
create_engine = lambda *a, **kw: None
|
|
|
|
errors = _validate_manifest(FakeModule(), Path("/tmp"))
|
|
assert any("must be a list" in e for e in errors)
|
|
|
|
def test_invalid_model_requirements_item(self) -> None:
|
|
class FakeModule:
|
|
PLUGIN_MANIFEST = PluginManifest(
|
|
id="test", name="Test", version="1.0.0",
|
|
api_version="1.0", description="Test", author="Test",
|
|
)
|
|
MODEL_REQUIREMENTS = ["not a model manifest"]
|
|
create_engine = lambda *a, **kw: None
|
|
|
|
errors = _validate_manifest(FakeModule(), Path("/tmp"))
|
|
assert any("ModelManifest instance" in e for e in errors)
|
|
|
|
def test_missing_create_engine(self) -> None:
|
|
class FakeModule:
|
|
PLUGIN_MANIFEST = PluginManifest(
|
|
id="test", name="Test", version="1.0.0",
|
|
api_version="1.0", description="Test", author="Test",
|
|
)
|
|
MODEL_REQUIREMENTS: list = []
|
|
|
|
errors = _validate_manifest(FakeModule(), Path("/tmp"))
|
|
assert any("Missing create_engine" in e for e in errors)
|
|
|
|
def test_create_engine_not_callable(self) -> None:
|
|
class FakeModule:
|
|
PLUGIN_MANIFEST = PluginManifest(
|
|
id="test", name="Test", version="1.0.0",
|
|
api_version="1.0", description="Test", author="Test",
|
|
)
|
|
MODEL_REQUIREMENTS: list = []
|
|
create_engine = "not callable"
|
|
|
|
errors = _validate_manifest(FakeModule(), Path("/tmp"))
|
|
assert any("must be callable" in e for e in errors)
|
|
|
|
|
|
# ──────────────────────────────────────────────────────────────
|
|
# Unit tests: _validate_capabilities
|
|
# ──────────────────────────────────────────────────────────────
|
|
|
|
class TestValidateCapabilities:
|
|
def test_valid_capabilities(self) -> None:
|
|
manifest = PluginManifest(
|
|
id="test", name="Test", version="1.0.0",
|
|
api_version="1.0", description="Test", author="Test",
|
|
capabilities=("voice_list", "preview"),
|
|
)
|
|
errors = _validate_capabilities(manifest)
|
|
assert errors == []
|
|
|
|
def test_unknown_capability(self) -> None:
|
|
manifest = PluginManifest(
|
|
id="test", name="Test", version="1.0.0",
|
|
api_version="1.0", description="Test", author="Test",
|
|
capabilities=("voice_list", "unknown_cap"),
|
|
)
|
|
errors = _validate_capabilities(manifest)
|
|
assert any("unknown_cap" in e for e in errors)
|
|
|
|
def test_empty_capabilities(self) -> None:
|
|
manifest = PluginManifest(
|
|
id="test", name="Test", version="1.0.0",
|
|
api_version="1.0", description="Test", author="Test",
|
|
capabilities=(),
|
|
)
|
|
errors = _validate_capabilities(manifest)
|
|
assert errors == []
|
|
|
|
|
|
# ──────────────────────────────────────────────────────────────
|
|
# Unit tests: _validate_api_version
|
|
# ──────────────────────────────────────────────────────────────
|
|
|
|
class TestValidateApiVersion:
|
|
def test_compatible(self) -> None:
|
|
manifest = PluginManifest(
|
|
id="test", name="Test", version="1.0.0",
|
|
api_version="1.0", description="Test", author="Test",
|
|
)
|
|
errors = _validate_api_version(manifest)
|
|
assert errors == []
|
|
|
|
def test_incompatible(self) -> None:
|
|
manifest = PluginManifest(
|
|
id="test", name="Test", version="1.0.0",
|
|
api_version="2.0", description="Test", author="Test",
|
|
)
|
|
errors = _validate_api_version(manifest)
|
|
assert len(errors) > 0
|
|
|
|
|
|
# ──────────────────────────────────────────────────────────────
|
|
# Integration tests: load_plugin_from_dir
|
|
# ──────────────────────────────────────────────────────────────
|
|
|
|
class TestLoadPluginFromDir:
|
|
def test_load_valid_plugin(self, fake_plugin_dir: Path) -> None:
|
|
result = load_plugin_from_dir(fake_plugin_dir)
|
|
assert result.success is True
|
|
assert result.manifest is not None
|
|
assert result.manifest.id == "fake_plugin"
|
|
assert result.model_requirements is not None
|
|
assert result.create_engine is not None
|
|
assert result.module is not None
|
|
assert result.error is None
|
|
|
|
def test_plugin_satisfies_protocol(self, fake_plugin_dir: Path) -> None:
|
|
from abogen.tts_plugin.engine import Engine
|
|
from abogen.tts_plugin.host_context import HostContext
|
|
import logging
|
|
|
|
result = load_plugin_from_dir(fake_plugin_dir)
|
|
assert result.success is True
|
|
|
|
# Create engine using the loaded create_engine function
|
|
ctx = HostContext(
|
|
config_dir=Path("/tmp/test"),
|
|
logger=logging.getLogger("test"),
|
|
http_client=type("FakeClient", (), {"get": lambda s, **kw: None, "post": lambda s, **kw: None})(),
|
|
)
|
|
engine = result.create_engine(ctx, None, __import__("abogen.tts_plugin.types", fromlist=["EngineConfig"]).EngineConfig())
|
|
assert isinstance(engine, Engine)
|
|
engine.dispose()
|
|
|
|
def test_nonexistent_directory(self, tmp_path: Path) -> None:
|
|
result = load_plugin_from_dir(tmp_path / "nonexistent")
|
|
assert result.success is False
|
|
assert result.error is not None
|
|
assert "does not exist" in result.error.errors[0]
|
|
|
|
def test_missing_init_file(self, tmp_path: Path) -> None:
|
|
plugin_dir = tmp_path / "no_init"
|
|
plugin_dir.mkdir()
|
|
result = load_plugin_from_dir(plugin_dir)
|
|
assert result.success is False
|
|
assert result.error is not None
|
|
assert "__init__.py" in result.error.errors[0]
|
|
|
|
def test_import_error(self, import_error_dir: Path) -> None:
|
|
result = load_plugin_from_dir(import_error_dir)
|
|
assert result.success is False
|
|
assert result.error is not None
|
|
assert "Failed to import" in result.error.errors[0]
|
|
|
|
|
|
# ──────────────────────────────────────────────────────────────
|
|
# Integration tests: invalid plugins
|
|
# ──────────────────────────────────────────────────────────────
|
|
|
|
class TestInvalidPlugins:
|
|
def test_missing_manifest(self, missing_manifest_dir: Path) -> None:
|
|
result = load_plugin_from_dir(missing_manifest_dir)
|
|
assert result.success is False
|
|
assert result.error is not None
|
|
assert any("Missing PLUGIN_MANIFEST" in e for e in result.error.errors)
|
|
|
|
def test_invalid_api_version(self, invalid_api_version_dir: Path) -> None:
|
|
result = load_plugin_from_dir(invalid_api_version_dir)
|
|
assert result.success is False
|
|
assert result.error is not None
|
|
assert any("major mismatch" in e for e in result.error.errors)
|
|
|
|
def test_invalid_capabilities(self, invalid_capabilities_dir: Path) -> None:
|
|
result = load_plugin_from_dir(invalid_capabilities_dir)
|
|
assert result.success is False
|
|
assert result.error is not None
|
|
assert any("Unknown capability" in e for e in result.error.errors)
|
|
|
|
def test_missing_create_engine(self, missing_create_engine_dir: Path) -> None:
|
|
result = load_plugin_from_dir(missing_create_engine_dir)
|
|
assert result.success is False
|
|
assert result.error is not None
|
|
assert any("Missing create_engine" in e for e in result.error.errors)
|
|
|
|
def test_missing_model_requirements(self, missing_model_requirements_dir: Path) -> None:
|
|
result = load_plugin_from_dir(missing_model_requirements_dir)
|
|
assert result.success is False
|
|
assert result.error is not None
|
|
assert any("Missing MODEL_REQUIREMENTS" in e for e in result.error.errors)
|
|
|
|
|
|
# ──────────────────────────────────────────────────────────────
|
|
# Integration tests: discover_plugins
|
|
# ──────────────────────────────────────────────────────────────
|
|
|
|
class TestDiscoverPlugins:
|
|
def test_discover_from_valid_dir(self, plugins_dir: Path) -> None:
|
|
results = discover_plugins([plugins_dir])
|
|
# Should find multiple plugins (valid and invalid)
|
|
assert len(results) > 0
|
|
|
|
def test_discover_includes_valid_plugin(self, plugins_dir: Path) -> None:
|
|
results = discover_plugins([plugins_dir])
|
|
valid = [r for r in results if r.success]
|
|
assert len(valid) >= 1
|
|
assert any(r.manifest and r.manifest.id == "fake_plugin" for r in valid)
|
|
|
|
def test_discover_includes_invalid_plugins(self, plugins_dir: Path) -> None:
|
|
results = discover_plugins([plugins_dir])
|
|
invalid = [r for r in results if not r.success]
|
|
assert len(invalid) >= 1
|
|
|
|
def test_discover_nonexistent_dir(self, tmp_path: Path) -> None:
|
|
results = discover_plugins([tmp_path / "nonexistent"])
|
|
assert results == []
|
|
|
|
def test_discover_multiple_dirs(self, plugins_dir: Path, tmp_path: Path) -> None:
|
|
results = discover_plugins([plugins_dir, tmp_path / "nonexistent"])
|
|
assert len(results) > 0
|
|
|
|
|
|
# ──────────────────────────────────────────────────────────────
|
|
# Diagnostic messages tests
|
|
# ──────────────────────────────────────────────────────────────
|
|
|
|
class TestDiagnosticMessages:
|
|
def test_error_contains_plugin_id(self, missing_manifest_dir: Path) -> None:
|
|
result = load_plugin_from_dir(missing_manifest_dir)
|
|
assert result.error is not None
|
|
assert result.error.plugin_id == "missing_manifest"
|
|
|
|
def test_error_contains_path(self, missing_manifest_dir: Path) -> None:
|
|
result = load_plugin_from_dir(missing_manifest_dir)
|
|
assert result.error is not None
|
|
assert result.error.path == missing_manifest_dir
|
|
|
|
def test_error_contains_messages(self, missing_manifest_dir: Path) -> None:
|
|
result = load_plugin_from_dir(missing_manifest_dir)
|
|
assert result.error is not None
|
|
assert len(result.error.errors) > 0
|
|
|
|
def test_multiple_errors(self, invalid_api_version_dir: Path) -> None:
|
|
# This plugin has multiple issues
|
|
result = load_plugin_from_dir(invalid_api_version_dir)
|
|
assert result.error is not None
|
|
# Should have at least the api_version error
|
|
assert len(result.error.errors) >= 1
|
|
|
|
|
|
# ──────────────────────────────────────────────────────────────
|
|
# No partial registration tests
|
|
# ──────────────────────────────────────────────────────────────
|
|
|
|
class TestNoPartialRegistration:
|
|
def test_invalid_plugin_no_manifest_attr(self, missing_manifest_dir: Path) -> None:
|
|
"""After failed load, module should not remain in sys.modules."""
|
|
result = load_plugin_from_dir(missing_manifest_dir)
|
|
assert result.success is False
|
|
# Module should not be registered
|
|
module_name = f"abogen.tts_plugin._loaded.missing_manifest"
|
|
assert module_name not in sys.modules
|
|
|
|
def test_import_error_no_registration(self, import_error_dir: Path) -> None:
|
|
"""After import error, module should not remain in sys.modules."""
|
|
result = load_plugin_from_dir(import_error_dir)
|
|
assert result.success is False
|
|
module_name = f"abogen.tts_plugin._loaded.import_error"
|
|
assert module_name not in sys.modules
|