feat: add plugin loader infrastructure

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.
This commit is contained in:
Artem Akymenko
2026-07-12 16:20:05 +03:00
parent 0f568120f4
commit 6eda8516cc
9 changed files with 1025 additions and 0 deletions
+365
View File
@@ -0,0 +1,365 @@
"""Plugin loader infrastructure for the TTS Plugin Architecture.
This module provides functionality to discover, import, validate, and load
TTS plugins. It handles both valid and invalid plugins, providing diagnostic
messages for errors.
The loader does NOT:
- Create Engine instances (that's the plugin's create_engine() responsibility)
- Manage plugin lifecycle (that's the Plugin Manager's responsibility)
- Implement any TTS engine functionality
"""
from __future__ import annotations
import importlib
import re
import sys
import types
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Callable
from abogen.tts_plugin.manifest import ModelManifest, PluginManifest
# Host API version for compatibility checking
HOST_API_VERSION = "1.0"
@dataclass(frozen=True)
class PluginLoadError:
"""Diagnostic information for a failed plugin load.
Attributes:
plugin_id: Plugin identifier if available, otherwise directory name.
path: Path to the plugin directory.
errors: List of error messages describing what went wrong.
"""
plugin_id: str
path: Path
errors: tuple[str, ...] = field(default_factory=tuple)
@dataclass(frozen=True)
class PluginLoadResult:
"""Result of loading a plugin.
Attributes:
success: Whether the plugin loaded successfully.
manifest: The plugin manifest if successful.
model_requirements: Model requirements if successful.
create_engine: The create_engine function if successful.
module: The plugin module if successful.
error: Error information if failed.
"""
success: bool
manifest: PluginManifest | None = None
model_requirements: tuple[ModelManifest, ...] | None = None
create_engine: Callable[..., Any] | None = None
module: types.ModuleType | None = None
error: PluginLoadError | None = None
def _parse_api_version(version: str) -> tuple[int, int] | None:
"""Parse an api_version string into (major, minor) tuple.
Args:
version: Version string in format "MAJOR.MINOR".
Returns:
Tuple of (major, minor) or None if invalid format.
"""
match = re.match(r"^(\d+)\.(\d+)$", version)
if match:
return int(match.group(1)), int(match.group(2))
return None
def _check_api_version_compatibility(plugin_version: str) -> str | None:
"""Check if plugin api_version is compatible with host.
Architecture spec:
- Format: semver (MAJOR.MINOR)
- Compatibility: Host rejects plugin if major version differs
- Minor version: backward compatible, Host accepts higher minor
Args:
plugin_version: Plugin's api_version string.
Returns:
Error message if incompatible, None if compatible.
"""
plugin_ver = _parse_api_version(plugin_version)
if plugin_ver is None:
return f"Invalid api_version format: '{plugin_version}'. Expected format: MAJOR.MINOR"
host_ver = _parse_api_version(HOST_API_VERSION)
if host_ver is None:
return f"Invalid host api_version format: '{HOST_API_VERSION}'"
if plugin_ver[0] != host_ver[0]:
return (
f"api_version major mismatch: plugin={plugin_ver[0]}, host={host_ver[0]}. "
f"Major version must match for compatibility."
)
return None
def _validate_manifest(module: types.ModuleType, plugin_dir: Path) -> list[str]:
"""Validate that a plugin module has required exports.
Args:
module: The imported plugin module.
plugin_dir: Path to the plugin directory.
Returns:
List of error messages (empty if valid).
"""
errors: list[str] = []
# Check PLUGIN_MANIFEST
manifest = getattr(module, "PLUGIN_MANIFEST", None)
if manifest is None:
errors.append("Missing PLUGIN_MANIFEST export")
elif not isinstance(manifest, PluginManifest):
errors.append(
f"PLUGIN_MANIFEST must be a PluginManifest instance, "
f"got {type(manifest).__name__}"
)
# Check MODEL_REQUIREMENTS
model_reqs = getattr(module, "MODEL_REQUIREMENTS", None)
if model_reqs is None:
errors.append("Missing MODEL_REQUIREMENTS export")
elif not isinstance(model_reqs, list):
errors.append(
f"MODEL_REQUIREMENTS must be a list, got {type(model_reqs).__name__}"
)
else:
for i, req in enumerate(model_reqs):
if not isinstance(req, ModelManifest):
errors.append(
f"MODEL_REQUIREMENTS[{i}] must be a ModelManifest instance, "
f"got {type(req).__name__}"
)
# Check create_engine
create_engine = getattr(module, "create_engine", None)
if create_engine is None:
errors.append("Missing create_engine export")
elif not callable(create_engine):
errors.append(
f"create_engine must be callable, got {type(create_engine).__name__}"
)
return errors
def _validate_capabilities(manifest: PluginManifest) -> list[str]:
"""Validate plugin capabilities.
Args:
manifest: The plugin manifest to validate.
Returns:
List of error messages (empty if valid).
"""
errors: list[str] = []
# Known capabilities (can be extended)
known_capabilities = frozenset({
"voice_list",
"preview",
"voice_clone",
"voice_blend",
"streaming",
"cancel",
})
for cap in manifest.capabilities:
if cap not in known_capabilities:
errors.append(f"Unknown capability: '{cap}'")
return errors
def _validate_api_version(manifest: PluginManifest) -> list[str]:
"""Validate api_version compatibility.
Args:
manifest: The plugin manifest to validate.
Returns:
List of error messages (empty if valid).
"""
errors: list[str] = []
error = _check_api_version_compatibility(manifest.api_version)
if error:
errors.append(error)
return errors
def load_plugin_from_dir(plugin_dir: Path) -> PluginLoadResult:
"""Load and validate a plugin from a directory.
The plugin directory must contain an __init__.py that exports:
- PLUGIN_MANIFEST: PluginManifest
- MODEL_REQUIREMENTS: list[ModelManifest]
- create_engine: Callable
Args:
plugin_dir: Path to the plugin directory.
Returns:
PluginLoadResult with success status and either plugin data or error info.
"""
plugin_id = plugin_dir.name
errors: list[str] = []
# Check if directory exists
if not plugin_dir.exists():
return PluginLoadResult(
success=False,
error=PluginLoadError(
plugin_id=plugin_id,
path=plugin_dir,
errors=(f"Plugin directory does not exist: {plugin_dir}",),
),
)
# Check for __init__.py
init_file = plugin_dir / "__init__.py"
if not init_file.exists():
return PluginLoadResult(
success=False,
error=PluginLoadError(
plugin_id=plugin_id,
path=plugin_dir,
errors=("Missing __init__.py in plugin directory",),
),
)
# Import the module
module_name = f"abogen.tts_plugin._loaded.{plugin_id}"
try:
# Remove from cache if already imported (for testing)
if module_name in sys.modules:
del sys.modules[module_name]
spec = importlib.util.spec_from_file_location(
module_name, init_file, submodule_search_locations=[]
)
if spec is None or spec.loader is None:
return PluginLoadResult(
success=False,
error=PluginLoadError(
plugin_id=plugin_id,
path=plugin_dir,
errors=(f"Failed to create module spec for {init_file}",),
),
)
module = importlib.util.module_from_spec(spec)
sys.modules[module_name] = module
spec.loader.exec_module(module)
except Exception as e:
# Clean up module from sys.modules on import failure
if module_name in sys.modules:
del sys.modules[module_name]
return PluginLoadResult(
success=False,
error=PluginLoadError(
plugin_id=plugin_id,
path=plugin_dir,
errors=(f"Failed to import plugin module: {e}",),
),
)
# Validate manifest
manifest_errors = _validate_manifest(module, plugin_dir)
errors.extend(manifest_errors)
# If manifest is valid, perform additional validation
manifest = getattr(module, "PLUGIN_MANIFEST", None)
if isinstance(manifest, PluginManifest):
# Validate api_version
api_errors = _validate_api_version(manifest)
errors.extend(api_errors)
# Validate capabilities
cap_errors = _validate_capabilities(manifest)
errors.extend(cap_errors)
# Use manifest id if available
plugin_id = manifest.id
# Check if any errors occurred
if errors:
# Clean up module from sys.modules
if module_name in sys.modules:
del sys.modules[module_name]
return PluginLoadResult(
success=False,
error=PluginLoadError(
plugin_id=plugin_id,
path=plugin_dir,
errors=tuple(errors),
),
)
# Get MODEL_REQUIREMENTS
model_requirements = tuple(getattr(module, "MODEL_REQUIREMENTS", []))
create_engine = getattr(module, "create_engine", None)
return PluginLoadResult(
success=True,
manifest=manifest,
model_requirements=model_requirements,
create_engine=create_engine,
module=module,
)
def discover_plugins(plugin_dirs: list[Path]) -> list[PluginLoadResult]:
"""Discover and load plugins from multiple directories.
Args:
plugin_dirs: List of directories to scan for plugins.
Returns:
List of PluginLoadResult, one per plugin directory found.
"""
results: list[PluginLoadResult] = []
for plugin_dir in plugin_dirs:
if not plugin_dir.exists():
continue
# Scan for subdirectories (each is a potential plugin)
for item in sorted(plugin_dir.iterdir()):
if item.is_dir() and not item.name.startswith("."):
result = load_plugin_from_dir(item)
results.append(result)
return results
def load_plugin(
plugin_dir: Path,
) -> PluginLoadResult:
"""Load a single plugin from a directory.
This is the main entry point for loading a plugin.
Args:
plugin_dir: Path to the plugin directory.
Returns:
PluginLoadResult with success status and either plugin data or error info.
"""
return load_plugin_from_dir(plugin_dir)
+436
View File
@@ -0,0 +1,436 @@
"""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
+92
View File
@@ -0,0 +1,92 @@
"""Fake plugin for testing the plugin loader.
This is a minimal valid plugin that satisfies the Plugin API contract.
It does NOT perform any real TTS synthesis.
"""
from __future__ import annotations
from pathlib import Path
from typing import Any
from abogen.tts_plugin.engine import Engine, EngineSession
from abogen.tts_plugin.errors import EngineError
from abogen.tts_plugin.host_context import HostContext
from abogen.tts_plugin.manifest import (
AudioFormatManifest,
EngineManifest,
PluginManifest,
VoiceSourceManifest,
)
from abogen.tts_plugin.types import (
AudioFormat,
Duration,
EngineConfig,
ParameterValues,
SynthesisRequest,
SynthesizedAudio,
)
class FakeSession:
"""Minimal EngineSession implementation for testing."""
def __init__(self) -> None:
self._disposed = False
def synthesize(self, request: SynthesisRequest) -> SynthesizedAudio:
if self._disposed:
raise EngineError("Session disposed")
return SynthesizedAudio(
data=b"\x00" * 100,
format=AudioFormat(mime="audio/wav", extension="wav"),
duration=Duration(seconds=1.0),
)
def dispose(self) -> None:
self._disposed = True
class FakeEngine:
"""Minimal Engine implementation for testing."""
def __init__(self) -> None:
self._disposed = False
def createSession(self) -> EngineSession:
if self._disposed:
raise EngineError("Engine disposed")
return FakeSession()
def dispose(self) -> None:
self._disposed = True
PLUGIN_MANIFEST = PluginManifest(
id="fake_plugin",
name="Fake Plugin",
version="1.0.0",
api_version="1.0",
description="A fake plugin for testing",
author="Test Author",
capabilities=(),
engine=EngineManifest(
voiceSources=(
VoiceSourceManifest(id="builtin", name="Builtin", type="list"),
),
audioFormats=(
AudioFormatManifest(mime="audio/wav", extension="wav"),
),
),
)
MODEL_REQUIREMENTS: list[Any] = []
def create_engine(
context: HostContext,
model_path: Path | None,
config: EngineConfig,
) -> Engine:
"""Create a fake engine instance."""
return FakeEngine()
+18
View File
@@ -0,0 +1,18 @@
"""Invalid plugin: raises ImportError during import."""
from __future__ import annotations
# This plugin intentionally raises an ImportError
raise ImportError("Simulated import error for testing")
# The following code will never be reached, but is here for documentation
from abogen.tts_plugin.manifest import PluginManifest
PLUGIN_MANIFEST = PluginManifest(
id="import_error",
name="Import Error Plugin",
version="1.0.0",
api_version="1.0",
description="Plugin that fails to import",
author="Test Author",
)
@@ -0,0 +1,29 @@
"""Invalid plugin: incompatible api_version (major version mismatch)."""
from __future__ import annotations
from pathlib import Path
from typing import Any
from abogen.tts_plugin.manifest import PluginManifest
from abogen.tts_plugin.types import EngineConfig
# api_version "2.0" has major version 2, but host expects major version 1
PLUGIN_MANIFEST = PluginManifest(
id="invalid_api_version",
name="Invalid API Version Plugin",
version="1.0.0",
api_version="2.0", # Major version mismatch!
description="Plugin with incompatible api_version",
author="Test Author",
)
MODEL_REQUIREMENTS: list[Any] = []
def create_engine(
context: Any,
model_path: Path | None,
config: EngineConfig,
) -> Any:
raise NotImplementedError("This plugin is invalid")
@@ -0,0 +1,29 @@
"""Invalid plugin: unknown capabilities."""
from __future__ import annotations
from pathlib import Path
from typing import Any
from abogen.tts_plugin.manifest import PluginManifest
from abogen.tts_plugin.types import EngineConfig
PLUGIN_MANIFEST = PluginManifest(
id="invalid_capabilities",
name="Invalid Capabilities Plugin",
version="1.0.0",
api_version="1.0",
description="Plugin with unknown capabilities",
author="Test Author",
capabilities=("voice_list", "unknown_capability", "another_unknown"),
)
MODEL_REQUIREMENTS: list[Any] = []
def create_engine(
context: Any,
model_path: Path | None,
config: EngineConfig,
) -> Any:
raise NotImplementedError("This plugin is invalid")
@@ -0,0 +1,18 @@
"""Invalid plugin: missing create_engine function."""
from __future__ import annotations
from abogen.tts_plugin.manifest import PluginManifest
PLUGIN_MANIFEST = PluginManifest(
id="missing_create_engine",
name="Missing Create Engine Plugin",
version="1.0.0",
api_version="1.0",
description="Plugin missing create_engine",
author="Test Author",
)
MODEL_REQUIREMENTS: list = []
# This plugin intentionally does NOT export create_engine
@@ -0,0 +1,10 @@
"""Invalid plugin: missing PLUGIN_MANIFEST."""
from __future__ import annotations
# This plugin intentionally does NOT export PLUGIN_MANIFEST
MODEL_REQUIREMENTS: list = []
def create_engine(context, model_path, config):
raise NotImplementedError("This plugin is invalid")
@@ -0,0 +1,28 @@
"""Invalid plugin: missing MODEL_REQUIREMENTS."""
from __future__ import annotations
from pathlib import Path
from typing import Any
from abogen.tts_plugin.manifest import PluginManifest
from abogen.tts_plugin.types import EngineConfig
PLUGIN_MANIFEST = PluginManifest(
id="missing_model_requirements",
name="Missing Model Requirements Plugin",
version="1.0.0",
api_version="1.0",
description="Plugin missing MODEL_REQUIREMENTS",
author="Test Author",
)
# This plugin intentionally does NOT export MODEL_REQUIREMENTS
def create_engine(
context: Any,
model_path: Path | None,
config: EngineConfig,
) -> Any:
raise NotImplementedError("This plugin is invalid")