diff --git a/README.md b/README.md index 68eaeba9..b227e519 100644 --- a/README.md +++ b/README.md @@ -325,6 +325,31 @@ server = ExternalPluginServer(plugins=[MyPlugin(config)]) server.run() ``` +## Isolated plugins + +Native plugins can be run in a separate python virtual environment (venv) to prevent them from interfering with the host environment. Plugin specific packages are automatically installed based on the contents of the supplied requirements_file. + +```yaml + - name: "test_plugin" + kind: "isolated_venv" + version: "0.1.0" + hooks: ["prompt_pre_fetch", "prompt_post_fetch", "tool_pre_invoke", "tool_post_invoke"] + tags: ["plugin"] + mode: "sequential" + priority: 150 + conditions: + # Apply to specific tools/servers + - server_ids: [] # Apply to all servers + tenant_ids: [] # Apply to all tenants + config: + # Plugin config dict passed to the plugin constructor + class_name: "test_plugin.plugin.TestPlugin" + requirements_file: "requirements.txt" + # essentially the plugin folder hosting the plugin relative to the project root + script_path: "plugins" +``` + + ## Project Status CPEX is under active development as part of the [ContextForge](https://github.com/contextforge-org) ecosystem. The framework is designed to work across AI gateways, agent frameworks, LLM proxies, and tool servers. diff --git a/cpex/framework/constants.py b/cpex/framework/constants.py index 0ed341a7..64d8e090 100644 --- a/cpex/framework/constants.py +++ b/cpex/framework/constants.py @@ -13,6 +13,8 @@ # Model constants. # Specialized plugin types. EXTERNAL_PLUGIN_TYPE = "external" +ISOLATED_VENV_PLUGIN_TYPE = "isolated_venv" + # MCP related constants. PYTHON_SUFFIX = ".py" diff --git a/cpex/framework/isolated/client.py b/cpex/framework/isolated/client.py new file mode 100644 index 00000000..d49dd6dd --- /dev/null +++ b/cpex/framework/isolated/client.py @@ -0,0 +1,341 @@ +# -*- coding: utf-8 -*- +"""Location: ./cpex/framework/isolated/client.py +Copyright 2025 +SPDX-License-Identifier: Apache-2.0 +Authors: Ted Habeck + +Isolated plugin client +Module that contains plugin client code to serve venv isolated plugins. +""" + +import asyncio +import functools +import hashlib +import json +import logging +import os +import shutil +import sys +import venv +from pathlib import Path + +from typing_extensions import Any, Optional + +from cpex.framework.base import Plugin +from cpex.framework.constants import CONTEXT, HOOK_TYPE, PAYLOAD, PLUGIN_NAME +from cpex.framework.errors import PluginError, convert_exception_to_error +from cpex.framework.hooks.registry import get_hook_registry +from cpex.framework.isolated.venv_comm import VenvProcessCommunicator +from cpex.framework.models import PluginConfig, PluginContext, PluginErrorModel, PluginPayload, PluginResult + +logger = logging.getLogger(__name__) + + +class IsolatedVenvPlugin(Plugin): + """IsolatedVenvPlugin class.""" + + def __init__(self, config: PluginConfig, plugin_dirs) -> None: + """Initialize the plugin's venv environment.""" + super().__init__(config) + self.implementation = "Python" + self.comm = None + self.plugin_dirs = plugin_dirs + # use the first plugin dir specified in the plugin configuration file. + path = Path(self.plugin_dirs[0]).resolve() + class_root = self.config.config.get("class_name").split(".")[0] + cache_root = path / class_root + self.plugin_path = cache_root + if not cache_root.exists(): + raise RuntimeError(f"plugin path does not exist: {str(cache_root)}") + self.cache_dir: Path = cache_root / ".cpex" / "venv_cache" + self.cache_dir.mkdir(parents=True, exist_ok=True) + + def _compute_requirements_hash(self, requirements_file: str) -> str: + """Compute SHA256 hash of requirements file content. + + Args: + requirements_file: Path to the requirements file + + Returns: + Hexadecimal hash string + """ + hasher = hashlib.sha256() + req_path = Path(requirements_file) + + if req_path.exists(): + with open(req_path, "rb") as f: + hasher.update(f.read()) + else: + # If no requirements file, use empty hash + hasher.update(b"") + + return hasher.hexdigest() + + def _get_cache_metadata_path(self, venv_path: str) -> Path: + """Get the path to the cache metadata file. + + Args: + venv_path: Path to the virtual environment + + Returns: + Path to the metadata file + """ + venv_name = Path(venv_path).name + return self.cache_dir / f"{venv_name}_metadata.json" + + def _is_venv_cache_valid(self, venv_path: str, requirements_file: str) -> bool: + """Check if cached venv is valid by comparing requirements hash. + + Args: + venv_path: Path to the virtual environment + requirements_file: Path to the requirements file + + Returns: + True if cache is valid, False otherwise + """ + venv_path_obj = Path(venv_path) + metadata_path = self._get_cache_metadata_path(venv_path) + + # Check if venv directory exists + if not venv_path_obj.exists(): + logger.debug("Venv path does not exist: %s", venv_path) + return False + + # Check if metadata file exists + if not metadata_path.exists(): + logger.debug("Metadata file does not exist: %s", metadata_path) + return False + + try: + # Load metadata + with open(metadata_path, "r", encoding="utf8") as f: + metadata = json.load(f) + + # Compute current requirements hash + current_hash = self._compute_requirements_hash(requirements_file) + + # Compare hashes + cached_hash = metadata.get("requirements_hash") + if cached_hash != current_hash: + logger.info("Requirements changed. Cached hash: %s, Current hash: %s", cached_hash, current_hash) + return False + + logger.info("Valid venv cache found for %s", venv_path) + return True + + except (json.JSONDecodeError, KeyError) as e: + logger.warning("Error reading cache metadata: %s", str(e)) + return False + + def _save_cache_metadata(self, venv_path: str, requirements_file: str) -> None: + """Save cache metadata for the venv. + + Args: + venv_path: Path to the virtual environment + requirements_file: Path to the requirements file + """ + metadata_path = self._get_cache_metadata_path(venv_path) + requirements_hash = self._compute_requirements_hash(requirements_file) + + metadata = { + "venv_path": str(Path(venv_path).resolve()), + "requirements_file": str(Path(requirements_file).resolve()) if Path(requirements_file).exists() else None, + "requirements_hash": requirements_hash, + "python_version": f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}", + } + + with open(metadata_path, "w", encoding="utf8") as f: + json.dump(metadata, f, indent=2) + + logger.info("Saved cache metadata to %s", metadata_path) + + async def create_venv( + self, venv_path: str = ".venv", requirements_file: Optional[str] = None, use_cache: bool = True + ) -> bool: + """Create a new venv environment with caching support. + + Args: + venv_path: Path where the virtual environment should be created + requirements_file: Path to requirements file for cache validation + use_cache: Whether to use cached venv if available + """ + venv_path_obj = Path(venv_path) + + # Check if we can use cached venv + if use_cache and requirements_file and self._is_venv_cache_valid(venv_path, requirements_file): + logger.info("✓ Using cached virtual environment at: %s", venv_path_obj.resolve()) + return False + + # If cache is invalid or not using cache, remove existing venv + if venv_path_obj.exists(): + logger.info("Removing existing venv at %s", venv_path) + shutil.rmtree(venv_path_obj) + + # Check Python version + python_version = sys.version_info + logger.info(f"Current Python version: {python_version.major}.{python_version.minor}.{python_version.micro}") + + # Create the EnvBuilder with common options + builder = venv.EnvBuilder( + system_site_packages=False, # Don't include system site-packages + clear=False, # Don't clear existing venv if it exists + symlinks=True, # Use symlinks (recommended on Unix-like systems) + upgrade=False, # Don't upgrade existing venv + with_pip=True, # Install pip in the venv + prompt=None, # Use default prompt (directory name) + ) + + # Create the virtual environment + logger.info(f"\nCreating virtual environment at: {venv_path_obj.resolve()}") + try: + builder.create(venv_path) + logger.info("✓ Virtual environment created successfully!") + logger.info("\nTo activate the virtual environment:") + logger.info(f" source {venv_path}/bin/activate # On Unix/macOS") + logger.info(f" {venv_path}\\Scripts\\activate # On Windows") + return True + except Exception as e: + logger.error(f"✗ Error creating virtual environment: {e}") + raise + + # Called by plugins/framework/loader/plugin.py load_and_instantiate_plugin() + # The plugins/framework/manager.py class (PluginManager) loads and registers the plugin + async def initialize(self) -> None: + """Initialize the plugin's venv environment with caching support.""" + # ensure the config is validated + if not os.path.exists(self.plugin_path): + raise FileNotFoundError(f"plugin path not found: {self.plugin_path}") + + venv_path = self.plugin_path / ".venv" + + # Prevent directory traversal: ensure requirements_file stays within plugin_path + requirements_file_input = self.config.config["requirements_file"] + + # Handle both relative and absolute paths + if isinstance(requirements_file_input, Path): + requirements_file = requirements_file_input + else: + requirements_file = Path(requirements_file_input) + + # If it's a relative path, resolve it relative to plugin_path + if not requirements_file.is_absolute(): + requirements_file = (self.plugin_path / requirements_file).resolve() + else: + # If absolute, resolve it to normalize + requirements_file = requirements_file.resolve() + + # Validate that the resolved path is within plugin_path (security check) + try: + requirements_file.relative_to(self.plugin_path.resolve()) + except ValueError as ve: + raise RuntimeError( + f"Invalid requirements_file path: {requirements_file_input}. " + f"Path must be within plugin directory: {self.plugin_path}" + ) from ve + + # Create venv with caching support + new_venv = await self.create_venv(venv_path=venv_path, requirements_file=requirements_file, use_cache=True) + + self.comm = VenvProcessCommunicator(venv_path) + + # Only install requirements if venv was newly created or cache was invalid + # Check if we need to install requirements + if new_venv: + logger.info("Installing requirements in venv") + self.comm.install_requirements(requirements_file) + # Save metadata after successful installation + self._save_cache_metadata(venv_path, requirements_file) + else: + logger.info("Using cached venv, skipping requirements installation") + + async def cleanup(self) -> None: + """Cleanup resources, including stopping the worker process.""" + if self.comm: + logger.info("Stopping worker process for plugin '%s'", self.name) + self.comm.stop_worker() + self.comm = None + + def _validate_hook_invocation(self, hook_type: str) -> type[PluginResult]: + """Validate hook type and communication channel. + + Args: + hook_type: The hook type to validate + + Returns: + The result type for the hook + + Raises: + PluginError: If validation fails + """ + registry = get_hook_registry() + result_type = registry.get_result_type(hook_type) + if not result_type: + raise PluginError( + error=PluginErrorModel( + message=f"Hook type '{hook_type}' not registered in hook registry", plugin_name=self.name + ) + ) + + if not self.comm: + raise PluginError(error=PluginErrorModel(message="Plugin comm not initialized", plugin_name=self.name)) + + return result_type + + def _build_hook_task(self, hook_type: str, payload: PluginPayload, context: PluginContext) -> dict[str, Any]: + """Build task dictionary for hook invocation. + + Args: + hook_type: The hook type to invoke + payload: The payload to send + context: The context to send + + Returns: + Task dictionary ready for transmission + """ + # Cache config lookups + class_name = self.config.config["class_name"] + safe_config = self.config.get_safe_config() + + # Serialize payload and context to ensure they are JSON-serializable + serialized_payload = payload.model_dump(mode="json") if payload is not None else None + serialized_context = context.model_dump(mode="json") if context is not None else None + + return { + "task_type": "load_and_run_hook", + "plugin_dirs": self.plugin_dirs, + "class_name": class_name, + "config": safe_config, + HOOK_TYPE: hook_type, + PLUGIN_NAME: self.name, + PAYLOAD: serialized_payload, + CONTEXT: serialized_context, + } + + async def invoke_hook(self, hook_type: str, payload: PluginPayload, context: PluginContext) -> PluginResult: + """Invoke a plugin in the context of the active venv (self.comm)""" + try: + # Validate and get result type + self._validate_hook_invocation(hook_type) + + # Build and send task + task_data = self._build_hook_task(hook_type, payload, context) + loop = asyncio.get_event_loop() + result_dict: dict[str, Any] = await loop.run_in_executor( + None, + functools.partial( + self.comm.send_task, + script_path="cpex/framework/isolated/worker.py", + task_data=task_data, + max_content_size=self.config.max_content_size, + ), + ) + # Convert response to typed result + registry = get_hook_registry() + return registry.json_to_result(hook_type, result_dict) + + except PluginError: + logger.exception("Plugin error invoking hook '%s' for plugin '%s'", hook_type, self.name) + raise + except Exception as e: + logger.exception("Unexpected error invoking hook '%s' for plugin '%s'", hook_type, self.name) + raise PluginError(error=convert_exception_to_error(e, plugin_name=self.name)) from e diff --git a/cpex/framework/isolated/venv_comm.py b/cpex/framework/isolated/venv_comm.py new file mode 100644 index 00000000..4ef77163 --- /dev/null +++ b/cpex/framework/isolated/venv_comm.py @@ -0,0 +1,271 @@ +# -*- coding: utf-8 -*- +""" +Location: ./cpex/framework/isolated/venv_comm.py +Copyright 2025 +SPDX-License-Identifier: Apache-2.0 +Authors: Fred Araujo, Ted Habeck +""" + +import logging +import os +import subprocess +import sys +import threading +import uuid +from pathlib import Path +from queue import Empty, Queue +from typing import Any, Optional + +import orjson + +logger = logging.getLogger(__name__) + + +class VenvProcessCommunicator: + """Handles communication with a long-running child process in a different virtual environment.""" + + def __init__(self, venv_path: str) -> None: + """ + Initialize communicator with target virtual environment. + + Args: + venv_path (str): Path to the virtual environment directory + """ + self.venv_path = Path(venv_path) + self.python_executable = self._get_python_executable() + self.process: Optional[subprocess.Popen] = None + self.reader_thread: Optional[threading.Thread] = None + self.stderr_thread: Optional[threading.Thread] = None + self.response_queues: dict[str, Queue] = {} + self.lock = threading.Lock() + self.running = False + logger.info("cwd: %s", os.getcwd()) + + def _get_python_executable(self): + """Get the Python executable path for the target venv.""" + if sys.platform == "win32": + python_exe = self.venv_path / "Scripts" / "python.exe" + else: + python_exe = self.venv_path / "bin" / "python" + + if not python_exe.exists(): + raise FileNotFoundError(f"Python executable not found at {python_exe}") + + return str(python_exe) + + def install_requirements(self, requirements_file: str) -> None: + """ + Install Python requirements from a file in the target venv. + Args: + requirements_file (str): Path to the requirements file. + """ + requirements_path = Path(requirements_file) + if requirements_path.exists(): + try: + subprocess.check_call([self.python_executable, "-m", "pip", "install", "-r", requirements_file]) + except Exception as e: + raise RuntimeError(f"Failed to install requirements from {requirements_file}") from e + + def start_worker(self, script_path: str) -> None: + """ + Start the long-running worker process. + + Args: + script_path (str): Path to the worker script + """ + if self.running: + logger.warning("Worker process already running") + return + + try: + # Start child process + self.process = subprocess.Popen( + [self.python_executable, script_path], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + bufsize=1, # Line buffered + cwd=os.getcwd(), + env={"PLUGINS_CONFIG_FILE": os.environ.get("PLUGINS_CONFIG_FILE", "plugins/config.yaml")}, + ) + + self.running = True + + # Start reader thread to handle responses + self.reader_thread = threading.Thread(target=self._read_responses, daemon=True) + self.reader_thread.start() + + # Start stderr reader thread to capture errors + self.stderr_thread = threading.Thread(target=self._read_stderr, daemon=True) + self.stderr_thread.start() + + logger.info("Worker process started with PID: %s", self.process.pid) + + except Exception as e: + self.running = False + raise RuntimeError(f"Failed to start worker process: {e}") from e + + def _read_stderr(self) -> None: + """Background thread to read and log stderr from worker process.""" + if not self.process or not self.process.stderr: + return + + while self.running and self.process and self.process.stderr: + try: + line = self.process.stderr.readline() + if not line: + break + # Log stderr output from worker + logger.debug("Worker stderr: %s", line.strip()) + except Exception as e: + logger.error("Error reading stderr: %s", e) + break + + def _read_responses(self) -> None: + """Background thread to read responses from worker process.""" + while self.running and self.process and self.process.stdout: + try: + line = self.process.stdout.readline() + if not line: + # Process has terminated + logger.warning("Worker process stdout closed") + break + + line = line.strip() + if not line: + # Empty line, skip + continue + + try: + response = orjson.loads(line) + request_id = response.get("request_id") + + if request_id: + with self.lock: + if request_id in self.response_queues: + self.response_queues[request_id].put(response) + logger.debug("Response queued for request_id: %s", request_id) + else: + logger.warning("Received response for unknown request_id: %s", request_id) + else: + logger.warning("Received response without request_id: %s", line[:100]) + + except orjson.JSONDecodeError as e: + logger.error("Failed to decode response: %s, line: %s", e, line[:200]) + + except Exception as e: + logger.exception("Error reading response: %s", e) + break + + self.running = False + logger.info("Response reader thread terminated") + + def send_task( + self, script_path: str, task_data: Any, timeout: float = 30.0, max_content_size: int = 10000000 + ) -> Any: + """ + Send a task to the long-running worker process and get response. + + Args: + script_path (str): Path to the child script (used for worker initialization) + task_data (dict): Data to send to child process + timeout (float): Timeout in seconds for waiting for response + + Returns: + dict: Response from child process + """ + # Start worker if not running + if not self.running: + self.start_worker(script_path) + + # Generate unique request ID + request_id = str(uuid.uuid4()) + task_data["request_id"] = request_id + + # Create response queue for this request + response_queue: Queue = Queue() + with self.lock: + self.response_queues[request_id] = response_queue + + try: + # Send task to worker + input_json = orjson.dumps(task_data).decode() + if len(input_json) > max_content_size: + # remove the request_id from the response queue and raise + self.response_queues.pop(request_id) + raise RuntimeError(f"task_data exceeds max_content_size. {len(input_json)}") + if self.process and self.process.stdin: + self.process.stdin.write(input_json + "\n") + self.process.stdin.flush() + else: + raise RuntimeError("Worker process stdin not available") + + # Wait for response + try: + response = response_queue.get(timeout=timeout) + + # Check for errors in response + if response.get("status") == "error": + raise RuntimeError(f"Worker process error: {response.get('message')}") + + # Remove request_id from response before returning + response.pop("request_id", None) + return response + + except Empty: + raise RuntimeError(f"Worker process timed out after {timeout} seconds") + + finally: + # Clean up response queue + with self.lock: + self.response_queues.pop(request_id, None) + + def stop_worker(self) -> None: + """Stop the long-running worker process.""" + if not self.running: + return + + self.running = False + + try: + if self.process: + # Send shutdown signal + if self.process.stdin: + try: + shutdown_task = {"task_type": "shutdown", "request_id": "shutdown"} + self.process.stdin.write(orjson.dumps(shutdown_task).decode() + "\n") + self.process.stdin.flush() + except Exception as e: + logger.warning("Failed to send shutdown signal: %s", e) + + # Wait for process to terminate gracefully + try: + self.process.wait(timeout=5.0) + except subprocess.TimeoutExpired: + logger.warning("Worker process did not terminate gracefully, killing it") + self.process.kill() + self.process.wait() + + logger.info("Worker process stopped") + + except Exception as e: + logger.error("Error stopping worker process: %s", e) + + finally: + self.process = None + if self.reader_thread and self.reader_thread.is_alive(): + self.reader_thread.join(timeout=2.0) + self.reader_thread = None + if self.stderr_thread and self.stderr_thread.is_alive(): + self.stderr_thread.join(timeout=2.0) + self.stderr_thread = None + + def is_alive(self) -> bool: + """Check if the worker process is alive and running.""" + return self.running and self.process is not None and self.process.poll() is None + + def __del__(self): + """Cleanup when object is destroyed.""" + if hasattr(self, "running"): + self.stop_worker() diff --git a/cpex/framework/isolated/worker.py b/cpex/framework/isolated/worker.py new file mode 100644 index 00000000..426b660f --- /dev/null +++ b/cpex/framework/isolated/worker.py @@ -0,0 +1,238 @@ +# -*- coding: utf-8 -*- +"""Location: ./cpex/framework/isolated/worker.py +Copyright 2025 +SPDX-License-Identifier: Apache-2.0 +Authors: Ted Habeck, Fred Araujo + +Isolated plugin server +Module that contains plugin server code to invoke hooks in native plugins. +""" + +import asyncio +import hashlib +import importlib.metadata +import json +import logging +import platform +import sys +from pathlib import Path +from types import ModuleType +from typing import List, Type, cast + +from cpex.framework.base import HookRef, Plugin, PluginRef +from cpex.framework.constants import HOOK_TYPE +from cpex.framework.loader.plugin import ALLOWED_PLUGIN_DIRS +from cpex.framework.manager import PluginExecutor +from cpex.framework.models import PluginConfig, PluginContext +from cpex.framework.utils import parse_class_name + +logger = logging.getLogger(__name__) + + +class TaskProcessor: + """ + A Caching task processor that only reloads the plugin if the config has changed. + """ + + config_hash: str + module_path_hash: str + hook_ref: HookRef + executor: PluginExecutor + plugin_config: PluginConfig | None = None + + def __init__(self) -> None: + """Initialize defaults.""" + hasher = hashlib.sha256() + hasher.update(b"") + self.config_hash = hasher.hexdigest() + self.module_path_hash = self.config_hash + + def compute_hash(self, json_config_or_module_path: str): + """Compute the hash of the supplied string""" + hasher = hashlib.sha256() + hasher.update(json_config_or_module_path.encode()) + return hasher.hexdigest() + + def initialize( + self, + hook_ref: HookRef, + executor: PluginExecutor, + json_config: str, + module_path: str, + plugin_config: PluginConfig, + ): + """Assign locals, and compute hashes.""" + self.hook_ref = hook_ref + self.executor = executor + self.config_hash = self.compute_hash(json_config_or_module_path=json_config) + self.module_path_hash = self.compute_hash(json_config_or_module_path=module_path) + self.plugin_config = plugin_config + + +def get_environment_info(): + """Get information about current Python environment.""" + return { + "python_version": sys.version, + "python_executable": sys.executable, + "platform": platform.platform(), + "installed_packages": [str(d) for d in importlib.metadata.entry_points()][:10], # First 10 packages + } + + +async def process_task(task_data, tp: TaskProcessor): + """Process the task received from parent.""" + task_type = task_data.get("task_type") + + if task_type == "info": + return { + "status": "success", + "environment": get_environment_info(), + "message": "Environment info retrieved successfully", + } + # This is essentially emulating the plugin loader's load and instantiate plugin + if task_type == "load_and_run_hook": + # relative path from project root. + json_config = task_data.get("config") + config_raw = json.loads(json_config) + module_paths: List[str] = task_data.get("plugin_dirs") + resolved_paths: List[str] = [] + for module_path in module_paths: + path = Path(module_path).resolve() + resolved_module_path = str(path) + if path.exists(): + resolved_paths.append(resolved_module_path) + if resolved_module_path not in sys.path: + if resolved_module_path.startswith(tuple(ALLOWED_PLUGIN_DIRS)): + sys.path.append(resolved_module_path) + else: + raise RuntimeError(f"plugin module_path '{resolved_module_path}' not in allowed plugin dirs.") + else: + raise RuntimeError(f"plugin module_path '{resolved_module_path}' does not exist.") + + if tp.config_hash != tp.compute_hash(json_config): + # pull the resolved plugin path and only add the module path if it has the same root + config: PluginConfig = PluginConfig(**config_raw) + hook_type = task_data.get(HOOK_TYPE) + cls_name: str = task_data.get("class_name") + mod_name, n_cls_name = parse_class_name(cls_name) + module: ModuleType = importlib.import_module(mod_name) + # cool, we found the module, and verified it implemented the hook type. + class_ = getattr(module, n_cls_name) + plugin_type = cast(Type[Plugin], class_) + plugin = plugin_type(config) + await plugin.initialize() + # now invoke the hook + plugin_ref = PluginRef(plugin) + hook_ref = HookRef(hook_type, plugin_ref) + executor = PluginExecutor(None, 30) + tp.initialize( + hook_ref=hook_ref, + executor=executor, + json_config=json_config, + module_path=json.dumps(resolved_paths), + plugin_config=config, + ) + # retrieve the context + context = task_data.get("context") + plugin_context = PluginContext( + state=context.get("state"), global_context=context.get("global_context"), metadata=context.get("metadata") + ) + result = await tp.executor.execute_plugin( + hook_ref=tp.hook_ref, + payload=task_data.get("payload"), + local_context=plugin_context, + violations_as_exceptions=False, + ) + return result + return { + "status": "error", + "message": "task type not supported.", + "request_id": task_data.get("request_id", "unknown") if "task_data" in locals() else "unknown", + } + + +async def main(): + """Main function - continuously read from stdin, process tasks, write to stdout.""" + logger.info("Worker process started, waiting for tasks...") + + try: + # Cache the plugin so that it only has to be initialized once + tp = TaskProcessor() + # Continuously read and process tasks + while True: + try: + # Read one line at a time + if tp.plugin_config: + line = sys.stdin.readline(limit=int(tp.plugin_config.max_content_size)) + else: + # on the first read, the plugin_config has not yet been initialized so just read. + line = sys.stdin.readline() + # Check for EOF + if not line: + logger.info("EOF received, shutting down worker") + break + + # Parse the task + task_data = json.loads(line.strip()) + request_id = task_data.get("request_id", "unknown") + + # Check for shutdown signal + if task_data.get("task_type") == "shutdown": + logger.info("Shutdown signal received") + response = {"status": "success", "message": "Shutting down", "request_id": request_id} + print(json.dumps(response), flush=True) + break + + # Process the task + response = await process_task(task_data, tp) + + # Serialize response + if response: + serializable_response = response.model_dump(mode="json") + else: # none case should be a failure rather than success. + serializable_response = {"status": "success"} + + # Add request_id to response + serializable_response["request_id"] = request_id + + serialized_response = json.dumps(serializable_response) + # Send response back to parent (one line per response) + if tp.plugin_config: + if len(serialized_response) > tp.plugin_config.max_content_size: + logger.error("Serialized response exceeds max content size") + error_response = { + "status": "error", + "message": "Serialized response exceeds max content size", + "request_id": request_id, + } + serialized_response = json.dumps(error_response) + print(serialized_response, flush=True) + + except json.JSONDecodeError as e: + error_response = { + "status": "error", + "message": f"Invalid JSON input: {str(e)}", + "request_id": "unknown", + } + print(json.dumps(error_response), flush=True) + + except Exception as e: + logger.error("Error processing task: %s", str(e)) + error_response = { + "status": "error", + "message": f"Unexpected error: {str(e)}", + "request_id": "unknown", + } + print(json.dumps(error_response), flush=True) + + except KeyboardInterrupt: + logger.info("Worker interrupted") + except Exception: + logger.exception("Fatal error in worker main loop") + finally: + logger.info("Worker process shutting down") + + +if __name__ == "__main__": + logging.basicConfig(level=logging.INFO) + asyncio.run(main()) diff --git a/cpex/framework/loader/plugin.py b/cpex/framework/loader/plugin.py index 48248f6d..c1a83170 100644 --- a/cpex/framework/loader/plugin.py +++ b/cpex/framework/loader/plugin.py @@ -17,7 +17,7 @@ # First-Party from cpex.framework.base import Plugin -from cpex.framework.constants import EXTERNAL_PLUGIN_TYPE +from cpex.framework.constants import EXTERNAL_PLUGIN_TYPE, ISOLATED_VENV_PLUGIN_TYPE from cpex.framework.external.mcp.client import ExternalPlugin from cpex.framework.models import PluginConfig from cpex.framework.utils import import_module, parse_class_name @@ -53,6 +53,7 @@ def __init__(self) -> None: {} """ self._plugin_types: dict[str, Type[Plugin]] = {} + self.plugin_dirs: list[str] = [] def __get_plugin_type(self, kind: str) -> Type[Plugin]: """Import a plugin type from a python module. @@ -142,6 +143,13 @@ async def load_and_instantiate_plugin(self, config: PluginConfig) -> Plugin | No await plugin.initialize() return plugin + if config.kind == ISOLATED_VENV_PLUGIN_TYPE: + from cpex.framework.isolated.client import IsolatedVenvPlugin # pylint: disable=import-outside-toplevel + + plugin: Plugin = IsolatedVenvPlugin(config, plugin_dirs=self.plugin_dirs.copy()) + await plugin.initialize() + return plugin + # Handle other plugin types if config.kind not in self._plugin_types: self.__register_plugin_type(config.kind) @@ -160,8 +168,10 @@ def append_to_search_path(self, plugin_dirs: list[str]) -> None: """ for plugin_dir in plugin_dirs: resolved = str(Path(plugin_dir).resolve()) - if resolved not in sys.path and resolved.startswith(tuple(ALLOWED_PLUGIN_DIRS)): - sys.path.append(resolved) + if resolved.startswith(tuple(ALLOWED_PLUGIN_DIRS)): + self.plugin_dirs.append(plugin_dir) + if resolved not in sys.path: + sys.path.append(resolved) async def shutdown(self) -> None: """Shutdown and cleanup plugin loader. diff --git a/cpex/framework/models.py b/cpex/framework/models.py index c0bd5dbb..6e6de199 100644 --- a/cpex/framework/models.py +++ b/cpex/framework/models.py @@ -12,9 +12,13 @@ # Standard import logging import os +import re from enum import Enum, StrEnum from pathlib import Path -from typing import Any, Generic, Optional, Self, TypeVar, Union +from typing import Any, Generic, List, Optional, Self, TypeVar, Union + +import orjson +from packaging.version import InvalidVersion, Version # Third-Party from pydantic import ( @@ -1190,6 +1194,7 @@ class PluginConfig(BaseModel): config (dict[str, Any]): the plugin specific configurations. mcp (Optional[MCPClientConfig]): Client-side MCP configuration (gateway connecting to plugin). grpc (Optional[GRPCClientConfig]): Client-side gRPC configuration (gateway connecting to plugin). + max_content_size (Optional(int)): The maximum size of payload, context, """ name: str @@ -1203,6 +1208,7 @@ class PluginConfig(BaseModel): mode: PluginMode = PluginMode.SEQUENTIAL on_error: OnError = OnError.FAIL priority: int = 100 # Lower = higher priority + max_content_size: int = 10000000 @model_validator(mode="before") @classmethod @@ -1295,6 +1301,38 @@ def check_config_and_external(self, info: ValidationInfo) -> Self: # pylint: di return self + def get_safe_config(self) -> str: + """Return a new PluginConfig instance without validator methods. + + This method creates a new PluginConfig instance from the serialized data, + ensuring that validator methods are not included. This is useful when passing + the config to external processes or serializing it. + + Returns: + PluginConfig: A new PluginConfig instance with only data fields. + """ + # Get the JSON-safe dictionary representation + safe_data = self.to_json() + + # Create a new PluginConfig instance from the safe data + # This will run validators again, but the resulting object will be clean + return orjson.dumps(safe_data).decode() + + def to_json(self) -> dict[str, Any]: + """Serialize the PluginConfig object to a JSON-compatible dictionary. + + This method converts the PluginConfig instance to a dictionary that can be + serialized to JSON. It explicitly excludes validator methods and other + non-data attributes, ensuring only the actual configuration fields are included. + + Returns: + dict[str, Any]: A dictionary representation of the PluginConfig object + with all data fields, ready for JSON serialization. + """ + # Get the base serialization from Pydantic + data = self.model_dump(mode="json", exclude_none=False, exclude_unset=False) + return data + class PluginManifest(BaseModel): """Plugin manifest. @@ -1567,3 +1605,293 @@ class PluginPayload(BaseModel): """ model_config = ConfigDict(frozen=True, arbitrary_types_allowed=True) + + +class PluginPackageInfo(BaseModel): + """Plugin package information. + + Defines how to install a plugin: + - `pypi_package`: Install from PyPI (e.g., "apex-pii-filter") + - `git_repository`: Install from Git (e.g., "https://github.com/example/plugin.git") + - `git_branch/tag/commit`: Specify which version to clone + - `version_constraint`: Semantic version constraints (e.g., ">=1.0.0,<2.0.0") + + Examples: + >>> pkg = PluginPackageInfo(git_repository="https://github.com/user/repo.git", + git_branch_tag_commit="v1.0.0", + version_constraint=">=1.0.0") + >>> pkg2 = PluginPackageInfo(pypi_package="my-package", version_constraint=">=1.0.0") + """ + + pypi_package: Optional[str] = None + git_repository: Optional[str] = None + git_branch_tag_commit: Optional[str] = None + version_constraint: Optional[str] = None + + @field_validator("pypi_package", mode="after") + @classmethod + def validate_pypi_package(cls, pypi_package: str | None) -> str | None: + """Validate PyPI package name format. + + Args: + pypi_package: The PyPI package name to validate. + + Returns: + The validated package name or None if none is set. + + Raises: + ValueError: If the package name is invalid. + """ + if pypi_package is not None and pypi_package != "": + # PyPI package names must contain only ASCII letters, numbers, hyphens, underscores, and periods + # They cannot start or end with hyphens or periods + if not pypi_package.strip(): + raise ValueError("PyPI package name cannot be empty or whitespace") + + if not re.match(r"^[a-zA-Z0-9]([a-zA-Z0-9._-]*[a-zA-Z0-9])?$", pypi_package): + raise ValueError( + f"Invalid PyPI package name '{pypi_package}'. " + "Package names must start and end with a letter or number, " + "and can only contain ASCII letters, numbers, hyphens, underscores, and periods." + ) + + # Check length (PyPI has a 214 character limit for package names) + if len(pypi_package) > 214: + raise ValueError(f"PyPI package name '{pypi_package}' exceeds maximum length of 214 characters") + + return pypi_package if pypi_package != "" else None + + @field_validator("git_repository", mode="after") + @classmethod + def validate_git_repository(cls, git_repository: str | None) -> str | None: + """Validate Git repository URL format. + + Args: + git_repository: The Git repository URL to validate. + + Returns: + The validated repository URL or None if none is set. + + Raises: + ValueError: If the repository URL is invalid. + """ + if git_repository is not None and git_repository != "": + if not git_repository.strip(): + raise ValueError("Git repository URL cannot be empty or whitespace") + + # Support common Git URL formats: https://, git://, ssh://, git@ + git_url_pattern = re.compile( + r"^(https?://|git://|git@)" r"[a-zA-Z0-9._-]+" r"(/|:)" r"[a-zA-Z0-9._/-]+" r"(\.git)?$" + ) + + if not git_url_pattern.match(git_repository): + raise ValueError( + f"Invalid Git repository URL '{git_repository}'. " + "Must be a valid Git URL (e.g., https://github.com/user/repo.git, " + "git@github.com:user/repo.git)" + ) + + # Additional validation for https/http URLs using existing validator + if git_repository.startswith(("http://", "https://")): + validate_plugin_url(git_repository, "Git repository URL") + + return git_repository if git_repository != "" else None + + @field_validator("git_branch_tag_commit", mode="after") + @classmethod + def validate_git_branch_tag_commit(cls, git_branch_tag_commit: str | None) -> str | None: + """Validate Git branch, tag, or commit reference. + + Args: + git_branch_tag_commit: The Git reference to validate. + + Returns: + The validated reference or None if none is set. + + Raises: + ValueError: If the reference is invalid. + """ + if git_branch_tag_commit is not None and git_branch_tag_commit != "": + if not git_branch_tag_commit.strip(): + raise ValueError("Git branch/tag/commit cannot be empty or whitespace") + + # Git refs can contain alphanumeric characters, hyphens, underscores, slashes, and periods + # Commit hashes are typically 7-40 hex characters + if not re.match(r"^[a-zA-Z0-9._/-]+$", git_branch_tag_commit): + raise ValueError( + f"Invalid Git branch/tag/commit '{git_branch_tag_commit}'. " + "Must contain only alphanumeric characters, hyphens, underscores, slashes, and periods." + ) + + # Check for common invalid patterns + if git_branch_tag_commit.startswith(("/", ".", "-")) or git_branch_tag_commit.endswith(("/", ".")): + raise ValueError( + f"Invalid Git branch/tag/commit '{git_branch_tag_commit}'. " + "Cannot start with /, ., or - or end with / or ." + ) + + if len(git_branch_tag_commit) > 255: + raise ValueError( + f"Git branch/tag/commit '{git_branch_tag_commit}' exceeds maximum length of 255 characters" + ) + + return git_branch_tag_commit if git_branch_tag_commit != "" else None + + @field_validator("version_constraint", mode="after") + @classmethod + def validate_version_constraint(cls, version_constraint: str | None) -> str | None: + """Validate semantic version constraint format. + + Args: + version_constraint: The version constraint to validate. + + Returns: + The validated version constraint or None if none is set. + + Raises: + ValueError: If the version constraint is invalid. + """ + if version_constraint is not None and version_constraint != "": + if not version_constraint.strip(): + raise ValueError("Version constraint cannot be empty or whitespace") + + # Validate semantic version constraint format (e.g., ">=1.0.0,<2.0.0", "~=1.2.3", "==1.0.0") + # Pattern for version specifiers: operator + optional space + version number + version_pattern = re.compile(r"^(==|!=|<=|>=|<|>|~=|===)\s*" r"\d+(\.\d+)*" r"([a-zA-Z0-9._-]*)?$") + + # Split by comma for multiple constraints + constraints = [c.strip() for c in version_constraint.split(",")] + + for constraint in constraints: + if not constraint: + raise ValueError("Version constraint cannot contain empty parts") + + if not version_pattern.match(constraint): + raise ValueError( + f"Invalid version constraint '{constraint}'. " + "Must follow PEP 440 format (e.g., '>=1.0.0', '~=1.2.3', '==1.0.0,<2.0.0')" + ) + + if len(version_constraint) > 255: + raise ValueError(f"Version constraint '{version_constraint}' exceeds maximum length of 255 characters") + + return version_constraint if version_constraint != "" else None + + @model_validator(mode="after") + def validate_installation_method(self) -> Self: + """Validate that at least one installation method is specified. + + Returns: + The validated model instance. + + Raises: + ValueError: If neither PyPI package nor Git repository is specified. + """ + if not self.pypi_package and not self.git_repository: + raise ValueError( + "At least one installation method must be specified: either 'pypi_package' or 'git_repository'" + ) + + # If git_branch_tag_commit is specified, git_repository must also be specified + if self.git_branch_tag_commit and not self.git_repository: + raise ValueError("'git_branch_tag_commit' can only be specified when 'git_repository' is provided") + + return self + + +class PluginVersionInfo(BaseModel): + """Represents the version information of a plugin. + + Attributes: + version (str): The version of the plugin. + released (str): The release date of the plugin. + breaking_changes: (bool): Whether the version contains breaking changes. + deprecated (bool): Whether the version is deprecated. + manifest_file (str): The manifest file of the plugin. + changelog (str): The release notes for the plugin. + min_max_framework_version (str): The minimum and maximum framework version required for the plugin (comma separated). + """ + + version: str + released: str + breaking_changes: Optional[bool] = None + deprecated: bool = False + manifest_file: str + changelog: Optional[str] = None + min_max_framework_version: Optional[str] = "0.1.0.dev4,0.1.0.dev4" + + +class PluginVersionRegistry(BaseModel): + """Represents the version registry of a plugin. + Attributes: + versions (List[PluginVersionInfo]): A list of PluginVersionInfo objects representing the different versions of the plugin. + """ + + latest: Optional[PluginVersionInfo] = None + latest_prerelease: Optional[PluginVersionInfo] = None + versions: List[PluginVersionInfo] + + def get_version(self) -> Optional[PluginVersionInfo]: + """Returns the latest version of the plugin. + Returns: + Optional[PluginVersionInfo]: The latest version of the plugin, or None if no version is available. + """ + return self.latest + + def get_latest_compatible(self, framework_version: str) -> Optional[PluginVersionInfo]: + """Returns the latest compatible version for the given framework version. + + Args: + framework_version (str): The framework version to check compatibility against. + + Returns: + Optional[PluginVersionInfo]: The latest compatible version, or None if no compatible version is found. + """ + + try: + fw_version = Version(framework_version) + except InvalidVersion: + logging.getLogger(__name__).warning(f"Invalid framework version format: {framework_version}") + return None + + compatible_versions = [] + + for version_info in self.versions: + if not version_info.min_max_framework_version: + continue + + try: + # Parse min and max framework versions + parts = version_info.min_max_framework_version.split(",") + if len(parts) != 2: + continue + + min_version = Version(parts[0].strip()) + max_version = Version(parts[1].strip()) + + # Check if framework version is within range + if min_version <= fw_version <= max_version: + compatible_versions.append(version_info) + + except (InvalidVersion, ValueError): + continue + + if not compatible_versions: + return None + + # Sort by version and return the latest + try: + sorted_versions = sorted(compatible_versions, key=lambda v: Version(v.version), reverse=True) + return sorted_versions[0] + except InvalidVersion: + # If sorting fails, return the first compatible version + return compatible_versions[0] + + +class PluginInstallationType(StrEnum): + """Plugin installation type.""" + + BUNDLED = "bundled" # Pre-installed with framework + PYPI = "pypi" # Installed from PyPI + GIT = "git" # Installed from Git repo + LOCAL = "local" # Installed from local path diff --git a/cpex/templates/isolated/cookiecutter.json b/cpex/templates/isolated/cookiecutter.json new file mode 100644 index 00000000..1016c1e9 --- /dev/null +++ b/cpex/templates/isolated/cookiecutter.json @@ -0,0 +1,8 @@ +{ + "plugin_name": "MyFilter", + "plugin_slug": "{{ cookiecutter.plugin_name|lower|replace(' ', '_')|replace('-', '_') }}", + "version": "0.1.0", + "author": "Your Name", + "email": "your@email.com", + "description": "A filter plugin" +} diff --git a/cpex/templates/isolated/{{cookiecutter.plugin_slug}}/README.md b/cpex/templates/isolated/{{cookiecutter.plugin_slug}}/README.md new file mode 100644 index 00000000..fb0a2a5c --- /dev/null +++ b/cpex/templates/isolated/{{cookiecutter.plugin_slug}}/README.md @@ -0,0 +1,10 @@ +# {{cookiecutter.plugin_name}} for ContextForge + +{{cookiecutter.description}}. + + +## Installation + +1. Copy .env.example .env +2. Enable plugins in `.env` +3. Add the plugin configuration to `plugins/config.yaml`: diff --git a/cpex/templates/isolated/{{cookiecutter.plugin_slug}}/__init__.py b/cpex/templates/isolated/{{cookiecutter.plugin_slug}}/__init__.py new file mode 100644 index 00000000..11905acf --- /dev/null +++ b/cpex/templates/isolated/{{cookiecutter.plugin_slug}}/__init__.py @@ -0,0 +1,7 @@ +"""ContextForge {{cookiecutter.plugin_name}} Plugin - {{cookiecutter.description}}. + +Copyright 2025 +SPDX-License-Identifier: Apache-2.0 +Authors: {{cookiecutter.author}} + +""" diff --git a/cpex/templates/isolated/{{cookiecutter.plugin_slug}}/config.yaml b/cpex/templates/isolated/{{cookiecutter.plugin_slug}}/config.yaml new file mode 100644 index 00000000..cd793837 --- /dev/null +++ b/cpex/templates/isolated/{{cookiecutter.plugin_slug}}/config.yaml @@ -0,0 +1,37 @@ +plugins: + - name: "{{ cookiecutter.plugin_name }}" + {% set class_parts = cookiecutter.plugin_name.replace(' ', '_').replace('-','_').split('_') -%} + {% if class_parts|length > 1 -%} + {% set class_name = class_parts|map('capitalize')|join -%} + {% else -%} + {% set class_name = class_parts|join -%} + {% endif -%} + kind: "isolated_venv" + description: "{{ cookiecutter.description }}" + version: "{{ cookiecutter.version }}" + author: "{{ cookiecutter.author }}" + hooks: ["prompt_pre_fetch", "prompt_post_fetch", "tool_pre_invoke", "tool_post_invoke"] + tags: ["plugin"] + mode: "enforce" # enforce | permissive | disabled + priority: 150 + conditions: + # Apply to specific tools/servers + - server_ids: [] # Apply to all servers + tenant_ids: [] # Apply to all tenants + config: + # Plugin config dict passed to the plugin constructor + # Plugin config dict passed to the plugin constructor + class_name: "{{ cookiecutter.plugin_slug }}.plugin.{{ class_name }}" + requirements_file: "requirements.txt" + +# Plugin directories to scan +plugin_dirs: + - "{{ cookiecutter.plugin_slug }}" + +# Global plugin settings +plugin_settings: + parallel_execution_within_band: true + plugin_timeout: 30 + fail_on_plugin_error: false + enable_plugin_api: true + plugin_health_check_interval: 60 diff --git a/cpex/templates/isolated/{{cookiecutter.plugin_slug}}/plugin-manifest.yaml b/cpex/templates/isolated/{{cookiecutter.plugin_slug}}/plugin-manifest.yaml new file mode 100644 index 00000000..4614398f --- /dev/null +++ b/cpex/templates/isolated/{{cookiecutter.plugin_slug}}/plugin-manifest.yaml @@ -0,0 +1,23 @@ +name: "{{ cookiecutter.plugin_name }}" +{% set class_parts = cookiecutter.plugin_name.replace(' ', '_').replace('-','_').split('_') -%} +{% if class_parts|length > 1 -%} +{% set class_name = class_parts|map('capitalize')|join -%} +{% else -%} +{% set class_name = class_parts|join -%} +{% endif -%} +description: "{{cookiecutter.description}}" +kind: "isolated_venv" +author: "{{cookiecutter.author}}" +version: "{{cookiecutter.version}}" +available_hooks: + - "prompt_pre_hook" + - "prompt_post_hook" + - "tool_pre_hook" + - "tool_post_hook" +default_config: + # Plugin config dict passed to the plugin constructor + class_name: "{{ cookiecutter.plugin_slug }}.plugin.{{ class_name }}" + requirements_file: "requirements.txt" +monorepo: + package_source: contextforge-plugins-python/{{ cookiecutter.plugin_slug }} +# package_info: \ No newline at end of file diff --git a/cpex/templates/isolated/{{cookiecutter.plugin_slug}}/plugin.py b/cpex/templates/isolated/{{cookiecutter.plugin_slug}}/plugin.py new file mode 100644 index 00000000..5c2db6c6 --- /dev/null +++ b/cpex/templates/isolated/{{cookiecutter.plugin_slug}}/plugin.py @@ -0,0 +1,90 @@ +"""{{ cookiecutter.description }}. + +Copyright 2025 +SPDX-License-Identifier: Apache-2.0 +Authors: {{ cookiecutter.author }} + +This module loads configurations for plugins. +""" + +# First-Party +from cpex.framework import ( + Plugin, + PluginConfig, + PluginContext, + PromptPosthookPayload, + PromptPosthookResult, + PromptPrehookPayload, + PromptPrehookResult, + ToolPostInvokePayload, + ToolPostInvokeResult, + ToolPreInvokePayload, + ToolPreInvokeResult, +) + + +{% set class_parts = cookiecutter.plugin_name.replace(' ', '_').replace('-','_').split('_') -%} +{% if class_parts|length > 1 -%} +{% set class_name = class_parts|map('capitalize')|join -%} +{% else -%} +{% set class_name = class_parts|join -%} +{% endif -%} +class {{ class_name }}(Plugin): + """{{ cookiecutter.description }}.""" + + def __init__(self, config: PluginConfig): + """Entry init block for plugin. + + Args: + logger: logger that the skill can make use of + config: the skill configuration + """ + super().__init__(config) + + async def prompt_pre_fetch(self, payload: PromptPrehookPayload, context: PluginContext) -> PromptPrehookResult: + """The plugin hook run before a prompt is retrieved and rendered. + + Args: + payload: The prompt payload to be analyzed. + context: contextual information about the hook call. + + Returns: + The result of the plugin's analysis, including whether the prompt can proceed. + """ + return PromptPrehookResult(continue_processing=True) + + async def prompt_post_fetch(self, payload: PromptPosthookPayload, context: PluginContext) -> PromptPosthookResult: + """Plugin hook run after a prompt is rendered. + + Args: + payload: The prompt payload to be analyzed. + context: Contextual information about the hook call. + + Returns: + The result of the plugin's analysis, including whether the prompt can proceed. + """ + return PromptPosthookResult(continue_processing=True) + + async def tool_pre_invoke(self, payload: ToolPreInvokePayload, context: PluginContext) -> ToolPreInvokeResult: + """Plugin hook run before a tool is invoked. + + Args: + payload: The tool payload to be analyzed. + context: Contextual information about the hook call. + + Returns: + The result of the plugin's analysis, including whether the tool can proceed. + """ + return ToolPreInvokeResult(continue_processing=True) + + async def tool_post_invoke(self, payload: ToolPostInvokePayload, context: PluginContext) -> ToolPostInvokeResult: + """Plugin hook run after a tool is invoked. + + Args: + payload: The tool result payload to be analyzed. + context: Contextual information about the hook call. + + Returns: + The result of the plugin's analysis, including whether the tool result should proceed. + """ + return ToolPostInvokeResult(continue_processing=True) diff --git a/cpex/templates/isolated/{{cookiecutter.plugin_slug}}/requirements.txt b/cpex/templates/isolated/{{cookiecutter.plugin_slug}}/requirements.txt new file mode 100644 index 00000000..d35182aa --- /dev/null +++ b/cpex/templates/isolated/{{cookiecutter.plugin_slug}}/requirements.txt @@ -0,0 +1 @@ +cpex>=0.1.0.dev10 \ No newline at end of file diff --git a/cpex/tools/cli.py b/cpex/tools/cli.py index 0d4645ec..071ac01b 100644 --- a/cpex/tools/cli.py +++ b/cpex/tools/cli.py @@ -151,7 +151,7 @@ def bootstrap( Args: destination: The directory in which to bootstrap the plugin project. template_url: The URL to the plugins cookiecutter template. - template_type: Plugin template type (native or external). + template_type: Plugin template type (native, external or isolated). vcs_ref: The version control system tag/branch/commit to use for the template. no_input: Use defaults without prompting. dry_run: Run but do not make any changes. diff --git a/pyproject.toml b/pyproject.toml index cac8005d..767877ee 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,6 +33,7 @@ dependencies = [ "pydantic-settings>=2.13.1", "pydantic>=2.12.5", "pyyaml>=6.0.3", + "packaging>=26.0" ] [project.scripts] diff --git a/tests/unit/cpex/fixtures/configs/isolated_plugin.yaml b/tests/unit/cpex/fixtures/configs/isolated_plugin.yaml new file mode 100644 index 00000000..a486ce72 --- /dev/null +++ b/tests/unit/cpex/fixtures/configs/isolated_plugin.yaml @@ -0,0 +1,34 @@ +# Plugin directories to scan +plugin_dirs: +- "tests/unit/cpex/fixtures/plugins/isolated" + +# Global plugin settings +plugin_settings: + parallel_execution_within_band: true + plugin_timeout: 30 + fail_on_plugin_error: false + enable_plugin_api: true + plugin_health_check_interval: 60 + + +plugins: + - name: "test_plugin" + kind: "isolated_venv" + description: "A framework testing filter plugin" + version: "0.1.0" + author: "habeck" + hooks: ["prompt_pre_fetch", "prompt_post_fetch", "tool_pre_invoke", "tool_post_invoke"] + tags: ["plugin"] + mode: "sequential" # enforce | permissive | disabled + priority: 150 + conditions: + # Apply to specific tools/servers + - server_ids: [] # Apply to all servers + tenant_ids: [] # Apply to all tenants + config: + # Plugin config dict passed to the plugin constructor + class_name: "test_plugin.plugin.TestPlugin" + requirements_file: "requirements.txt" + # essentially the plugin folder hosting the plugin + script_path: "tests/unit/cpex/fixtures/plugins/isolated" + diff --git a/tests/unit/cpex/fixtures/plugins/isolated/test_plugin/plugin.py b/tests/unit/cpex/fixtures/plugins/isolated/test_plugin/plugin.py new file mode 100644 index 00000000..c1f341cb --- /dev/null +++ b/tests/unit/cpex/fixtures/plugins/isolated/test_plugin/plugin.py @@ -0,0 +1,144 @@ +"""A filter plugin. + +Copyright 2025 +SPDX-License-Identifier: Apache-2.0 +Authors: habeck + +This module loads configurations for plugins. +""" + +import logging + +# First-Party +from cpex.framework import ( + Plugin, + PluginConfig, + PluginContext, + PromptPosthookPayload, + PromptPosthookResult, + PromptPrehookPayload, + PromptPrehookResult, + ToolPostInvokePayload, + ToolPostInvokeResult, + ToolPreInvokePayload, + ToolPreInvokeResult, +) +from cpex.framework.hooks.agents import ( + AgentPostInvokePayload, + AgentPostInvokeResult, + AgentPreInvokePayload, + AgentPreInvokeResult, +) +from cpex.framework.hooks.resources import ( + ResourcePostFetchPayload, + ResourcePostFetchResult, + ResourcePreFetchPayload, + ResourcePreFetchResult, +) + +logger = logging.getLogger(__name__) + + +class TestPlugin(Plugin): + """A filter plugin.""" + + def __init__(self, config: PluginConfig): + """Entry init block for plugin. + + Args: + logger: logger that the skill can make use of + config: the skill configuration + """ + super().__init__(config) + + async def prompt_pre_fetch(self, payload: PromptPrehookPayload, context: PluginContext) -> PromptPrehookResult: + """The plugin hook run before a prompt is retrieved and rendered. + + Args: + payload: The prompt payload to be analyzed. + context: contextual information about the hook call. + + Returns: + The result of the plugin's analysis, including whether the prompt can proceed. + """ + logger.info("TestPlugin: prompt_pre_fetch") + return PromptPrehookResult(continue_processing=True) + + async def prompt_post_fetch(self, payload: PromptPosthookPayload, context: PluginContext) -> PromptPosthookResult: + """Plugin hook run after a prompt is rendered. + + Args: + payload: The prompt payload to be analyzed. + context: Contextual information about the hook call. + + Returns: + The result of the plugin's analysis, including whether the prompt can proceed. + """ + logger.info("TestPlugin: prompt_post_fetch") + return PromptPosthookResult(continue_processing=True) + + async def tool_pre_invoke(self, payload: ToolPreInvokePayload, context: PluginContext) -> ToolPreInvokeResult: + """Plugin hook run before a tool is invoked. + + Args: + payload: The tool payload to be analyzed. + context: Contextual information about the hook call. + + Returns: + The result of the plugin's analysis, including whether the tool can proceed. + """ + logger.info("TestPlugin: tool_pre_invoke") + return ToolPreInvokeResult(continue_processing=True) + + async def tool_post_invoke(self, payload: ToolPostInvokePayload, context: PluginContext) -> ToolPostInvokeResult: + """Plugin hook run after a tool is invoked. + + Args: + payload: The tool result payload to be analyzed. + context: Contextual information about the hook call. + + Returns: + The result of the plugin's analysis, including whether the tool result should proceed. + """ + logger.info("TestPlugin: tool_post_invoke") + return ToolPostInvokeResult(continue_processing=True) + + async def resource_pre_fetch( + self, payload: ResourcePreFetchPayload, context: PluginContext + ) -> ResourcePreFetchResult: + """Plugin hook run before a resource is fetched. + Args: + payload: The resource payload to be analyzed. + context: Contextual information about the hook call. + """ + logger.info("TestPlugin: resource_pre_fetch") + return ResourcePreFetchResult(continue_processing=True) + + async def resource_post_fetch( + self, payload: ResourcePostFetchPayload, context: PluginContext + ) -> ResourcePostFetchResult: + """Plugin hook run after a resource is fetched. + Args: + payload: The resource payload to be analyzed. + context: Contextual information about the hook call. + """ + logger.info("TestPlugin: resource_post_fetch") + return ResourcePostFetchResult(continue_processing=True) + + async def agent_pre_invoke(self, payload: AgentPreInvokePayload, context: PluginContext) -> AgentPreInvokeResult: + """Plugin hook run before an agent is invoked. + Args: + payload: The agent payload to be analyzed. + context: Contextual information about the hook call. + """ + logger.info("TestPlugin: agent_pre_invoke") + return AgentPreInvokeResult(continue_processing=True) + + async def agent_post_invoke(self, payload: AgentPostInvokePayload, context: PluginContext) -> AgentPostInvokeResult: + """Plugin hook run after an agent is invoked. + Args: + payload: The agent payload to be analyzed. + context: Contextual information about the hook call. + """ + logger.info("TestPlugin: agent_post_invoke") + return AgentPostInvokeResult(continue_processing=True) diff --git a/tests/unit/cpex/fixtures/plugins/isolated/test_plugin/requirements.txt b/tests/unit/cpex/fixtures/plugins/isolated/test_plugin/requirements.txt new file mode 100644 index 00000000..e83eec53 --- /dev/null +++ b/tests/unit/cpex/fixtures/plugins/isolated/test_plugin/requirements.txt @@ -0,0 +1 @@ +cpex>=0.1.0.dev4 \ No newline at end of file diff --git a/tests/unit/cpex/framework/isolated/README.md b/tests/unit/cpex/framework/isolated/README.md new file mode 100644 index 00000000..162e5489 --- /dev/null +++ b/tests/unit/cpex/framework/isolated/README.md @@ -0,0 +1,199 @@ +# Isolated Plugin Framework Tests + +This directory contains comprehensive unit and integration tests for the isolated plugin framework, which enables running plugins in separate Python virtual environments. + +## Overview + +The isolated plugin framework consists of three main components: + +1. **VenvProcessCommunicator** (`venv_comm.py`) - Handles communication with child processes in different virtual environments +2. **IsolatedVenvPlugin** (`client.py`) - Plugin client that manages venv-isolated plugins +3. **Worker** (`worker.py`) - Worker process that runs inside the venv and executes plugin hooks + +## Test Files + +### `test_venv_comm.py` +Tests for the `VenvProcessCommunicator` class that handles inter-process communication. + +**Coverage:** +- Virtual environment path validation (Unix/Windows) +- Python executable detection +- Requirements installation (success/failure cases) +- Task sending and response handling +- Error handling (timeouts, invalid JSON, process failures) +- Complex data serialization +- Working directory maintenance + +**Key Test Cases:** +- `test_init_valid_venv` - Validates proper initialization with valid venv +- `test_send_task_success` - Tests successful task execution +- `test_send_task_timeout` - Tests timeout handling +- `test_install_requirements_success` - Tests pip installation + +### `test_client.py` +Tests for the `IsolatedVenvPlugin` class that serves as the plugin client. + +**Coverage:** +- Plugin initialization and configuration +- Virtual environment creation +- Hook invocation for all hook types (tool_pre_invoke, tool_post_invoke, prompt_pre_fetch, prompt_post_fetch) +- Payload and context serialization +- Error handling (PluginError, generic exceptions) +- Policy violation handling +- Safe config generation + +**Key Test Cases:** +- `test_invoke_hook_tool_pre_invoke_success` - Tests tool pre-invoke hook +- `test_invoke_hook_with_violation` - Tests policy violation handling +- `test_invoke_hook_plugin_error` - Tests PluginError propagation +- `test_invoke_hook_serialization` - Tests proper data serialization + +### `test_worker.py` +Tests for the worker process functions that execute inside the venv. + +**Coverage:** +- Environment information retrieval +- Plugin configuration loading +- Task processing (info, load_and_run_hook) +- Plugin loading and instantiation +- Hook execution +- Error handling (import errors, missing configs) +- Multiple hook type support +- sys.path modification + +**Key Test Cases:** +- `test_get_environment_info` - Tests environment info collection +- `test_process_task_load_and_run_hook_success` - Tests successful hook execution +- `test_process_task_with_different_hook_types` - Tests all hook types +- `test_process_task_import_error` - Tests import error handling + +### `test_integration.py` +Integration tests that verify the entire isolated plugin system working together. + +**Coverage:** +- Full plugin lifecycle (initialization → hook invocation → cleanup) +- PluginManager integration with isolated plugins +- Context propagation through the isolation boundary +- Multiple hook type execution +- Policy violation handling end-to-end +- Error handling across process boundaries + +**Key Test Cases:** +- `test_isolated_plugin_full_lifecycle` - Tests complete plugin lifecycle +- `test_isolated_plugin_context_propagation` - Tests context serialization +- `test_isolated_plugin_with_multiple_hooks` - Tests multiple hook types +- `test_isolated_plugin_violation_handling` - Tests violation propagation + +### `conftest.py` +Pytest fixtures shared across all isolated plugin tests. + +**Fixtures:** +- `mock_venv_structure` - Creates mock venv directory structure +- `sample_plugin_config` - Provides sample plugin configuration +- `sample_global_context` - Creates test GlobalContext +- `sample_plugin_context` - Creates test PluginContext +- `mock_communicator` - Provides mock VenvProcessCommunicator +- `sample_requirements_file` - Creates test requirements.txt + +## Running the Tests + +### Run all isolated plugin tests: +```bash +pytest tests/unit/cpex/framework/isolated/ +``` + +### Run specific test file: +```bash +pytest tests/unit/cpex/framework/isolated/test_venv_comm.py +``` + +### Run with coverage: +```bash +pytest tests/unit/cpex/framework/isolated/ --cov=cpex.framework.isolated --cov-report=html +``` + +### Run specific test: +```bash +pytest tests/unit/cpex/framework/isolated/test_client.py::TestIsolatedVenvPlugin::test_invoke_hook_tool_pre_invoke_success +``` + +## Test Architecture + +### Mocking Strategy +The tests use extensive mocking to avoid: +- Creating actual virtual environments (slow and resource-intensive) +- Installing real packages via pip +- Spawning actual subprocesses +- File system operations where possible + +### Fixtures +Common test fixtures are defined in `conftest.py` to promote code reuse and consistency across tests. + +### Test Organization +Tests are organized by component: +- **Unit tests** - Test individual functions and methods in isolation +- **Integration tests** - Test components working together + +## Coverage Goals + +The test suite aims for: +- **Line coverage**: >90% +- **Branch coverage**: >85% +- **Function coverage**: 100% + +## Key Testing Patterns + +### 1. Async Testing +```python +@pytest.mark.asyncio +async def test_async_function(): + result = await some_async_function() + assert result is not None +``` + +### 2. Mock Subprocess Communication +```python +@patch("subprocess.Popen") +def test_send_task(mock_popen): + mock_process = MagicMock() + mock_process.communicate.return_value = ('{"status": "ok"}', "") + mock_popen.return_value = mock_process + # Test code here +``` + +### 3. Context Propagation Testing +```python +def test_context_propagation(): + # Create context with specific data + context = PluginContext(global_context=GlobalContext(...)) + # Invoke hook + result = await plugin.invoke_hook(hook_type, payload, context) + # Verify context was properly serialized and sent +``` + +## Common Issues and Solutions + +### Issue: Tests fail with "Python executable not found" +**Solution**: Ensure mock_venv_structure fixture is being used, which creates the proper directory structure. + +### Issue: Async tests hang +**Solution**: Ensure all async functions are properly awaited and use `@pytest.mark.asyncio` decorator. + +### Issue: Import errors in tests +**Solution**: Check that all required dependencies are installed in the test environment. + +## Contributing + +When adding new tests: +1. Follow the existing naming conventions (`test__`) +2. Add docstrings explaining what the test validates +3. Use fixtures from `conftest.py` where applicable +4. Mock external dependencies (filesystem, network, subprocesses) +5. Test both success and failure paths +6. Update this README if adding new test files + +## Related Documentation + +- [Isolated Plugin Design](../../../../cpex/framework/isolated/design.md) +- [Plugin Framework Documentation](../../../../cpex/framework/README.md) +- [Main Test Suite](../../../README.md) \ No newline at end of file diff --git a/tests/unit/cpex/framework/isolated/__init__.py b/tests/unit/cpex/framework/isolated/__init__.py new file mode 100644 index 00000000..f34f5b12 --- /dev/null +++ b/tests/unit/cpex/framework/isolated/__init__.py @@ -0,0 +1,10 @@ +# -*- coding: utf-8 -*- +"""Location: ./tests/unit/cpex/framework/isolated/__init__.py +Copyright 2025 +SPDX-License-Identifier: Apache-2.0 +Authors: Ted Habeck + +Unit tests for isolated plugin framework. +""" + +# Made with Bob diff --git a/tests/unit/cpex/framework/isolated/conftest.py b/tests/unit/cpex/framework/isolated/conftest.py new file mode 100644 index 00000000..9b79a787 --- /dev/null +++ b/tests/unit/cpex/framework/isolated/conftest.py @@ -0,0 +1,145 @@ +# -*- coding: utf-8 -*- +"""Location: ./tests/unit/cpex/framework/isolated/conftest.py +Copyright 2025 +SPDX-License-Identifier: Apache-2.0 +Authors: Ted Habeck + +Pytest fixtures for isolated plugin tests. +""" + +import sys +from pathlib import Path +from unittest.mock import MagicMock + +import pytest + +from cpex.framework import GlobalContext +from cpex.framework.models import PluginConfig, PluginContext + + +@pytest.fixture +def mock_venv_structure(tmp_path): + """Create a mock virtual environment directory structure. + + Args: + tmp_path: pytest tmp_path fixture + + Returns: + Path to the mock venv directory + """ + venv_path = tmp_path / ".venv" + venv_path.mkdir() + + # Create appropriate bin/Scripts directory based on platform + if sys.platform == "win32": + scripts_dir = venv_path / "Scripts" + scripts_dir.mkdir() + python_exe = scripts_dir / "python.exe" + else: + bin_dir = venv_path / "bin" + bin_dir.mkdir() + python_exe = bin_dir / "python" + + # Create a dummy python executable + python_exe.touch() + python_exe.chmod(0o755) + + return venv_path + + +@pytest.fixture +def sample_plugin_config(tmp_path): + """Create a sample plugin configuration for testing. + + Args: + tmp_path: pytest tmp_path fixture + + Returns: + PluginConfig instance + """ + venv_path = tmp_path / ".venv" + script_path = tmp_path / "plugin" + requirements_file = tmp_path / "requirements.txt" + + config_dict = { + "name": "test_isolated_plugin", + "kind": "isolated_venv", + "description": "Test isolated plugin", + "version": "1.0.0", + "author": "Test Author", + "hooks": ["tool_pre_invoke", "tool_post_invoke"], + "config": { + "venv_path": str(venv_path), + "script_path": str(script_path), + "requirements_file": str(requirements_file), + "class_name": "test_plugin.TestPlugin" + } + } + return PluginConfig(**config_dict) + + +@pytest.fixture +def sample_global_context(): + """Create a sample GlobalContext for testing. + + Returns: + GlobalContext instance + """ + return GlobalContext( + request_id="test-req-123", + user="test_user", + tenant_id="test-tenant", + server_id="test-server" + ) + + +@pytest.fixture +def sample_plugin_context(sample_global_context): + """Create a sample PluginContext for testing. + + Args: + sample_global_context: GlobalContext fixture + + Returns: + PluginContext instance + """ + return PluginContext( + global_context=sample_global_context, + state={"test_key": "test_value"}, + metadata={"test_meta": "test_data"} + ) + + +@pytest.fixture +def mock_communicator(): + """Create a mock VenvProcessCommunicator. + + Returns: + MagicMock instance configured as a communicator + """ + mock_comm = MagicMock() + mock_comm.install_requirements = MagicMock() + mock_comm.send_task = MagicMock(return_value={ + "continue_processing": True, + "modified_payload": None, + "violation": None, + "metadata": {} + }) + return mock_comm + + +@pytest.fixture +def sample_requirements_file(tmp_path): + """Create a sample requirements.txt file. + + Args: + tmp_path: pytest tmp_path fixture + + Returns: + Path to the requirements file + """ + requirements_file = tmp_path / "requirements.txt" + requirements_file.write_text("pytest>=7.0.0\nrequests>=2.28.0\n") + return requirements_file + +# Made with Bob diff --git a/tests/unit/cpex/framework/isolated/test_client.py b/tests/unit/cpex/framework/isolated/test_client.py new file mode 100644 index 00000000..4ed49344 --- /dev/null +++ b/tests/unit/cpex/framework/isolated/test_client.py @@ -0,0 +1,681 @@ +# -*- coding: utf-8 -*- +"""Location: ./tests/unit/cpex/framework/isolated/test_client.py +Copyright 2025 +SPDX-License-Identifier: Apache-2.0 +Authors: Ted Habeck + +Unit tests for IsolatedVenvPlugin. +""" + +import asyncio +import json +import sys +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock, Mock, patch, mock_open + +import pytest + +from cpex.framework.errors import PluginError +from cpex.framework.hooks.prompts import PromptPosthookResult, PromptPrehookResult +from cpex.framework.hooks.tools import ToolPostInvokeResult, ToolPreInvokeResult +from cpex.framework.isolated.client import IsolatedVenvPlugin +from cpex.framework.models import PluginConfig, PluginContext, PluginErrorModel + + +class TestIsolatedVenvPlugin: + """Test suite for IsolatedVenvPlugin class.""" + + @pytest.fixture + def mock_config(self, tmp_path): + """Create a mock plugin configuration.""" + # Create the test_plugin directory structure + plugin_dir = tmp_path / "test_plugin" + plugin_dir.mkdir(parents=True, exist_ok=True) + + # Create requirements.txt file + requirements_file = plugin_dir / "requirements.txt" + requirements_file.write_text("pytest>=7.0.0\n") + + venv_path = tmp_path / ".venv" + + config_dict = { + "name": "test_plugin", + "kind": "isolated_venv", + "description": "Test plugin", + "version": "1.0.0", + "author": "Test", + "hooks": ["tool_pre_invoke"], + "config": { + "class_name": "test_plugin.TestPlugin", + "venv_path": venv_path, + "requirements_file": "requirements.txt", # Use relative path + } + } + + return PluginConfig(**config_dict) + + @pytest.fixture + def plugin(self, mock_config, tmp_path): + """Create an IsolatedVenvPlugin instance.""" + plugin_instance = IsolatedVenvPlugin(mock_config, plugin_dirs=[tmp_path]) + # Override plugin_path to use tmp_path for testing + plugin_instance.plugin_path = tmp_path / "test_plugin" + return plugin_instance + + @pytest.fixture + def plugin_context(self): + """Create a PluginContext instance""" + context = {"state": {}, "global_context": {"request_id": "req-123"}, "metadata": {}} + plugin_context = PluginContext( + state=context.get("state"), global_context=context.get("global_context"), metadata=context.get("metadata") + ) + return plugin_context + + def test_init(self, plugin): + """Test plugin initialization.""" + assert plugin.name == "test_plugin" + assert plugin.implementation == "Python" + assert plugin.comm is None + + @pytest.mark.asyncio + @patch("cpex.framework.isolated.client.venv.EnvBuilder") + async def test_create_venv_success(self, mock_builder_class, plugin, tmp_path): + """Test successful venv creation.""" + venv_path = tmp_path / ".venv" + mock_builder = MagicMock() + mock_builder_class.return_value = mock_builder + + await plugin.create_venv(str(venv_path)) + + mock_builder_class.assert_called_once() + mock_builder.create.assert_called_once_with(str(venv_path)) + + @pytest.mark.asyncio + @patch("cpex.framework.isolated.client.venv.EnvBuilder") + async def test_create_venv_failure(self, mock_builder_class, plugin, tmp_path): + """Test venv creation failure.""" + venv_path = tmp_path / ".venv" + mock_builder = MagicMock() + mock_builder.create.side_effect = Exception("Creation failed") + mock_builder_class.return_value = mock_builder + + with pytest.raises(Exception, match="Creation failed"): + await plugin.create_venv(str(venv_path)) + + @pytest.mark.asyncio + @patch("cpex.framework.isolated.client.VenvProcessCommunicator") + @patch.object(IsolatedVenvPlugin, "create_venv") + async def test_initialize_success(self, mock_create_venv, mock_comm_class, plugin): + """Test successful plugin initialization.""" + mock_create_venv.return_value = True + mock_comm = MagicMock() + mock_comm_class.return_value = mock_comm + + await plugin.initialize() + + mock_create_venv.assert_called_once() + mock_comm_class.assert_called_once() + mock_comm.install_requirements.assert_called_once() + assert plugin.comm is not None + + @pytest.mark.asyncio + @patch("cpex.framework.isolated.client.get_hook_registry") + async def test_invoke_hook_unregistered_hook_type(self, mock_get_registry, plugin, plugin_context): + """Test invoking an unregistered hook type.""" + mock_registry = MagicMock() + mock_registry.get_result_type.return_value = None + mock_get_registry.return_value = mock_registry + + plugin.comm = MagicMock() + + with pytest.raises(PluginError, match="Hook type .* not registered"): + await plugin.invoke_hook("invalid_hook", None, plugin_context) + + @pytest.mark.asyncio + async def test_invoke_hook_no_comm(self, plugin, plugin_context): + """Test invoking hook without initialized communicator.""" + plugin.comm = None + with pytest.raises(PluginError, match="Plugin comm not initialized"): + await plugin.invoke_hook("tool_pre_invoke", None, plugin_context) + + @pytest.mark.asyncio + @patch("cpex.framework.isolated.client.get_hook_registry") + async def test_invoke_hook_tool_pre_invoke_success(self, mock_get_registry, plugin, plugin_context): + """Test successful tool_pre_invoke hook invocation.""" + # Setup registry + mock_registry = MagicMock() + mock_registry.get_result_type.return_value = ToolPreInvokeResult + mock_get_registry.return_value = mock_registry + response_data = { + "continue_processing": True, + "modified_payload": {"name": "test_tool", "args": {}}, + "violation": None, + "metadata": {}, + } + + mock_registry.json_to_result = MagicMock() + mock_registry.json_to_result.return_value = ToolPreInvokeResult( + continue_processing=response_data.get("continue_processing"), + modified_payload=response_data.get("modified_payload"), + violation=response_data.get("violation"), + metadata=response_data.get("metadata"), + ) + # Setup communicator + mock_comm = MagicMock() + mock_comm.send_task.return_value = response_data + plugin.comm = mock_comm + + # Create payload and context + from cpex.framework.hooks.tools import ToolPreInvokePayload + + payload = ToolPreInvokePayload(name="test_tool", args={}) + result = await plugin.invoke_hook("tool_pre_invoke", payload, plugin_context) + + assert isinstance(result, ToolPreInvokeResult) + assert result.continue_processing is True + mock_comm.send_task.assert_called_once() + + @pytest.mark.asyncio + @patch("cpex.framework.isolated.client.get_hook_registry") + async def test_invoke_hook_tool_post_invoke_success(self, mock_get_registry, plugin, plugin_context): + """Test successful tool_post_invoke hook invocation.""" + mock_registry = MagicMock() + mock_registry.get_result_type.return_value = ToolPostInvokeResult + mock_get_registry.return_value = mock_registry + + mock_comm = MagicMock() + response_data = { + "continue_processing": True, + "modified_payload": {"name": "test_tool", "result": "success"}, + "violation": None, + "metadata": {}, + } + mock_comm.send_task.return_value = response_data + mock_registry.json_to_result = MagicMock() + mock_registry.json_to_result.return_value = ToolPostInvokeResult( + continue_processing=response_data.get("continue_processing"), + modified_payload=response_data.get("modified_payload"), + violation=response_data.get("violation"), + metadata=response_data.get("metadata"), + ) + plugin.comm = mock_comm + + from cpex.framework.hooks.tools import ToolPostInvokePayload + + payload = ToolPostInvokePayload(name="test_tool", result="success") + + result = await plugin.invoke_hook("tool_post_invoke", payload, plugin_context) + + assert isinstance(result, ToolPostInvokeResult) + assert result.continue_processing is True + + @pytest.mark.asyncio + @patch("cpex.framework.isolated.client.get_hook_registry") + async def test_invoke_hook_prompt_pre_fetch_success(self, mock_get_registry, plugin, plugin_context): + """Test successful prompt_pre_fetch hook invocation.""" + mock_registry = MagicMock() + mock_registry.get_result_type.return_value = PromptPrehookResult + mock_registry.json_to_result = MagicMock() + mock_get_registry.return_value = mock_registry + + mock_comm = MagicMock() + response_data = { + "continue_processing": True, + "modified_payload": {"prompt_id": "test", "args": {}}, + "violation": None, + "metadata": {}, + } + mock_comm.send_task.return_value = response_data + mock_registry.json_to_result.return_value = PromptPrehookResult( + continue_processing=response_data.get("continue_processing"), + modified_payload=response_data.get("modified_payload"), + violation=response_data.get("violation"), + metadata=response_data.get("metadata"), + ) + plugin.comm = mock_comm + + from cpex.framework.hooks.prompts import PromptPrehookPayload + + payload = PromptPrehookPayload(prompt_id="test", args={}) + + result = await plugin.invoke_hook("prompt_pre_fetch", payload, plugin_context) + + assert isinstance(result, PromptPrehookResult) + assert result.continue_processing is True + + @pytest.mark.asyncio + @patch("cpex.framework.isolated.client.get_hook_registry") + async def test_invoke_hook_prompt_post_fetch_success(self, mock_get_registry, plugin, plugin_context): + """Test successful prompt_post_fetch hook invocation.""" + mock_registry = MagicMock() + mock_registry.get_result_type.return_value = PromptPosthookResult + mock_get_registry.return_value = mock_registry + + mock_comm = MagicMock() + response_data = { + "continue_processing": True, + "modified_payload": {"prompt_id": "test", "result": {}}, + "violation": None, + "metadata": {}, + } + mock_registry.json_to_result = MagicMock() + mock_registry.json_to_result.return_value = PromptPosthookResult( + continue_processing=response_data.get("continue_processing"), + modified_payload=response_data.get("modified_payload"), + violation=response_data.get("violation"), + metadata=response_data.get("metadata"), + ) + mock_comm.send_task.return_value = response_data + plugin.comm = mock_comm + + from cpex.framework.hooks.prompts import PromptPosthookPayload + + payload = PromptPosthookPayload(prompt_id="test", result={}) + result = await plugin.invoke_hook("prompt_post_fetch", payload, plugin_context) + + assert isinstance(result, PromptPosthookResult) + assert result.continue_processing is True + + @pytest.mark.asyncio + @patch("cpex.framework.isolated.client.get_hook_registry") + async def test_invoke_hook_with_violation(self, mock_get_registry, plugin, plugin_context): + """Test hook invocation that returns a violation.""" + mock_registry = MagicMock() + mock_registry.get_result_type.return_value = ToolPreInvokeResult + mock_get_registry.return_value = mock_registry + mock_registry.json_to_result = MagicMock() + + mock_comm = MagicMock() + response_data = { + "continue_processing": False, + "modified_payload": None, + "violation": {"reason": "Policy violation", "description":"severity high", "code": "PROHIBITED_CONTENT"}, + "metadata": {}, + } + mock_comm.send_task.return_value = response_data + plugin.comm = mock_comm + mock_registry.json_to_result.return_value = ToolPreInvokeResult( + continue_processing=response_data.get("continue_processing"), + modified_payload=response_data.get("modified_payload"), + violation=response_data.get("violation"), + metadata=response_data.get("metadata"), + ) + + + from cpex.framework.hooks.tools import ToolPreInvokePayload + + payload = ToolPreInvokePayload(name="test_tool", args={}) + + result = await plugin.invoke_hook("tool_pre_invoke", payload, plugin_context) + + assert isinstance(result, ToolPreInvokeResult) + assert result.continue_processing is False + assert result.violation is not None + + @pytest.mark.asyncio + @patch("cpex.framework.isolated.client.get_hook_registry") + async def test_invoke_hook_plugin_error(self, mock_get_registry, plugin, plugin_context): + """Test hook invocation that raises PluginError.""" + mock_registry = MagicMock() + mock_registry.get_result_type.return_value = ToolPreInvokeResult + mock_get_registry.return_value = mock_registry + + mock_comm = MagicMock() + mock_comm.send_task.side_effect = PluginError( + error=PluginErrorModel(message="Test error", plugin_name="test_plugin") + ) + plugin.comm = mock_comm + + from cpex.framework.hooks.tools import ToolPreInvokePayload + + payload = ToolPreInvokePayload(name="test_tool", args={}) + with pytest.raises(PluginError): + await plugin.invoke_hook("tool_pre_invoke", payload, plugin_context) + + @pytest.mark.asyncio + @patch("cpex.framework.isolated.client.get_hook_registry") + @patch("cpex.framework.isolated.client.convert_exception_to_error") + async def test_invoke_hook_generic_exception(self, mock_convert, mock_get_registry, plugin, plugin_context): + """Test hook invocation that raises generic exception.""" + mock_registry = MagicMock() + mock_registry.get_result_type.return_value = ToolPreInvokeResult + mock_get_registry.return_value = mock_registry + + mock_comm = MagicMock() + mock_comm.send_task.side_effect = ValueError("Test error") + plugin.comm = mock_comm + + mock_convert.return_value = PluginErrorModel(message="Converted error", plugin_name="test_plugin") + + from cpex.framework.hooks.tools import ToolPreInvokePayload + + payload = ToolPreInvokePayload(name="test_tool", args={}) + + with pytest.raises(PluginError): + await plugin.invoke_hook("tool_pre_invoke", payload, plugin_context) + + mock_convert.assert_called_once() + + @pytest.mark.asyncio + @patch("cpex.framework.isolated.client.get_hook_registry") + async def test_invoke_hook_serialization(self, mock_get_registry, plugin): + """Test that payload and context are properly serialized.""" + mock_registry = MagicMock() + mock_registry.get_result_type.return_value = ToolPreInvokeResult + mock_get_registry.return_value = mock_registry + + mock_comm = MagicMock() + response_data = {"continue_processing": True, "modified_payload": None, "violation": None, "metadata": {}} + mock_comm.send_task.return_value = response_data + plugin.comm = mock_comm + + from cpex.framework.hooks.tools import ToolPreInvokePayload + from cpex.framework import GlobalContext + + payload = ToolPreInvokePayload(name="test_tool", args={"key": "value"}) + global_ctx = GlobalContext(request_id="req-123", user="alice") + context = PluginContext(global_context=global_ctx) + + await plugin.invoke_hook("tool_pre_invoke", payload, context) + + # Verify send_task was called with serialized data + call_args = mock_comm.send_task.call_args + task_data = call_args[1]["task_data"] + + assert "payload" in task_data + assert "context" in task_data + assert task_data["hook_type"] == "tool_pre_invoke" + assert task_data["plugin_name"] == plugin.name + + def test_get_safe_config(self, plugin): + """Test that get_safe_config returns sanitized config.""" + safe_config = plugin.config.get_safe_config() + assert isinstance(safe_config, str) + # Should be valid JSON + import json + + config_dict = json.loads(safe_config) + assert "name" in config_dict + + def test_cache_dir_creation(self, plugin): + """Test that cache directory is created on plugin initialization.""" + assert plugin.cache_dir.exists() + assert plugin.cache_dir.is_dir() + assert plugin.cache_dir.name == "venv_cache" + + def test_compute_requirements_hash_with_file(self, plugin, tmp_path): + """Test computing hash of existing requirements file.""" + req_file = tmp_path / "requirements.txt" + req_file.write_text("pytest==7.0.0\nrequests==2.28.0\n") + + hash1 = plugin._compute_requirements_hash(str(req_file)) + assert isinstance(hash1, str) + assert len(hash1) == 64 # SHA256 produces 64 hex characters + + # Same content should produce same hash + hash2 = plugin._compute_requirements_hash(str(req_file)) + assert hash1 == hash2 + + def test_compute_requirements_hash_different_content(self, plugin, tmp_path): + """Test that different content produces different hashes.""" + req_file1 = tmp_path / "requirements1.txt" + req_file1.write_text("pytest==7.0.0\n") + + req_file2 = tmp_path / "requirements2.txt" + req_file2.write_text("pytest==8.0.0\n") + + hash1 = plugin._compute_requirements_hash(str(req_file1)) + hash2 = plugin._compute_requirements_hash(str(req_file2)) + + assert hash1 != hash2 + + def test_compute_requirements_hash_nonexistent_file(self, plugin, tmp_path): + """Test computing hash of non-existent file.""" + nonexistent = tmp_path / "nonexistent.txt" + hash_result = plugin._compute_requirements_hash(str(nonexistent)) + + # Should return hash of empty content + assert isinstance(hash_result, str) + assert len(hash_result) == 64 + + def test_get_cache_metadata_path(self, plugin, tmp_path): + """Test getting cache metadata path.""" + venv_path = tmp_path / ".venv" + metadata_path = plugin._get_cache_metadata_path(str(venv_path)) + + assert metadata_path.parent == plugin.cache_dir + assert metadata_path.name == ".venv_metadata.json" + assert isinstance(metadata_path, Path) + + def test_is_venv_cache_valid_no_venv(self, plugin, tmp_path): + """Test cache validation when venv doesn't exist.""" + venv_path = tmp_path / ".venv" + req_file = tmp_path / "requirements.txt" + req_file.write_text("pytest==7.0.0\n") + + result = plugin._is_venv_cache_valid(str(venv_path), str(req_file)) + assert result is False + + def test_is_venv_cache_valid_no_metadata(self, plugin, tmp_path): + """Test cache validation when metadata file doesn't exist.""" + venv_path = tmp_path / ".venv" + venv_path.mkdir() + req_file = tmp_path / "requirements.txt" + req_file.write_text("pytest==7.0.0\n") + + result = plugin._is_venv_cache_valid(str(venv_path), str(req_file)) + assert result is False + + def test_is_venv_cache_valid_hash_mismatch(self, plugin, tmp_path): + """Test cache validation when requirements hash doesn't match.""" + venv_path = tmp_path / ".venv" + venv_path.mkdir() + req_file = tmp_path / "requirements.txt" + req_file.write_text("pytest==7.0.0\n") + + # Create metadata with different hash + metadata_path = plugin._get_cache_metadata_path(str(venv_path)) + metadata = { + "venv_path": str(venv_path), + "requirements_file": str(req_file), + "requirements_hash": "different_hash", + "python_version": f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}" + } + metadata_path.write_text(json.dumps(metadata)) + + result = plugin._is_venv_cache_valid(str(venv_path), str(req_file)) + assert result is False + + def test_is_venv_cache_valid_success(self, plugin, tmp_path): + """Test successful cache validation.""" + venv_path = tmp_path / ".venv" + venv_path.mkdir() + req_file = tmp_path / "requirements.txt" + req_file.write_text("pytest==7.0.0\n") + + # Create metadata with correct hash + req_hash = plugin._compute_requirements_hash(str(req_file)) + metadata_path = plugin._get_cache_metadata_path(str(venv_path)) + metadata = { + "venv_path": str(venv_path), + "requirements_file": str(req_file), + "requirements_hash": req_hash, + "python_version": f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}" + } + metadata_path.write_text(json.dumps(metadata)) + + result = plugin._is_venv_cache_valid(str(venv_path), str(req_file)) + assert result is True + + def test_is_venv_cache_valid_invalid_json(self, plugin, tmp_path): + """Test cache validation with invalid JSON metadata.""" + venv_path = tmp_path / ".venv" + venv_path.mkdir() + req_file = tmp_path / "requirements.txt" + req_file.write_text("pytest==7.0.0\n") + + # Create invalid JSON metadata + metadata_path = plugin._get_cache_metadata_path(str(venv_path)) + metadata_path.write_text("invalid json {") + + result = plugin._is_venv_cache_valid(str(venv_path), str(req_file)) + assert result is False + + def test_save_cache_metadata(self, plugin, tmp_path): + """Test saving cache metadata.""" + venv_path = tmp_path / ".venv" + venv_path.mkdir() + req_file = tmp_path / "requirements.txt" + req_file.write_text("pytest==7.0.0\n") + + plugin._save_cache_metadata(str(venv_path), str(req_file)) + + metadata_path = plugin._get_cache_metadata_path(str(venv_path)) + assert metadata_path.exists() + + with open(metadata_path) as f: + metadata = json.load(f) + + assert "venv_path" in metadata + assert "requirements_file" in metadata + assert "requirements_hash" in metadata + assert "python_version" in metadata + assert metadata["requirements_hash"] == plugin._compute_requirements_hash(str(req_file)) + + def test_save_cache_metadata_nonexistent_requirements(self, plugin, tmp_path): + """Test saving cache metadata with non-existent requirements file.""" + venv_path = tmp_path / ".venv" + venv_path.mkdir() + req_file = tmp_path / "nonexistent.txt" + + plugin._save_cache_metadata(str(venv_path), str(req_file)) + + metadata_path = plugin._get_cache_metadata_path(str(venv_path)) + assert metadata_path.exists() + + with open(metadata_path) as f: + metadata = json.load(f) + + assert metadata["requirements_file"] is None + + @pytest.mark.asyncio + @patch("cpex.framework.isolated.client.venv.EnvBuilder") + @patch("cpex.framework.isolated.client.shutil.rmtree") + async def test_create_venv_with_cache_valid(self, mock_rmtree, mock_builder_class, plugin, tmp_path): + """Test create_venv uses cache when valid.""" + venv_path = tmp_path / ".venv" + venv_path.mkdir() + req_file = tmp_path / "requirements.txt" + req_file.write_text("pytest==7.0.0\n") + + # Setup valid cache + plugin._save_cache_metadata(str(venv_path), str(req_file)) + + await plugin.create_venv(str(venv_path), str(req_file), use_cache=True) + + # Should not create new venv or remove existing + mock_builder_class.assert_not_called() + mock_rmtree.assert_not_called() + + @pytest.mark.asyncio + @patch("cpex.framework.isolated.client.venv.EnvBuilder") + @patch("cpex.framework.isolated.client.shutil.rmtree") + async def test_create_venv_with_cache_invalid(self, mock_rmtree, mock_builder_class, plugin, tmp_path): + """Test create_venv recreates when cache invalid.""" + venv_path = tmp_path / ".venv" + venv_path.mkdir() + req_file = tmp_path / "requirements.txt" + req_file.write_text("pytest==7.0.0\n") + + # Setup invalid cache (wrong hash) + metadata_path = plugin._get_cache_metadata_path(str(venv_path)) + metadata = { + "venv_path": str(venv_path), + "requirements_file": str(req_file), + "requirements_hash": "wrong_hash", + "python_version": f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}" + } + metadata_path.write_text(json.dumps(metadata)) + + mock_builder = MagicMock() + mock_builder_class.return_value = mock_builder + + await plugin.create_venv(str(venv_path), str(req_file), use_cache=True) + + # Should remove old venv and create new one + mock_rmtree.assert_called_once_with(venv_path) + mock_builder_class.assert_called_once() + mock_builder.create.assert_called_once() + + @pytest.mark.asyncio + @patch("cpex.framework.isolated.client.venv.EnvBuilder") + async def test_create_venv_without_cache(self, mock_builder_class, plugin, tmp_path): + """Test create_venv without using cache.""" + venv_path = tmp_path / ".venv" + req_file = tmp_path / "requirements.txt" + req_file.write_text("pytest==7.0.0\n") + + mock_builder = MagicMock() + mock_builder_class.return_value = mock_builder + + await plugin.create_venv(str(venv_path), str(req_file), use_cache=False) + + # Should create new venv + mock_builder_class.assert_called_once() + mock_builder.create.assert_called_once() + + @pytest.mark.asyncio + @patch("cpex.framework.isolated.client.VenvProcessCommunicator") + @patch.object(IsolatedVenvPlugin, "create_venv") + @patch.object(IsolatedVenvPlugin, "_is_venv_cache_valid") + async def test_initialize_with_valid_cache(self, mock_cache_valid, mock_create_venv, mock_comm_class, plugin): + """Test initialize with valid cache skips requirements installation.""" + mock_cache_valid.return_value = True + mock_create_venv.return_value = None + mock_comm = MagicMock() + mock_comm_class.return_value = mock_comm + + await plugin.initialize() + + # Should not install requirements when cache is valid + mock_comm.install_requirements.assert_not_called() + + @pytest.mark.asyncio + @patch("cpex.framework.isolated.client.VenvProcessCommunicator") + @patch.object(IsolatedVenvPlugin, "create_venv") + @patch.object(IsolatedVenvPlugin, "_is_venv_cache_valid") + @patch.object(IsolatedVenvPlugin, "_save_cache_metadata") + async def test_initialize_with_invalid_cache(self, mock_save_metadata, mock_cache_valid, mock_create_venv, mock_comm_class, plugin): + """Test initialize with invalid cache installs requirements.""" + mock_cache_valid.return_value = False + mock_create_venv.return_value = True + mock_comm = MagicMock() + mock_comm_class.return_value = mock_comm + + await plugin.initialize() + + # Should install requirements when cache is invalid + mock_comm.install_requirements.assert_called_once() + mock_save_metadata.assert_called_once() + @pytest.mark.asyncio + async def test_cleanup(self, plugin): + """Test cleanup method stops worker process.""" + mock_comm = MagicMock() + plugin.comm = mock_comm + + await plugin.cleanup() + + mock_comm.stop_worker.assert_called_once() + assert plugin.comm is None + + @pytest.mark.asyncio + async def test_cleanup_no_comm(self, plugin): + """Test cleanup when comm is None.""" + plugin.comm = None + + # Should not raise error + await plugin.cleanup() + + + +# Made with Bob diff --git a/tests/unit/cpex/framework/isolated/test_integration.py b/tests/unit/cpex/framework/isolated/test_integration.py new file mode 100644 index 00000000..69b9257e --- /dev/null +++ b/tests/unit/cpex/framework/isolated/test_integration.py @@ -0,0 +1,381 @@ +# -*- coding: utf-8 -*- +"""Location: ./tests/unit/cpex/framework/isolated/test_integration.py +Copyright 2025 +SPDX-License-Identifier: Apache-2.0 +Authors: Ted Habeck + +Integration tests for isolated plugin system. +""" + +import asyncio +import sys +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock, Mock, patch + +import pytest +import yaml + +from cpex.framework import GlobalContext, PluginManager +from cpex.framework.hooks.tools import ToolPreInvokePayload +from cpex.framework.isolated.client import IsolatedVenvPlugin +from cpex.framework.loader.plugin import ALLOWED_PLUGIN_DIRS +from cpex.framework.models import Config, PluginConfig + + +class TestIsolatedPluginIntegration: + """Integration tests for the isolated plugin system.""" + + @pytest.fixture + def integration_config_path(self, tmp_path): + """Create a temporary config file for integration testing.""" + + cfg = Config(plugins=[PluginConfig(name="test_isolated_plugin", kind="isolated_venv",description="Test isolated plugin",version="1.0.0",author="Test",hooks=["tool_pre_invoke"], + config={ + "class_name": "test_plugin.TestPlugin", + "requirements_file": "requirements.txt" + })],plugin_dirs=[str((tmp_path / "xplugins").resolve())], + plugin_settings={ + "parallel_execution_within_band": True, + "plugin_timeout": 30, + "fail_on_plugin_error": False + }) + config_file = tmp_path / "xplugins" / "test_config.yaml" + class_root = tmp_path / "xplugins" / "test_plugin" + class_root.mkdir(parents=True, exist_ok=True) + dumped_cfg = cfg.model_dump(mode="json") + config_content = yaml.safe_dump(dumped_cfg, default_flow_style=False) + config_file.write_text(config_content) + return str(config_file) + + @pytest.mark.asyncio + @patch("cpex.framework.isolated.client.VenvProcessCommunicator") + @patch.object(IsolatedVenvPlugin, "create_venv") + async def test_plugin_manager_with_isolated_plugin( + self, mock_create_venv, mock_comm_class, integration_config_path, tmp_path + ): + """Test PluginManager loading and initializing an isolated plugin.""" + # Setup mocks + mock_create_venv.return_value = None + mock_comm = MagicMock() + mock_comm.install_requirements = MagicMock() + mock_comm_class.return_value = mock_comm + with patch('cpex.framework.loader.plugin.ALLOWED_PLUGIN_DIRS', { str((tmp_path / "xplugins" ).resolve())}): + # Create manager + manager = PluginManager(integration_config_path) + + await manager.initialize() + + @pytest.mark.asyncio + @patch("cpex.framework.isolated.client.VenvProcessCommunicator") + @patch.object(IsolatedVenvPlugin, "create_venv") + async def test_isolated_plugin_full_lifecycle(self, mock_create_venv, mock_comm_class, tmp_path): + """Test full lifecycle of an isolated plugin.""" + # Setup + mock_create_venv.return_value = None + mock_comm = MagicMock() + mock_comm.install_requirements = MagicMock() + mock_comm.send_task.return_value = { + "continue_processing": True, + "modified_payload": None, + "violation": None, + "metadata": {} + } + mock_comm_class.return_value = mock_comm + + config_dict = { + "name": "test_plugin", + "kind": "isolated_venv", + "description": "Test plugin", + "version": "1.0.0", + "author": "Test", + "hooks": ["tool_pre_invoke"], + "config": { + "class_name": "test_plugin.TestPlugin", + "requirements_file": "requirements.txt", + } + } + resolved_plugin_path = (tmp_path / "xplugins" ).resolve() + plugin_root = resolved_plugin_path / "test_plugin" + plugin_root.mkdir(parents=True, exist_ok=True) + # resolved_plugin_path.mkdir(parents=True, exist_ok=True) + with patch('cpex.framework.loader.plugin.ALLOWED_PLUGIN_DIRS', { str(resolved_plugin_path) }): + + config = PluginConfig(**config_dict) + + # Create and initialize plugin + plugin = IsolatedVenvPlugin(config, plugin_dirs=[resolved_plugin_path]) + + with patch("cpex.framework.isolated.client.get_hook_registry") as mock_registry: + from cpex.framework.hooks.tools import ToolPreInvokeResult + mock_reg = MagicMock() + mock_reg.get_result_type.return_value = ToolPreInvokeResult + mock_reg.json_to_result = MagicMock() + mock_reg.json_to_result.return_value = ToolPreInvokeResult(continue_processing=True) + mock_registry.return_value = mock_reg + + await plugin.initialize() + + # Invoke hook + payload = ToolPreInvokePayload(name="test_tool", args={}) + global_ctx = GlobalContext(request_id="req-123") + from cpex.framework.models import PluginContext + context = PluginContext(global_context=global_ctx) + + result = await plugin.invoke_hook("tool_pre_invoke", payload, context) + + assert result is not None + assert result.continue_processing is True + + @pytest.mark.asyncio + async def test_isolated_plugin_error_handling(self, tmp_path): + """Test error handling in isolated plugin.""" + config_dict = { + "name": "test_plugin", + "kind": "isolated_venv", + "description": "Test plugin", + "version": "1.0.0", + "author": "Test", + "hooks": ["tool_pre_invoke"], + "config": { + "class_name": "test_plugin.TestPlugin", + "requirements_file": "requirements.txt", + } + } + config = PluginConfig(**config_dict) + resolved_plugin_path = (tmp_path / "xplugins" ).resolve() + cache_root = resolved_plugin_path / "test_plugin" + cache_root.mkdir(parents=True, exist_ok=True) + # resolved_plugin_path.mkdir(parents=True, exist_ok=True) + + plugin = IsolatedVenvPlugin(config, plugin_dirs=[str(resolved_plugin_path)]) + + # Try to invoke hook without initialization + from cpex.framework.errors import PluginError + payload = ToolPreInvokePayload(name="test_tool", args={}) + global_ctx = GlobalContext(request_id="req-123") + from cpex.framework.models import PluginContext + context = PluginContext(global_context=global_ctx) + + with pytest.raises(PluginError, match="Plugin comm not initialized"): + await plugin.invoke_hook("tool_pre_invoke", payload, context) + + @pytest.mark.asyncio + @patch("cpex.framework.isolated.client.VenvProcessCommunicator") + @patch.object(IsolatedVenvPlugin, "create_venv") + async def test_isolated_plugin_with_multiple_hooks( + self, mock_create_venv, mock_comm_class, tmp_path + ): + """Test isolated plugin with multiple hook types.""" + mock_create_venv.return_value = None + mock_comm = MagicMock() + mock_comm.install_requirements = MagicMock() + mock_comm_class.return_value = mock_comm + + config_dict = { + "name": "test_plugin", + "kind": "isolated_venv", + "description": "Test plugin", + "version": "1.0.0", + "author": "Test", + "hooks": ["tool_pre_invoke", "tool_post_invoke", "prompt_pre_fetch", "prompt_post_fetch"], + "config": { + "class_name": "test_plugin.TestPlugin", + "requirements_file": "requirements.txt", + "script_path": "tests/unit/cpex/fixtures/plugins/isolated" + } + } + + config = PluginConfig(**config_dict) + resolved_plugin_path = (tmp_path / "xplugins" ).resolve() + cache_root = resolved_plugin_path / "test_plugin" + cache_root.mkdir(parents=True, exist_ok=True) + + plugin = IsolatedVenvPlugin(config, plugin_dirs=[str(resolved_plugin_path)]) + + await plugin.initialize() + + # Test each hook type + hook_types = [ + ("tool_pre_invoke", "ToolPreInvokeResult"), + ("tool_post_invoke", "ToolPostInvokeResult"), + ("prompt_pre_fetch", "PromptPrehookResult"), + ("prompt_post_fetch", "PromptPosthookResult") + ] + + for hook_type, result_type_name in hook_types: + mock_comm.send_task.return_value = { + "continue_processing": True, + "modified_payload": None, + "violation": None, + "metadata": {} + } + + with patch("cpex.framework.isolated.client.get_hook_registry") as mock_registry: + # Import the appropriate result type + if "Tool" in result_type_name: + from cpex.framework.hooks.tools import ToolPreInvokeResult, ToolPostInvokeResult + result_class = ToolPreInvokeResult if "Pre" in result_type_name else ToolPostInvokeResult + else: + from cpex.framework.hooks.prompts import PromptPrehookResult, PromptPosthookResult + result_class = PromptPrehookResult if "Pre" in result_type_name else PromptPosthookResult + + mock_reg = MagicMock() + mock_reg.get_result_type.return_value = result_class + mock_registry.return_value = mock_reg + + # Create appropriate payload + if "tool" in hook_type: + from cpex.framework.hooks.tools import ToolPreInvokePayload, ToolPostInvokePayload + payload = ToolPreInvokePayload(name="test", args={}) if "pre" in hook_type else ToolPostInvokePayload(name="test", result={}) + else: + from cpex.framework.hooks.prompts import PromptPrehookPayload, PromptPosthookPayload + payload = PromptPrehookPayload(prompt_id="test", args={}) if "pre" in hook_type else PromptPosthookPayload(prompt_id="test", result={}) + + global_ctx = GlobalContext(request_id="req-123") + from cpex.framework.models import PluginContext + context = PluginContext(global_context=global_ctx) + + result = await plugin.invoke_hook(hook_type, payload, context) + assert result is not None + + @pytest.mark.asyncio + @patch("cpex.framework.isolated.client.VenvProcessCommunicator") + @patch.object(IsolatedVenvPlugin, "create_venv") + async def test_isolated_plugin_context_propagation( + self, mock_create_venv, mock_comm_class, tmp_path + ): + """Test that context is properly propagated through isolated plugin.""" + mock_create_venv.return_value = None + mock_comm = MagicMock() + mock_comm.install_requirements = MagicMock() + + # Capture the task data sent + captured_task = None + def capture_task(script_path, task_data, max_content_size): + nonlocal captured_task + captured_task = task_data + return { + "continue_processing": True, + "modified_payload": None, + "violation": None, + "metadata": {} + } + + mock_comm.send_task = capture_task + mock_comm_class.return_value = mock_comm + + config_dict = { + "name": "test_plugin", + "kind": "isolated_venv", + "description": "Test plugin", + "version": "1.0.0", + "author": "Test", + "hooks": ["tool_pre_invoke"], + "config": { + "class_name": "test_plugin.TestPlugin", + "requirements_file": "requirements.txt", + }, + } + config = PluginConfig(**config_dict) + resolved_plugin_path = (tmp_path / "xplugins" ).resolve() + cache_root = resolved_plugin_path / "test_plugin" + cache_root.mkdir(parents=True, exist_ok=True) + + plugin = IsolatedVenvPlugin(config, plugin_dirs=[str(resolved_plugin_path)]) + + await plugin.initialize() + + with patch("cpex.framework.isolated.client.get_hook_registry") as mock_registry: + from cpex.framework.hooks.tools import ToolPreInvokeResult + mock_reg = MagicMock() + mock_reg.get_result_type.return_value = ToolPreInvokeResult + mock_registry.return_value = mock_reg + + # Create context with metadata + global_ctx = GlobalContext(request_id="req-123", user="alice", tenant_id="tenant-1") + from cpex.framework.models import PluginContext + context = PluginContext( + global_context=global_ctx, + state={"key": "value"}, + metadata={"custom": "data"} + ) + + payload = ToolPreInvokePayload(name="test_tool", args={"arg1": "value1"}) + + await plugin.invoke_hook("tool_pre_invoke", payload, context) + + # Verify context was properly serialized and sent + assert captured_task is not None + assert "context" in captured_task + assert captured_task["context"]["global_context"]["request_id"] == "req-123" + assert captured_task["context"]["global_context"]["user"] == "alice" + assert captured_task["context"]["state"]["key"] == "value" + assert captured_task["context"]["metadata"]["custom"] == "data" + + # Verify payload was serialized + assert "payload" in captured_task + assert captured_task["payload"]["name"] == "test_tool" + assert captured_task["payload"]["args"]["arg1"] == "value1" + + @pytest.mark.asyncio + @patch("cpex.framework.isolated.client.VenvProcessCommunicator") + @patch.object(IsolatedVenvPlugin, "create_venv") + async def test_isolated_plugin_violation_handling( + self, mock_create_venv, mock_comm_class, tmp_path + ): + """Test handling of policy violations in isolated plugin.""" + mock_create_venv.return_value = None + mock_comm = MagicMock() + mock_comm.install_requirements = MagicMock() + mock_comm.send_task.return_value = { + "continue_processing": False, + "modified_payload": None, + "violation": {"reason": "Policy violation", "description":"severity high", "code": "PROHIBITED_CONTENT"}, + "metadata": {} + } + mock_comm_class.return_value = mock_comm + + config_dict = { + "name": "test_plugin", + "kind": "isolated_venv", + "description": "Test plugin", + "version": "1.0.0", + "author": "Test", + "hooks": ["tool_pre_invoke"], + "config": { + "class_name": "test_plugin.TestPlugin", + "requirements_file": "requirements.txt", + } + } + config = PluginConfig(**config_dict) + resolved_plugin_path = (tmp_path / "xplugins" ).resolve() + cache_root = resolved_plugin_path / "test_plugin" + cache_root.mkdir(parents=True, exist_ok=True) + + plugin = IsolatedVenvPlugin(config, plugin_dirs=[str(resolved_plugin_path)]) + + await plugin.initialize() + + with patch("cpex.framework.isolated.client.get_hook_registry") as mock_registry: + from cpex.framework.hooks.tools import ToolPreInvokeResult + + mock_reg = MagicMock() + mock_reg.get_result_type.return_value = ToolPreInvokeResult + mock_reg.json_to_result = MagicMock() + mock_reg.json_to_result.return_value = ToolPreInvokeResult( + continue_processing=False, + violation={"reason": "Policy violation", "description":"severity high", "code": "PROHIBITED_CONTENT"}, + ) + mock_registry.return_value = mock_reg + + payload = ToolPreInvokePayload(name="dangerous_tool", args={}) + global_ctx = GlobalContext(request_id="req-123") + from cpex.framework.models import PluginContext + context = PluginContext(global_context=global_ctx) + + result = await plugin.invoke_hook("tool_pre_invoke", payload, context) + + assert result.continue_processing is False + assert result.violation is not None + +# Made with Bob diff --git a/tests/unit/cpex/framework/isolated/test_venv_comm.py b/tests/unit/cpex/framework/isolated/test_venv_comm.py new file mode 100644 index 00000000..81a1f155 --- /dev/null +++ b/tests/unit/cpex/framework/isolated/test_venv_comm.py @@ -0,0 +1,929 @@ +# -*- coding: utf-8 -*- +"""Location: ./tests/unit/cpex/framework/isolated/test_venv_comm.py +Copyright 2025 +SPDX-License-Identifier: Apache-2.0 +Authors: Ted Habeck + +Unit tests for VenvProcessCommunicator. +""" + +import json +import subprocess +import sys +from pathlib import Path +from queue import Queue +from unittest.mock import MagicMock, Mock, patch + +import pytest + +from cpex.framework.isolated.venv_comm import VenvProcessCommunicator + + +class TestVenvProcessCommunicator: + """Test suite for VenvProcessCommunicator class.""" + + @pytest.fixture + def mock_venv_path(self, tmp_path): + """Create a mock venv directory structure.""" + venv_path = tmp_path / ".venv" + venv_path.mkdir() + + # Create appropriate bin/Scripts directory based on platform + if sys.platform == "win32": + scripts_dir = venv_path / "Scripts" + scripts_dir.mkdir() + python_exe = scripts_dir / "python.exe" + else: + bin_dir = venv_path / "bin" + bin_dir.mkdir() + python_exe = bin_dir / "python" + + # Create a dummy python executable + python_exe.touch() + python_exe.chmod(0o755) + + return venv_path + + @pytest.fixture + def communicator(self, mock_venv_path): + """Create a VenvProcessCommunicator instance with mock venv.""" + return VenvProcessCommunicator(str(mock_venv_path)) + + def test_init_valid_venv(self, mock_venv_path): + """Test initialization with valid venv path.""" + comm = VenvProcessCommunicator(str(mock_venv_path)) + assert comm.venv_path == mock_venv_path + assert comm.python_executable is not None + assert Path(comm.python_executable).exists() + + def test_init_invalid_venv(self, tmp_path): + """Test initialization with invalid venv path raises error.""" + invalid_path = tmp_path / "nonexistent" + with pytest.raises(FileNotFoundError, match="Python executable not found"): + VenvProcessCommunicator(str(invalid_path)) + + def test_get_python_executable_unix(self, tmp_path): + """Test getting Python executable path on Unix-like systems.""" + venv_path = tmp_path / ".venv" + venv_path.mkdir() + bin_dir = venv_path / "bin" + bin_dir.mkdir() + python_exe = bin_dir / "python" + python_exe.touch() + + with patch("sys.platform", "linux"): + comm = VenvProcessCommunicator(str(venv_path)) + assert comm.python_executable == str(python_exe) + + def test_get_python_executable_windows(self, tmp_path): + """Test getting Python executable path on Windows.""" + venv_path = tmp_path / ".venv" + venv_path.mkdir() + scripts_dir = venv_path / "Scripts" + scripts_dir.mkdir() + python_exe = scripts_dir / "python.exe" + python_exe.touch() + + with patch("sys.platform", "win32"): + comm = VenvProcessCommunicator(str(venv_path)) + assert comm.python_executable == str(python_exe) + + @patch("subprocess.check_call") + def test_install_requirements_success(self, mock_check_call, communicator, tmp_path): + """Test successful requirements installation.""" + requirements_file = tmp_path / "requirements.txt" + requirements_file.write_text("pytest>=7.0.0\n") + + mock_check_call.return_value = 0 + + communicator.install_requirements(str(requirements_file)) + + mock_check_call.assert_called_once_with([ + communicator.python_executable, + "-m", + "pip", + "install", + "-r", + str(requirements_file) + ]) + + @patch("subprocess.check_call") + def test_install_requirements_failure(self, mock_check_call, communicator, tmp_path): + """Test requirements installation failure.""" + requirements_file = tmp_path / "requirements.txt" + requirements_file.write_text("invalid-package-name-xyz\n") + + # Simulate subprocess.check_call raising an exception + mock_check_call.side_effect = subprocess.CalledProcessError(1, "pip install") + + with pytest.raises(RuntimeError, match=f"Failed to install requirements from {requirements_file}"): + communicator.install_requirements(str(requirements_file)) + + def test_install_requirements_nonexistent_file(self, communicator): + """Test install_requirements with nonexistent file does nothing.""" + # Should not raise an error if file doesn't exist + communicator.install_requirements("nonexistent_requirements.txt") + + @patch("subprocess.Popen") + @patch("threading.Thread") + @patch("cpex.framework.isolated.venv_comm.Queue") + def test_send_task_success(self, mock_queue_class, mock_thread, mock_popen, communicator): + """Test successful task sending and response.""" + task_data = {"task_type": "info", "data": "test"} + + # Mock the process + mock_process = MagicMock() + mock_process.stdin = MagicMock() + mock_process.stdout = MagicMock() + mock_process.stderr = MagicMock() + mock_process.poll.return_value = None + mock_popen.return_value = mock_process + + # Mock the thread + mock_thread_instance = MagicMock() + mock_thread.return_value = mock_thread_instance + + # Mock the Queue to return our response + mock_queue_instance = MagicMock() + mock_queue_instance.get.return_value = { + "status": "success", + "result": "ok", + "request_id": "test-id" + } + mock_queue_class.return_value = mock_queue_instance + + # Manually start the worker to set up the infrastructure + communicator.start_worker("test_script.py") + + result = communicator.send_task("test_script.py", task_data) + + # Request ID should be removed from response + assert result == {"status": "success", "result": "ok"} + + @patch("subprocess.Popen") + @patch("threading.Thread") + @patch("cpex.framework.isolated.venv_comm.Queue") + def test_send_task_process_failure(self, mock_queue_class, mock_thread, mock_popen, communicator): + """Test task sending with process failure.""" + task_data = {"task_type": "test"} + + mock_process = MagicMock() + mock_process.stdin = MagicMock() + mock_process.stdout = MagicMock() + mock_process.stderr = MagicMock() + mock_process.poll.return_value = None + mock_popen.return_value = mock_process + + mock_thread_instance = MagicMock() + mock_thread.return_value = mock_thread_instance + + # Mock the Queue to return error response + mock_queue_instance = MagicMock() + mock_queue_instance.get.return_value = { + "status": "error", + "message": "Process failed", + "request_id": "test-id" + } + mock_queue_class.return_value = mock_queue_instance + + # Start worker + communicator.start_worker("test_script.py") + + with pytest.raises(RuntimeError, match="Worker process error: Process failed"): + communicator.send_task("test_script.py", task_data) + + @patch("subprocess.Popen") + @patch("threading.Thread") + def test_send_task_timeout(self, mock_thread, mock_popen, communicator): + """Test task sending with timeout.""" + task_data = {"task_type": "test"} + + mock_process = MagicMock() + mock_process.stdin = MagicMock() + mock_process.stdout = MagicMock() + mock_process.stderr = MagicMock() + mock_process.poll.return_value = None + mock_popen.return_value = mock_process + + mock_thread_instance = MagicMock() + mock_thread.return_value = mock_thread_instance + + # Don't put anything in the queue to simulate timeout + + with pytest.raises(RuntimeError, match="Worker process timed out"): + communicator.send_task("test_script.py", task_data, timeout=0.1) + + @patch("subprocess.Popen") + def test_send_task_communication_error(self, mock_popen, communicator): + """Test task sending with communication error.""" + task_data = {"task_type": "test"} + + mock_popen.side_effect = OSError("Connection failed") + + with pytest.raises(RuntimeError, match="Failed to start worker process"): + communicator.send_task("test_script.py", task_data) + + @patch("subprocess.Popen") + @patch("threading.Thread") + @patch("cpex.framework.isolated.venv_comm.Queue") + def test_send_task_with_complex_data(self, mock_queue_class, mock_thread, mock_popen, communicator): + """Test sending task with complex nested data structures.""" + task_data = { + "task_type": "load_and_run_hook", + "config": {"nested": {"data": [1, 2, 3]}}, + "payload": {"args": {"key": "value"}}, + "context": {"state": {}, "metadata": {}} + } + + mock_process = MagicMock() + mock_process.stdin = MagicMock() + mock_process.stdout = MagicMock() + mock_process.stderr = MagicMock() + mock_process.poll.return_value = None + mock_popen.return_value = mock_process + + mock_thread_instance = MagicMock() + mock_thread.return_value = mock_thread_instance + + # Mock the Queue to return response + mock_queue_instance = MagicMock() + mock_queue_instance.get.return_value = { + "status": "success", + "result": {"data": "processed"}, + "request_id": "test-id" + } + mock_queue_class.return_value = mock_queue_instance + + # Start worker + communicator.start_worker("worker.py") + + result = communicator.send_task("worker.py", task_data) + + assert result == {"status": "success", "result": {"data": "processed"}} + # Verify the task was serialized properly + call_args = mock_popen.call_args + assert call_args is not None + + @patch("subprocess.Popen") + @patch("threading.Thread") + @patch("cpex.framework.isolated.venv_comm.Queue") + @patch("os.getcwd") + def test_send_task_maintains_cwd(self, mock_getcwd, mock_queue_class, mock_thread, mock_popen, communicator): + """Test that send_task maintains current working directory.""" + mock_getcwd.return_value = "/test/path" + task_data = {"task_type": "test"} + + mock_process = MagicMock() + mock_process.stdin = MagicMock() + mock_process.stdout = MagicMock() + mock_process.stderr = MagicMock() + mock_process.poll.return_value = None + mock_popen.return_value = mock_process + + mock_thread_instance = MagicMock() + mock_thread.return_value = mock_thread_instance + + # Mock the Queue to return response + mock_queue_instance = MagicMock() + mock_queue_instance.get.return_value = {"status": "ok", "request_id": "test-id"} + mock_queue_class.return_value = mock_queue_instance + + # Start worker + communicator.start_worker("test_script.py") + + communicator.send_task("test_script.py", task_data) + + # Verify cwd was passed to Popen + call_kwargs = mock_popen.call_args[1] + assert call_kwargs["cwd"] == "/test/path" + + def test_python_executable_property(self, communicator): + """Test that python_executable property is accessible.""" + assert communicator.python_executable is not None + assert isinstance(communicator.python_executable, str) + assert Path(communicator.python_executable).exists() + + def test_venv_path_property(self, communicator, mock_venv_path): + """Test that venv_path property is accessible.""" + assert communicator.venv_path == mock_venv_path + assert isinstance(communicator.venv_path, Path) + @patch("subprocess.Popen") + @patch("threading.Thread") + def test_start_worker_success(self, mock_thread, mock_popen, communicator): + """Test successful worker process start.""" + mock_process = MagicMock() + mock_process.stdin = MagicMock() + mock_process.stdout = MagicMock() + mock_process.stderr = MagicMock() + mock_process.pid = 12345 + mock_process.poll.return_value = None + mock_popen.return_value = mock_process + + mock_thread_instance = MagicMock() + mock_thread.return_value = mock_thread_instance + + communicator.start_worker("test_script.py") + + assert communicator.running is True + assert communicator.process is not None + mock_popen.assert_called_once() + # Should start two threads (stdout and stderr readers) + assert mock_thread.call_count == 2 + + @patch("subprocess.Popen") + def test_start_worker_already_running(self, mock_popen, communicator): + """Test starting worker when already running.""" + communicator.running = True + communicator.process = MagicMock() + + communicator.start_worker("test_script.py") + + # Should not create new process + mock_popen.assert_not_called() + + @patch("subprocess.Popen") + def test_start_worker_failure(self, mock_popen, communicator): + """Test worker start failure.""" + mock_popen.side_effect = OSError("Failed to start") + + with pytest.raises(RuntimeError, match="Failed to start worker process"): + communicator.start_worker("test_script.py") + + assert communicator.running is False + + def test_stop_worker_not_running(self, communicator): + """Test stopping worker when not running.""" + communicator.running = False + communicator.process = None + + # Should not raise error + communicator.stop_worker() + + @patch("subprocess.Popen") + @patch("threading.Thread") + def test_stop_worker_success(self, mock_thread, mock_popen, communicator): + """Test successful worker stop.""" + mock_process = MagicMock() + mock_process.stdin = MagicMock() + mock_process.stdout = MagicMock() + mock_process.stderr = MagicMock() + mock_process.wait.return_value = None + mock_popen.return_value = mock_process + + mock_thread_instance = MagicMock() + mock_thread_instance.is_alive.return_value = False + mock_thread.return_value = mock_thread_instance + + # Start worker first + communicator.start_worker("test_script.py") + + # Stop worker + communicator.stop_worker() + + assert communicator.running is False + assert communicator.process is None + mock_process.wait.assert_called() + + @patch("subprocess.Popen") + @patch("threading.Thread") + def test_stop_worker_timeout(self, mock_thread, mock_popen, communicator): + """Test worker stop with timeout.""" + mock_process = MagicMock() + mock_process.stdin = MagicMock() + mock_process.stdout = MagicMock() + mock_process.stderr = MagicMock() + mock_process.wait.side_effect = subprocess.TimeoutExpired("cmd", 5) + mock_popen.return_value = mock_process + + mock_thread_instance = MagicMock() + mock_thread_instance.is_alive.return_value = False + mock_thread.return_value = mock_thread_instance + + # Start worker first + communicator.start_worker("test_script.py") + + # Stop worker + communicator.stop_worker() + + # Should kill process after timeout + mock_process.kill.assert_called_once() + + def test_is_alive_not_running(self, communicator): + """Test is_alive when worker not running.""" + communicator.running = False + communicator.process = None + + assert communicator.is_alive() is False + + @patch("subprocess.Popen") + @patch("threading.Thread") + def test_is_alive_running(self, mock_thread, mock_popen, communicator): + """Test is_alive when worker is running.""" + mock_process = MagicMock() + mock_process.stdin = MagicMock() + mock_process.stdout = MagicMock() + mock_process.stderr = MagicMock() + mock_process.poll.return_value = None + mock_popen.return_value = mock_process + + mock_thread_instance = MagicMock() + mock_thread.return_value = mock_thread_instance + + communicator.start_worker("test_script.py") + + assert communicator.is_alive() is True + + @patch("subprocess.Popen") + @patch("threading.Thread") + def test_is_alive_process_terminated(self, mock_thread, mock_popen, communicator): + """Test is_alive when process has terminated.""" + mock_process = MagicMock() + mock_process.stdin = MagicMock() + mock_process.stdout = MagicMock() + mock_process.stderr = MagicMock() + mock_process.poll.return_value = 1 # Process terminated + mock_popen.return_value = mock_process + + mock_thread_instance = MagicMock() + mock_thread.return_value = mock_thread_instance + + communicator.start_worker("test_script.py") + + assert communicator.is_alive() is False + + + @patch("subprocess.Popen") + @patch("threading.Thread") + def test_read_stderr_with_output(self, mock_thread, mock_popen, communicator): + """Test _read_stderr method reads and logs stderr output.""" + mock_process = MagicMock() + mock_process.stdin = MagicMock() + mock_process.stdout = MagicMock() + + # Mock stderr with some output + mock_stderr = MagicMock() + mock_stderr.readline.side_effect = [ + "Error line 1\n", + "Error line 2\n", + "", # Empty string signals end + ] + mock_process.stderr = mock_stderr + mock_process.poll.return_value = None + mock_popen.return_value = mock_process + + mock_thread_instance = MagicMock() + mock_thread.return_value = mock_thread_instance + + # Start worker to trigger stderr thread + communicator.start_worker("test_script.py") + + # Manually call _read_stderr to test it + communicator._read_stderr() + + # Verify readline was called + assert mock_stderr.readline.call_count >= 1 + + @patch("subprocess.Popen") + @patch("threading.Thread") + def test_read_stderr_with_exception(self, mock_thread, mock_popen, communicator): + """Test _read_stderr handles exceptions gracefully.""" + mock_process = MagicMock() + mock_process.stdin = MagicMock() + mock_process.stdout = MagicMock() + + # Mock stderr that raises exception + mock_stderr = MagicMock() + mock_stderr.readline.side_effect = Exception("Read error") + mock_process.stderr = mock_stderr + mock_process.poll.return_value = None + mock_popen.return_value = mock_process + + mock_thread_instance = MagicMock() + mock_thread.return_value = mock_thread_instance + + communicator.start_worker("test_script.py") + + # Should not raise exception + communicator._read_stderr() + + @patch("subprocess.Popen") + @patch("threading.Thread") + def test_read_stderr_no_process(self, mock_thread, mock_popen, communicator): + """Test _read_stderr returns early when no process.""" + # Don't start worker, just call _read_stderr + communicator._read_stderr() + # Should return without error + + @patch("subprocess.Popen") + @patch("threading.Thread") + def test_read_responses_with_valid_json(self, mock_thread, mock_popen, communicator): + """Test _read_responses processes valid JSON responses.""" + mock_process = MagicMock() + mock_process.stdin = MagicMock() + mock_process.stderr = MagicMock() + + # Mock stdout with valid JSON responses + mock_stdout = MagicMock() + mock_stdout.readline.side_effect = [ + '{"status": "ok", "request_id": "test-123"}\n', + "", # Empty string signals end + ] + mock_process.stdout = mock_stdout + mock_process.poll.return_value = None + mock_popen.return_value = mock_process + + mock_thread_instance = MagicMock() + mock_thread.return_value = mock_thread_instance + + # Create a response queue for the request + communicator.response_queues["test-123"] = Queue() + + communicator.start_worker("test_script.py") + + # Manually call _read_responses + communicator._read_responses() + + # Verify the response was queued + assert not communicator.response_queues["test-123"].empty() + + @patch("subprocess.Popen") + @patch("threading.Thread") + def test_read_responses_with_empty_lines(self, mock_thread, mock_popen, communicator): + """Test _read_responses skips empty lines.""" + mock_process = MagicMock() + mock_process.stdin = MagicMock() + mock_process.stderr = MagicMock() + + # Mock stdout with empty lines + mock_stdout = MagicMock() + mock_stdout.readline.side_effect = [ + "\n", + " \n", + '{"status": "ok", "request_id": "test-456"}\n', + "", + ] + mock_process.stdout = mock_stdout + mock_process.poll.return_value = None + mock_popen.return_value = mock_process + + mock_thread_instance = MagicMock() + mock_thread.return_value = mock_thread_instance + + communicator.response_queues["test-456"] = Queue() + communicator.start_worker("test_script.py") + communicator._read_responses() + + assert not communicator.response_queues["test-456"].empty() + + @patch("subprocess.Popen") + @patch("threading.Thread") + def test_read_responses_with_invalid_json(self, mock_thread, mock_popen, communicator): + """Test _read_responses handles invalid JSON gracefully.""" + mock_process = MagicMock() + mock_process.stdin = MagicMock() + mock_process.stderr = MagicMock() + + # Mock stdout with invalid JSON + mock_stdout = MagicMock() + mock_stdout.readline.side_effect = [ + "not valid json\n", + '{"incomplete": \n', + "", + ] + mock_process.stdout = mock_stdout + mock_process.poll.return_value = None + mock_popen.return_value = mock_process + + mock_thread_instance = MagicMock() + mock_thread.return_value = mock_thread_instance + + communicator.start_worker("test_script.py") + + # Should not raise exception + communicator._read_responses() + + @patch("subprocess.Popen") + @patch("threading.Thread") + def test_read_responses_without_request_id(self, mock_thread, mock_popen, communicator): + """Test _read_responses handles responses without request_id.""" + mock_process = MagicMock() + mock_process.stdin = MagicMock() + mock_process.stderr = MagicMock() + + # Mock stdout with response missing request_id + mock_stdout = MagicMock() + mock_stdout.readline.side_effect = [ + '{"status": "ok", "data": "test"}\n', + "", + ] + mock_process.stdout = mock_stdout + mock_process.poll.return_value = None + mock_popen.return_value = mock_process + + mock_thread_instance = MagicMock() + mock_thread.return_value = mock_thread_instance + + communicator.start_worker("test_script.py") + + # Should log warning but not crash + communicator._read_responses() + + @patch("subprocess.Popen") + @patch("threading.Thread") + def test_read_responses_unknown_request_id(self, mock_thread, mock_popen, communicator): + """Test _read_responses handles unknown request_id.""" + mock_process = MagicMock() + mock_process.stdin = MagicMock() + mock_process.stderr = MagicMock() + + # Mock stdout with unknown request_id + mock_stdout = MagicMock() + mock_stdout.readline.side_effect = [ + '{"status": "ok", "request_id": "unknown-999"}\n', + "", + ] + mock_process.stdout = mock_stdout + mock_process.poll.return_value = None + mock_popen.return_value = mock_process + + mock_thread_instance = MagicMock() + mock_thread.return_value = mock_thread_instance + + communicator.start_worker("test_script.py") + + # Should log warning but not crash + communicator._read_responses() + + @patch("subprocess.Popen") + @patch("threading.Thread") + def test_read_responses_with_exception(self, mock_thread, mock_popen, communicator): + """Test _read_responses handles exceptions during reading.""" + mock_process = MagicMock() + mock_process.stdin = MagicMock() + mock_process.stderr = MagicMock() + + # Mock stdout that raises exception + mock_stdout = MagicMock() + mock_stdout.readline.side_effect = Exception("Read error") + mock_process.stdout = mock_stdout + mock_process.poll.return_value = None + mock_popen.return_value = mock_process + + mock_thread_instance = MagicMock() + mock_thread.return_value = mock_thread_instance + + communicator.start_worker("test_script.py") + + # Should handle exception and set running to False + communicator._read_responses() + assert communicator.running is False + + @patch("subprocess.Popen") + @patch("threading.Thread") + @patch("cpex.framework.isolated.venv_comm.Queue") + def test_send_task_stdin_not_available(self, mock_queue_class, mock_thread, mock_popen, communicator): + """Test send_task when stdin is not available.""" + task_data = {"task_type": "test"} + + mock_process = MagicMock() + mock_process.stdin = None # stdin not available + mock_process.stdout = MagicMock() + mock_process.stderr = MagicMock() + mock_process.poll.return_value = None + mock_popen.return_value = mock_process + + mock_thread_instance = MagicMock() + mock_thread.return_value = mock_thread_instance + + mock_queue_instance = MagicMock() + mock_queue_class.return_value = mock_queue_instance + + communicator.start_worker("test_script.py") + + with pytest.raises(RuntimeError, match="Worker process stdin not available"): + communicator.send_task("test_script.py", task_data) + + @patch("subprocess.Popen") + @patch("threading.Thread") + def test_stop_worker_send_shutdown_exception(self, mock_thread, mock_popen, communicator): + """Test stop_worker handles exception when sending shutdown signal.""" + mock_process = MagicMock() + mock_stdin = MagicMock() + mock_stdin.write.side_effect = Exception("Write failed") + mock_process.stdin = mock_stdin + mock_process.stdout = MagicMock() + mock_process.stderr = MagicMock() + mock_process.wait.return_value = None + mock_popen.return_value = mock_process + + mock_thread_instance = MagicMock() + mock_thread_instance.is_alive.return_value = False + mock_thread.return_value = mock_thread_instance + + communicator.start_worker("test_script.py") + + # Should handle exception gracefully + communicator.stop_worker() + + assert communicator.running is False + assert communicator.process is None + + def test_del_method(self, communicator): + """Test __del__ method calls stop_worker.""" + communicator.running = True + communicator.process = MagicMock() + + # Call __del__ directly + communicator.__del__() + + # Should have stopped the worker + assert communicator.running is False + + def test_del_method_no_running_attribute(self): + """Test __del__ handles missing running attribute.""" + # Create instance without proper initialization + comm = object.__new__(VenvProcessCommunicator) + + # Should not raise exception + comm.__del__() + + @patch("subprocess.Popen") + @patch("threading.Thread") + def test_send_task_exceeds_max_content_size(self, mock_thread, mock_popen, communicator): + """Test send_task raises error when data exceeds max_content_size.""" + # Create a large task that will exceed the limit + large_data = "x" * 5000 + task_data = { + "task_type": "test", + "data": large_data + } + + mock_process = MagicMock() + mock_process.stdin = MagicMock() + mock_process.stdout = MagicMock() + mock_process.stderr = MagicMock() + mock_process.poll.return_value = None + mock_popen.return_value = mock_process + + mock_thread_instance = MagicMock() + mock_thread.return_value = mock_thread_instance + + communicator.start_worker("test_script.py") + + # Set a very small max_content_size to trigger the error + with pytest.raises(RuntimeError, match="task_data exceeds max_content_size"): + communicator.send_task("test_script.py", task_data, max_content_size=100) + + # Verify the request_id was cleaned up from response_queues + assert len(communicator.response_queues) == 0 + + @patch("subprocess.Popen") + @patch("threading.Thread") + @patch("uuid.uuid4") + def test_send_task_at_max_content_size_boundary(self, mock_uuid, mock_thread, mock_popen, communicator): + """Test send_task works when data is exactly at the limit.""" + # Use a fixed UUID to make size calculation predictable + mock_uuid.return_value = Mock(hex="12345678123456781234567812345678") + mock_uuid.return_value.__str__ = Mock(return_value="12345678-1234-5678-1234-567812345678") + + task_data = {"task_type": "test", "data": "small"} + + mock_process = MagicMock() + mock_process.stdin = MagicMock() + mock_process.stdout = MagicMock() + mock_process.stderr = MagicMock() + mock_process.poll.return_value = None + mock_popen.return_value = mock_process + + mock_thread_instance = MagicMock() + mock_thread.return_value = mock_thread_instance + + # Mock the Queue to return response + with patch("cpex.framework.isolated.venv_comm.Queue") as mock_queue_class: + mock_queue_instance = MagicMock() + mock_queue_instance.get.return_value = { + "status": "success", + "result": "ok", + "request_id": "test-id" + } + mock_queue_class.return_value = mock_queue_instance + + communicator.start_worker("test_script.py") + + # Calculate the exact size of the serialized data with the mocked UUID + import orjson + test_data_copy = task_data.copy() + test_data_copy["request_id"] = "12345678-1234-5678-1234-567812345678" + serialized_size = len(orjson.dumps(test_data_copy).decode()) + + # Set max_content_size to exactly the serialized size + result = communicator.send_task("test_script.py", task_data, max_content_size=serialized_size) + + assert result == {"status": "success", "result": "ok"} + + @patch("subprocess.Popen") + @patch("threading.Thread") + def test_send_task_with_custom_max_content_size(self, mock_thread, mock_popen, communicator): + """Test send_task respects custom max_content_size parameter.""" + # Create task data that's moderately sized + task_data = { + "task_type": "test", + "data": "x" * 1000, + "metadata": {"key": "value"} + } + + mock_process = MagicMock() + mock_process.stdin = MagicMock() + mock_process.stdout = MagicMock() + mock_process.stderr = MagicMock() + mock_process.poll.return_value = None + mock_popen.return_value = mock_process + + mock_thread_instance = MagicMock() + mock_thread.return_value = mock_thread_instance + + with patch("cpex.framework.isolated.venv_comm.Queue") as mock_queue_class: + mock_queue_instance = MagicMock() + mock_queue_instance.get.return_value = { + "status": "success", + "result": "processed", + "request_id": "test-id" + } + mock_queue_class.return_value = mock_queue_instance + + communicator.start_worker("test_script.py") + + # Should succeed with large max_content_size + result = communicator.send_task("test_script.py", task_data, max_content_size=50000) + assert result == {"status": "success", "result": "processed"} + + # Should fail with small max_content_size + with pytest.raises(RuntimeError, match="task_data exceeds max_content_size"): + communicator.send_task("test_script.py", task_data, max_content_size=500) + + @patch("subprocess.Popen") + @patch("threading.Thread") + def test_send_task_default_max_content_size(self, mock_thread, mock_popen, communicator): + """Test send_task uses default max_content_size of 10MB.""" + # Create a task that's under 10MB + task_data = { + "task_type": "test", + "data": "x" * 100000 # 100KB + } + + mock_process = MagicMock() + mock_process.stdin = MagicMock() + mock_process.stdout = MagicMock() + mock_process.stderr = MagicMock() + mock_process.poll.return_value = None + mock_popen.return_value = mock_process + + mock_thread_instance = MagicMock() + mock_thread.return_value = mock_thread_instance + + with patch("cpex.framework.isolated.venv_comm.Queue") as mock_queue_class: + mock_queue_instance = MagicMock() + mock_queue_instance.get.return_value = { + "status": "success", + "result": "ok", + "request_id": "test-id" + } + mock_queue_class.return_value = mock_queue_instance + + communicator.start_worker("test_script.py") + + # Should succeed with default max_content_size (10MB) + result = communicator.send_task("test_script.py", task_data) + assert result == {"status": "success", "result": "ok"} + + @patch("subprocess.Popen") + @patch("threading.Thread") + def test_send_task_very_large_data_exceeds_default_limit(self, mock_thread, mock_popen, communicator): + """Test send_task fails when data exceeds default 10MB limit.""" + # Create a task that exceeds 10MB + task_data = { + "task_type": "test", + "data": "x" * 11000000 # ~11MB + } + + mock_process = MagicMock() + mock_process.stdin = MagicMock() + mock_process.stdout = MagicMock() + mock_process.stderr = MagicMock() + mock_process.poll.return_value = None + mock_popen.return_value = mock_process + + mock_thread_instance = MagicMock() + mock_thread.return_value = mock_thread_instance + + communicator.start_worker("test_script.py") + + # Should fail with default max_content_size + with pytest.raises(RuntimeError, match="task_data exceeds max_content_size"): + communicator.send_task("test_script.py", task_data) + + # Verify cleanup happened + assert len(communicator.response_queues) == 0 + + +# Made with Bob diff --git a/tests/unit/cpex/framework/isolated/test_worker.py b/tests/unit/cpex/framework/isolated/test_worker.py new file mode 100644 index 00000000..dd6d293c --- /dev/null +++ b/tests/unit/cpex/framework/isolated/test_worker.py @@ -0,0 +1,463 @@ +# -*- coding: utf-8 -*- +"""Location: ./tests/unit/cpex/framework/isolated/test_worker.py +Copyright 2025 +SPDX-License-Identifier: Apache-2.0 +Authors: Ted Habeck + +Unit tests for worker.py functions. +""" + +import json +import os +import shutil +import sys +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock, Mock, patch + +import pytest + +from cpex.framework.isolated.worker import TaskProcessor, get_environment_info, main, process_task + + +class TestWorkerFunctions: + """Test suite for worker.py functions.""" + + @pytest.fixture + def mock_plugin_dirs(self): + """ensure that the plugins directory exists""" + plugin_dirs = Path(os.getcwd()) / "tmp" / "plugins" + tmp = plugin_dirs + tmp.mkdir(parents=True, exist_ok=True) + return [str(plugin_dirs.resolve())] + + def cleanup_mock_plugin_dirs(self): + """Test cleanup for the mock plugin directories.""" + plugin_root = Path(os.getcwd()) / "tmp" + shutil.rmtree(plugin_root.resolve()) + + def test_get_environment_info(self): + """Test getting environment information.""" + info = get_environment_info() + + assert "python_version" in info + assert "python_executable" in info + assert "platform" in info + assert "installed_packages" in info + + assert info["python_version"] == sys.version + assert info["python_executable"] == sys.executable + assert isinstance(info["installed_packages"], list) + assert len(info["installed_packages"]) <= 10 # Limited to first 10 + + + @pytest.mark.asyncio + async def test_process_task_info(self): + """Test processing info task.""" + config_dict = {"name": "test_plugin", "kind": "isolated_venv", "config": {}} + task_data = {"task_type": "info", "config": json.dumps(config_dict)} + tp = TaskProcessor() + result = await process_task(task_data, tp) + + assert result["status"] == "success" + assert "environment" in result + assert "message" in result + assert result["message"] == "Environment info retrieved successfully" + + @pytest.mark.asyncio + @patch("cpex.framework.isolated.worker.get_proper_config") + @patch("cpex.framework.isolated.worker.importlib.import_module") + @patch("cpex.framework.isolated.worker.PluginExecutor") + async def test_process_task_load_and_run_hook_success(self, mock_executor_class, mock_import, mock_get_config, mock_plugin_dirs): + """Test processing load_and_run_hook task successfully.""" + # Setup mock config + mock_config = MagicMock() + mock_config.name = "test_plugin" + mock_get_config.return_value = mock_config + + # Setup mock plugin class + mock_plugin_instance = AsyncMock() + mock_plugin_instance.initialize = AsyncMock() + mock_plugin_instance.tool_pre_invoke = AsyncMock() + mock_plugin_instance.tool_post_invoke = AsyncMock() + mock_plugin_instance.tool_exception = AsyncMock() + mock_plugin_instance.tool_cleanup = AsyncMock() + mock_plugin_class = MagicMock(return_value=mock_plugin_instance) + + mock_module = MagicMock() + mock_module.TestPlugin = mock_plugin_class + mock_import.return_value = mock_module + + # Setup mock executor + mock_executor = MagicMock() + mock_result = MagicMock() + mock_result.continue_processing = True + mock_executor.execute_plugin = AsyncMock(return_value=mock_result) + mock_executor_class.return_value = mock_executor + + # Create task data + config_dict = {"name": "test_plugin", "kind": "isolated_venv", "config": {}} + task_data = { + "task_type": "load_and_run_hook", + "config": json.dumps(config_dict), + "plugin_dirs": mock_plugin_dirs, + "class_name": "test_plugin.TestPlugin", + "hook_type": "tool_pre_invoke", + "payload": {"name": "test_tool", "args": {}}, + "context": {"state": {}, "global_context": {"request_id": "req-123"}, "metadata": {}}, + } + tp = TaskProcessor() + result = await process_task(task_data, tp=tp) + + assert result is not None + mock_plugin_instance.initialize.assert_called_once() + mock_executor.execute_plugin.assert_called_once() + self.cleanup_mock_plugin_dirs() + + @pytest.mark.asyncio + @patch("cpex.framework.isolated.worker.get_proper_config") + @patch("cpex.framework.isolated.worker.importlib.import_module") + async def test_process_task_load_and_run_hook_import_error(self, mock_import, mock_get_config, mock_plugin_dirs): + """Test processing load_and_run_hook task with import error.""" + mock_config = MagicMock() + mock_get_config.return_value = mock_config + + mock_import.side_effect = ImportError("Module not found") + + config_dict = {"name": "test_plugin", "kind": "isolated_venv"} + task_data = { + "task_type": "load_and_run_hook", + "config": json.dumps(config_dict), + "class_name": "test_plugin.TestPlugin", + "plugin_dirs": mock_plugin_dirs, + "hook_type": "tool_pre_invoke", + "payload": {}, + "context": {"state": {}, "global_context": {}, "metadata": {}}, + } + tp = TaskProcessor() + with pytest.raises(ImportError): + await process_task(task_data, tp) + + @pytest.mark.asyncio + @patch("cpex.framework.isolated.worker.get_proper_config") + @patch("cpex.framework.isolated.worker.importlib.import_module") + @patch("cpex.framework.isolated.worker.PluginExecutor") + async def test_process_task_with_different_hook_types(self, mock_executor_class, mock_import, mock_get_config, mock_plugin_dirs): + """Test processing tasks with different hook types.""" + # Setup mocks + mock_config = MagicMock() + mock_get_config.return_value = mock_config + + mock_plugin_instance = MagicMock() + mock_plugin_instance.initialize = AsyncMock() + mock_plugin_instance.tool_pre_invoke = AsyncMock() + mock_plugin_instance.tool_post_invoke = AsyncMock() + mock_plugin_instance.prompt_pre_fetch = AsyncMock() + mock_plugin_instance.prompt_post_fetch = AsyncMock() + mock_plugin_instance.tool_exception = AsyncMock() + mock_plugin_instance.tool_cleanup = AsyncMock() + mock_plugin_class = MagicMock(return_value=mock_plugin_instance) + + mock_module = MagicMock() + mock_module.TestPlugin = mock_plugin_class + mock_import.return_value = mock_module + + mock_executor = MagicMock() + mock_result = MagicMock() + mock_executor.execute_plugin = AsyncMock(return_value=mock_result) + mock_executor_class.return_value = mock_executor + + hook_types = ["tool_pre_invoke", "tool_post_invoke", "prompt_pre_fetch", "prompt_post_fetch"] + tp = TaskProcessor() + + for hook_type in hook_types: + config_dict = {"name": "test_plugin", "kind": "isolated_venv"} + task_data = { + "task_type": "load_and_run_hook", + "config": json.dumps(config_dict), + "plugin_dirs": mock_plugin_dirs, + "class_name": "test_plugin.TestPlugin", + "hook_type": hook_type, + "payload": {}, + "context": {"state": {}, "global_context": {"request_id": "req-123"}, "metadata": {}}, + } + result = await process_task(task_data, tp) + assert result is not None + self.cleanup_mock_plugin_dirs() + + @pytest.mark.asyncio + async def test_process_task_unknown_task_type(self): + """Test processing task with unknown task type.""" + task_data = {"task_type": "unknown_type"} + tp = TaskProcessor() + # Should return None or handle gracefully + result = await process_task(task_data, tp) + assert result == {'message': 'task type not supported.', 'request_id': 'unknown', 'status': 'error'} + + @pytest.mark.asyncio + @patch("cpex.framework.isolated.worker.get_proper_config") + @patch("cpex.framework.isolated.worker.importlib.import_module") + @patch("cpex.framework.isolated.worker.PluginExecutor") + async def test_process_task_with_metadata(self, mock_executor_class, mock_import, mock_get_config, mock_plugin_dirs): + """Test processing task with metadata in context.""" + mock_config = MagicMock() + mock_get_config.return_value = mock_config + + mock_plugin_instance = AsyncMock() + mock_plugin_instance.initialize = AsyncMock() + mock_plugin_instance.tool_pre_invoke = AsyncMock() + mock_plugin_instance.tool_post_invoke = AsyncMock() + mock_plugin_instance.prompt_pre_fetch = AsyncMock() + mock_plugin_instance.prompt_post_fetch = AsyncMock() + mock_plugin_instance.tool_exception = AsyncMock() + mock_plugin_instance.tool_cleanup = AsyncMock() + + mock_plugin_class = MagicMock(return_value=mock_plugin_instance) + + mock_module = MagicMock() + mock_module.TestPlugin = mock_plugin_class + mock_import.return_value = mock_module + + mock_executor = MagicMock() + mock_result = MagicMock() + mock_executor.execute_plugin = AsyncMock(return_value=mock_result) + mock_executor_class.return_value = mock_executor + + config_dict = {"name": "test_plugin", "kind": "isolated_venv"} + task_data = { + "task_type": "load_and_run_hook", + "config": json.dumps(config_dict), + "class_name": "test_plugin.TestPlugin", + "plugin_dirs": mock_plugin_dirs, + "hook_type": "tool_pre_invoke", + "payload": {"name": "test_tool"}, + "context": { + "state": {"key": "value"}, + "global_context": {"request_id": "req-123", "user": "alice"}, + "metadata": {"custom": "data"}, + }, + } + tp = TaskProcessor() + + result = await process_task(task_data, tp) + + assert result is not None + # Verify executor was called with proper context + call_args = mock_executor.execute_plugin.call_args + assert call_args is not None + self.cleanup_mock_plugin_dirs() + + +class TestMainFunction: + """Test suite for the main() function.""" + + @pytest.mark.asyncio + @patch("sys.stdin") + @patch("builtins.print") + @patch("cpex.framework.isolated.worker.process_task") + async def test_main_success_with_info_task(self, mock_process_task, mock_print, mock_stdin): + """Test main function with successful info task.""" + # Setup stdin to return one task then EOF + task_data = {"task_type": "info", "request_id": "req-123"} + mock_stdin.readline.side_effect = [json.dumps(task_data) + "\n", ""] # EOF after first task + + # Setup process_task to return a mock result + mock_result = MagicMock() + mock_result.model_dump.return_value = { + "status": "success", + "environment": {"python_version": "3.10"}, + "message": "Environment info retrieved successfully", + } + mock_process_task.return_value = mock_result + + # Run main + await main() + + # Verify process_task was called with correct data + mock_process_task.assert_called_once() + call_args = mock_process_task.call_args[0][0] + assert call_args["task_type"] == "info" + assert call_args["request_id"] == "req-123" + + # Verify output was printed with request_id + mock_print.assert_called_once() + printed_output = mock_print.call_args[0][0] + output_data = json.loads(printed_output) + assert output_data["status"] == "success" + assert output_data["request_id"] == "req-123" + + @pytest.mark.asyncio + @patch("sys.stdin") + @patch("builtins.print") + @patch("cpex.framework.isolated.worker.process_task") + async def test_main_success_with_none_result(self, mock_process_task, mock_print, mock_stdin): + """Test main function when process_task returns None.""" + task_data = {"task_type": "unknown", "request_id": "req-456"} + mock_stdin.readline.side_effect = [json.dumps(task_data) + "\n", ""] + + # process_task returns None for unknown task types + mock_process_task.return_value = None + + await main() + + mock_process_task.assert_called_once() + mock_print.assert_called_once() + printed_output = mock_print.call_args[0][0] + output_data = json.loads(printed_output) + # Should have success status and request_id + assert output_data["status"] == "success" + assert output_data["request_id"] == "req-456" + + @pytest.mark.asyncio + @patch("sys.stdin") + @patch("builtins.print") + async def test_main_json_decode_error(self, mock_print, mock_stdin): + """Test main function with invalid JSON input.""" + # Setup stdin with invalid JSON then EOF + mock_stdin.readline.side_effect = ["not valid json {{", ""] + + await main() + + # Verify error response was printed + mock_print.assert_called() + printed_output = mock_print.call_args_list[0][0][0] + output_data = json.loads(printed_output) + assert output_data["status"] == "error" + assert "Invalid JSON input" in output_data["message"] + + @pytest.mark.asyncio + @patch("sys.stdin") + @patch("builtins.print") + @patch("cpex.framework.isolated.worker.process_task") + async def test_main_unexpected_exception(self, mock_process_task, mock_print, mock_stdin): + """Test main function with unexpected exception during processing.""" + task_data = {"task_type": "load_and_run_hook", "request_id": "req-789"} + mock_stdin.readline.side_effect = [json.dumps(task_data) + "\n", ""] + + # Make process_task raise an exception + mock_process_task.side_effect = RuntimeError("Unexpected error occurred") + + await main() + + # Verify error response was printed + mock_print.assert_called() + printed_output = mock_print.call_args_list[0][0][0] + output_data = json.loads(printed_output) + assert output_data["status"] == "error" + assert "Unexpected error: Unexpected error occurred" in output_data["message"] + assert output_data["request_id"] == "unknown" + + @pytest.mark.asyncio + @patch("sys.stdin") + @patch("builtins.print") + @patch("cpex.framework.isolated.worker.process_task") + async def test_main_with_load_and_run_hook_task(self, mock_process_task, mock_print, mock_stdin): + """Test main function with load_and_run_hook task.""" + config_dict = {"name": "test_plugin", "kind": "isolated_venv"} + task_data = { + "task_type": "load_and_run_hook", + "config": json.dumps(config_dict), + "class_name": "test_plugin.TestPlugin", + "hook_type": "tool_pre_invoke", + "payload": {"name": "test_tool"}, + "context": {"state": {}, "global_context": {}, "metadata": {}}, + "request_id": "req-abc", + } + mock_stdin.readline.side_effect = [json.dumps(task_data) + "\n", ""] + + # Setup mock result + mock_result = MagicMock() + mock_result.model_dump.return_value = { + "continue_processing": True, + "payload": {"name": "test_tool", "modified": True}, + "violations": [], + } + mock_process_task.return_value = mock_result + + await main() + + mock_process_task.assert_called_once() + mock_print.assert_called_once() + printed_output = mock_print.call_args[0][0] + output_data = json.loads(printed_output) + assert output_data["continue_processing"] is True + assert output_data["request_id"] == "req-abc" + + @pytest.mark.asyncio + @patch("sys.stdin") + @patch("builtins.print") + async def test_main_with_empty_line(self, mock_print, mock_stdin): + """Test main function with empty line (EOF).""" + mock_stdin.readline.return_value = "" + + await main() + + # Should exit gracefully without printing error + # (may not print anything if EOF is first thing read) + + @pytest.mark.asyncio + @patch("sys.stdin") + @patch("builtins.print") + @patch("cpex.framework.isolated.worker.process_task") + async def test_main_with_model_dump_exception(self, mock_process_task, mock_print, mock_stdin): + """Test main function when model_dump raises an exception.""" + task_data = {"task_type": "info", "request_id": "req-error"} + mock_stdin.readline.side_effect = [json.dumps(task_data) + "\n", ""] + + # Setup mock result that raises exception on model_dump + mock_result = MagicMock() + mock_result.model_dump.side_effect = ValueError("Cannot serialize") + mock_process_task.return_value = mock_result + + await main() + + # Should catch the exception and return error + mock_print.assert_called() + printed_output = mock_print.call_args_list[0][0][0] + output_data = json.loads(printed_output) + assert output_data["status"] == "error" + assert "Unexpected error" in output_data["message"] + + @pytest.mark.asyncio + @patch("sys.stdin") + @patch("builtins.print") + async def test_main_with_shutdown_signal(self, mock_print, mock_stdin): + """Test main function with shutdown signal.""" + task_data = {"task_type": "shutdown", "request_id": "shutdown"} + mock_stdin.readline.side_effect = [json.dumps(task_data) + "\n", ""] + + await main() + + # Should print shutdown response and exit + mock_print.assert_called_once() + printed_output = mock_print.call_args[0][0] + output_data = json.loads(printed_output) + assert output_data["status"] == "success" + assert output_data["message"] == "Shutting down" + assert output_data["request_id"] == "shutdown" + + @pytest.mark.asyncio + @patch("sys.stdin") + @patch("builtins.print") + @patch("cpex.framework.isolated.worker.process_task") + async def test_main_multiple_tasks(self, mock_process_task, mock_print, mock_stdin): + """Test main function processing multiple tasks.""" + task1 = {"task_type": "info", "request_id": "req-1"} + task2 = {"task_type": "info", "request_id": "req-2"} + mock_stdin.readline.side_effect = [ + json.dumps(task1) + "\n", + json.dumps(task2) + "\n", + "" # EOF + ] + + mock_result = MagicMock() + mock_result.model_dump.return_value = {"status": "success"} + mock_process_task.return_value = mock_result + + await main() + + # Should process both tasks + assert mock_process_task.call_count == 2 + assert mock_print.call_count == 2 + + +# Made with Bob diff --git a/tests/unit/cpex/framework/test_models_package_version.py b/tests/unit/cpex/framework/test_models_package_version.py new file mode 100644 index 00000000..0429127c --- /dev/null +++ b/tests/unit/cpex/framework/test_models_package_version.py @@ -0,0 +1,435 @@ +# -*- coding: utf-8 -*- +"""Additional unit tests for PluginPackageInfo and PluginVersionRegistry in cpex.framework.models. + +This module provides additional test coverage for edge cases and scenarios +not covered in the main test_plugin_models.py file. +""" + +# Third-Party +import pytest + +# First-Party +from cpex.framework.models import PluginPackageInfo, PluginVersionInfo, PluginVersionRegistry + + +class TestPluginPackageInfoEdgeCases: + """Additional edge case tests for PluginPackageInfo.""" + + def test_pypi_package_single_character(self): + """Single character PyPI package names should be valid.""" + pkg = PluginPackageInfo(pypi_package="a") + assert pkg.pypi_package == "a" + + def test_pypi_package_two_characters(self): + """Two character PyPI package names should be valid.""" + pkg = PluginPackageInfo(pypi_package="ab") + assert pkg.pypi_package == "ab" + + def test_pypi_package_max_length(self): + """PyPI package name at exactly 214 characters should be valid.""" + max_name = "a" * 214 + pkg = PluginPackageInfo(pypi_package=max_name) + assert pkg.pypi_package == max_name + assert len(pkg.pypi_package) == 214 + + def test_pypi_package_with_numbers_only(self): + """PyPI package names with only numbers should be valid.""" + pkg = PluginPackageInfo(pypi_package="123") + assert pkg.pypi_package == "123" + + def test_pypi_package_mixed_separators(self): + """PyPI package names with mixed valid separators should be valid.""" + pkg = PluginPackageInfo(pypi_package="my-package_name.version") + assert pkg.pypi_package == "my-package_name.version" + + def test_git_repository_without_git_extension(self): + """Git repository URLs without .git extension should be valid.""" + pkg = PluginPackageInfo(git_repository="https://github.com/user/repo") + assert pkg.git_repository == "https://github.com/user/repo" + + def test_git_repository_with_subdirectories(self): + """Git repository URLs with subdirectories should be valid.""" + pkg = PluginPackageInfo(git_repository="https://github.com/org/team/repo.git") + assert pkg.git_repository == "https://github.com/org/team/repo.git" + + def test_git_repository_ssh_with_port(self): + """SSH Git URLs with custom ports are not supported by the current validator.""" + # The current regex doesn't support ssh:// protocol with ports + with pytest.raises(ValueError, match="Invalid Git repository URL"): + PluginPackageInfo(git_repository="ssh://git@github.com:2222/user/repo.git") + + def test_git_branch_single_character(self): + """Single character branch names should be valid.""" + pkg = PluginPackageInfo( + git_repository="https://github.com/user/repo.git", + git_branch_tag_commit="v" + ) + assert pkg.git_branch_tag_commit == "v" + + def test_git_branch_with_multiple_slashes(self): + """Branch names with multiple slashes should be valid.""" + pkg = PluginPackageInfo( + git_repository="https://github.com/user/repo.git", + git_branch_tag_commit="feature/sub/branch" + ) + assert pkg.git_branch_tag_commit == "feature/sub/branch" + + def test_git_commit_short_hash(self): + """Short commit hashes (7 characters) should be valid.""" + pkg = PluginPackageInfo( + git_repository="https://github.com/user/repo.git", + git_branch_tag_commit="abc1234" + ) + assert pkg.git_branch_tag_commit == "abc1234" + + def test_git_commit_full_hash(self): + """Full commit hashes (40 characters) should be valid.""" + full_hash = "a" * 40 + pkg = PluginPackageInfo( + git_repository="https://github.com/user/repo.git", + git_branch_tag_commit=full_hash + ) + assert pkg.git_branch_tag_commit == full_hash + + def test_version_constraint_with_spaces(self): + """Version constraints with spaces around operators should be valid.""" + pkg = PluginPackageInfo( + pypi_package="my-package", + version_constraint=">= 1.0.0, < 2.0.0" + ) + assert pkg.version_constraint == ">= 1.0.0, < 2.0.0" + + def test_version_constraint_triple_equals(self): + """Version constraints with === operator should be valid.""" + pkg = PluginPackageInfo( + pypi_package="my-package", + version_constraint="===1.0.0" + ) + assert pkg.version_constraint == "===1.0.0" + + def test_version_constraint_with_local_version(self): + """Version constraints with local version identifiers are not supported by current validator.""" + # The current regex doesn't support + in version constraints + with pytest.raises(ValueError, match="Invalid version constraint"): + PluginPackageInfo( + pypi_package="my-package", + version_constraint="==1.0.0+local.version" + ) + + def test_both_installation_methods_with_all_fields(self): + """Both installation methods with all optional fields should be valid.""" + pkg = PluginPackageInfo( + pypi_package="my-package", + git_repository="https://github.com/user/repo.git", + git_branch_tag_commit="v1.0.0", + version_constraint=">=1.0.0,<2.0.0" + ) + assert pkg.pypi_package == "my-package" + assert pkg.git_repository == "https://github.com/user/repo.git" + assert pkg.git_branch_tag_commit == "v1.0.0" + assert pkg.version_constraint == ">=1.0.0,<2.0.0" + + +class TestPluginVersionInfoEdgeCases: + """Additional edge case tests for PluginVersionInfo.""" + + def test_version_info_minimal_fields(self): + """PluginVersionInfo with only required fields should be valid.""" + info = PluginVersionInfo( + version="1.0.0", + released="2024-01-01", + manifest_file="manifest.json" + ) + assert info.version == "1.0.0" + assert info.released == "2024-01-01" + assert info.manifest_file == "manifest.json" + assert info.breaking_changes is None + assert info.deprecated is False + assert info.changelog is None + + def test_version_info_all_fields(self): + """PluginVersionInfo with all fields should be valid.""" + info = PluginVersionInfo( + version="2.0.0", + released="2024-02-01", + breaking_changes=True, + deprecated=True, + manifest_file="manifest.json", + changelog="Major update with breaking changes", + min_max_framework_version="0.2.0,0.3.0" + ) + assert info.version == "2.0.0" + assert info.breaking_changes is True + assert info.deprecated is True + assert info.changelog == "Major update with breaking changes" + assert info.min_max_framework_version == "0.2.0,0.3.0" + + def test_version_info_prerelease_version(self): + """PluginVersionInfo with pre-release version should be valid.""" + info = PluginVersionInfo( + version="1.0.0-alpha.1", + released="2024-01-01", + manifest_file="manifest.json" + ) + assert info.version == "1.0.0-alpha.1" + + def test_version_info_dev_version(self): + """PluginVersionInfo with dev version should be valid.""" + info = PluginVersionInfo( + version="1.0.0.dev1", + released="2024-01-01", + manifest_file="manifest.json", + min_max_framework_version="0.1.0.dev1,0.1.0.dev10" + ) + assert info.version == "1.0.0.dev1" + + +class TestPluginVersionRegistryEdgeCases: + """Additional edge case tests for PluginVersionRegistry.""" + + def test_registry_with_only_prerelease(self): + """Registry with only pre-release versions should work correctly.""" + v1 = PluginVersionInfo( + version="1.0.0-alpha", + released="2024-01-01", + manifest_file="manifest.json", + min_max_framework_version="0.1.0,0.2.0" + ) + + registry = PluginVersionRegistry( + latest=None, + latest_prerelease=v1, + versions=[v1] + ) + + assert registry.get_version() is None + assert registry.latest_prerelease == v1 + + def test_registry_with_both_latest_and_prerelease(self): + """Registry with both latest and latest_prerelease should maintain both.""" + v1 = PluginVersionInfo( + version="1.0.0", + released="2024-01-01", + manifest_file="manifest.json", + min_max_framework_version="0.1.0,0.2.0" + ) + v2 = PluginVersionInfo( + version="1.1.0-beta", + released="2024-02-01", + manifest_file="manifest.json", + min_max_framework_version="0.1.0,0.2.0" + ) + + registry = PluginVersionRegistry( + latest=v1, + latest_prerelease=v2, + versions=[v1, v2] + ) + + assert registry.get_version() == v1 + assert registry.latest_prerelease == v2 + + def test_get_latest_compatible_with_single_version_in_range(self): + """get_latest_compatible with only one version in range should return it.""" + v1 = PluginVersionInfo( + version="1.0.0", + released="2024-01-01", + manifest_file="manifest.json", + min_max_framework_version="0.1.0,0.2.0" + ) + + registry = PluginVersionRegistry( + latest=v1, + versions=[v1] + ) + + result = registry.get_latest_compatible("0.1.5") + assert result == v1 + + def test_get_latest_compatible_with_overlapping_ranges(self): + """get_latest_compatible with overlapping version ranges should return latest.""" + v1 = PluginVersionInfo( + version="1.0.0", + released="2024-01-01", + manifest_file="manifest.json", + min_max_framework_version="0.1.0,0.3.0" + ) + v2 = PluginVersionInfo( + version="1.5.0", + released="2024-02-01", + manifest_file="manifest.json", + min_max_framework_version="0.2.0,0.4.0" + ) + v3 = PluginVersionInfo( + version="2.0.0", + released="2024-03-01", + manifest_file="manifest.json", + min_max_framework_version="0.2.5,0.5.0" + ) + + registry = PluginVersionRegistry( + latest=v3, + versions=[v1, v2, v3] + ) + + # Framework 0.2.7 matches v1, v2, and v3 - should return v3 (latest) + result = registry.get_latest_compatible("0.2.7") + assert result == v3 + assert result.version == "2.0.0" + + def test_get_latest_compatible_with_non_overlapping_ranges(self): + """get_latest_compatible with non-overlapping ranges should return correct version.""" + v1 = PluginVersionInfo( + version="1.0.0", + released="2024-01-01", + manifest_file="manifest.json", + min_max_framework_version="0.1.0,0.2.0" + ) + v2 = PluginVersionInfo( + version="2.0.0", + released="2024-02-01", + manifest_file="manifest.json", + min_max_framework_version="0.3.0,0.4.0" + ) + + registry = PluginVersionRegistry( + latest=v2, + versions=[v1, v2] + ) + + # Framework 0.1.5 should match v1 + result = registry.get_latest_compatible("0.1.5") + assert result == v1 + + # Framework 0.3.5 should match v2 + result = registry.get_latest_compatible("0.3.5") + assert result == v2 + + # Framework 0.2.5 should match neither + result = registry.get_latest_compatible("0.2.5") + assert result is None + + def test_get_latest_compatible_with_malformed_version_in_list(self): + """get_latest_compatible should handle malformed versions in the list gracefully.""" + v1 = PluginVersionInfo( + version="not-a-version", + released="2024-01-01", + manifest_file="manifest.json", + min_max_framework_version="0.1.0,0.2.0" + ) + v2 = PluginVersionInfo( + version="1.0.0", + released="2024-02-01", + manifest_file="manifest.json", + min_max_framework_version="0.1.0,0.2.0" + ) + + registry = PluginVersionRegistry( + latest=v2, + versions=[v1, v2] + ) + + # Should still find v2 even though v1 has invalid version + result = registry.get_latest_compatible("0.1.5") + # If sorting fails, it returns the first compatible version + assert result in [v1, v2] + + def test_get_latest_compatible_with_extra_whitespace_in_min_max(self): + """get_latest_compatible should handle extra whitespace in min_max_framework_version.""" + v1 = PluginVersionInfo( + version="1.0.0", + released="2024-01-01", + manifest_file="manifest.json", + min_max_framework_version=" 0.1.0 , 0.2.0 " + ) + + registry = PluginVersionRegistry( + latest=v1, + versions=[v1] + ) + + result = registry.get_latest_compatible("0.1.5") + assert result == v1 + + def test_get_latest_compatible_with_three_part_min_max(self): + """get_latest_compatible should reject min_max with more than 2 parts.""" + v1 = PluginVersionInfo( + version="1.0.0", + released="2024-01-01", + manifest_file="manifest.json", + min_max_framework_version="0.1.0,0.2.0,0.3.0" # Invalid: 3 parts + ) + + registry = PluginVersionRegistry( + latest=v1, + versions=[v1] + ) + + result = registry.get_latest_compatible("0.1.5") + assert result is None + + def test_get_latest_compatible_with_reversed_min_max(self): + """get_latest_compatible should handle reversed min/max (max < min).""" + v1 = PluginVersionInfo( + version="1.0.0", + released="2024-01-01", + manifest_file="manifest.json", + min_max_framework_version="0.2.0,0.1.0" # Reversed + ) + + registry = PluginVersionRegistry( + latest=v1, + versions=[v1] + ) + + # No version should match since max < min + result = registry.get_latest_compatible("0.1.5") + assert result is None + + def test_registry_versions_list_order_independence(self): + """Registry should work correctly regardless of versions list order.""" + v1 = PluginVersionInfo( + version="1.0.0", + released="2024-01-01", + manifest_file="manifest.json", + min_max_framework_version="0.1.0,0.2.0" + ) + v2 = PluginVersionInfo( + version="2.0.0", + released="2024-02-01", + manifest_file="manifest.json", + min_max_framework_version="0.1.0,0.2.0" + ) + v3 = PluginVersionInfo( + version="1.5.0", + released="2024-01-15", + manifest_file="manifest.json", + min_max_framework_version="0.1.0,0.2.0" + ) + + # Test with different orderings + registry1 = PluginVersionRegistry( + latest=v2, + versions=[v1, v2, v3] + ) + + registry2 = PluginVersionRegistry( + latest=v2, + versions=[v3, v1, v2] + ) + + registry3 = PluginVersionRegistry( + latest=v2, + versions=[v2, v3, v1] + ) + + # All should return v2 as the latest compatible + result1 = registry1.get_latest_compatible("0.1.5") + result2 = registry2.get_latest_compatible("0.1.5") + result3 = registry3.get_latest_compatible("0.1.5") + + assert result1 == v2 + assert result2 == v2 + assert result3 == v2 + +# Made with Bob diff --git a/tests/unit/cpex/framework/test_plugin_models.py b/tests/unit/cpex/framework/test_plugin_models.py index 01454db6..c5d3da82 100644 --- a/tests/unit/cpex/framework/test_plugin_models.py +++ b/tests/unit/cpex/framework/test_plugin_models.py @@ -197,3 +197,736 @@ def test_plugin_config_external_config_disallowed(): mcp = MCPClientConfig(proto=TransportType.SSE, url="https://example.com") with pytest.raises(ValueError): PluginConfig(name="external", kind=EXTERNAL_PLUGIN_TYPE, config={"x": 1}, mcp=mcp) + + +# ============================================================================= +# PluginPackageInfo Validator Tests +# ============================================================================= + + +class TestPluginPackageInfoValidators: + """Tests for PluginPackageInfo field validators.""" + + # ------------------------------------------------------------------------- + # PyPI Package Validator Tests + # ------------------------------------------------------------------------- + + def test_pypi_package_valid(self): + """Valid PyPI package names should be accepted.""" + from cpex.framework.models import PluginPackageInfo + + # Standard package names + pkg = PluginPackageInfo(pypi_package="my-package") + assert pkg.pypi_package == "my-package" + + pkg = PluginPackageInfo(pypi_package="my_package") + assert pkg.pypi_package == "my_package" + + pkg = PluginPackageInfo(pypi_package="my.package") + assert pkg.pypi_package == "my.package" + + pkg = PluginPackageInfo(pypi_package="MyPackage123") + assert pkg.pypi_package == "MyPackage123" + + # Complex valid names + pkg = PluginPackageInfo(pypi_package="apex-pii-filter") + assert pkg.pypi_package == "apex-pii-filter" + + pkg = PluginPackageInfo(pypi_package="package_name.with-everything123") + assert pkg.pypi_package == "package_name.with-everything123" + + def test_pypi_package_invalid_empty(self): + """Empty or whitespace-only PyPI package names should be rejected.""" + from cpex.framework.models import PluginPackageInfo + + # Empty string is treated as None, so model validator catches it + with pytest.raises(ValueError, match="At least one installation method"): + PluginPackageInfo(pypi_package="") + + with pytest.raises(ValueError, match="cannot be empty or whitespace"): + PluginPackageInfo(pypi_package=" ") + + def test_pypi_package_invalid_start_end(self): + """PyPI package names starting/ending with invalid characters should be rejected.""" + from cpex.framework.models import PluginPackageInfo + + with pytest.raises(ValueError, match="Invalid PyPI package name"): + PluginPackageInfo(pypi_package="-invalid") + + with pytest.raises(ValueError, match="Invalid PyPI package name"): + PluginPackageInfo(pypi_package="invalid-") + + with pytest.raises(ValueError, match="Invalid PyPI package name"): + PluginPackageInfo(pypi_package=".invalid") + + with pytest.raises(ValueError, match="Invalid PyPI package name"): + PluginPackageInfo(pypi_package="invalid.") + + def test_pypi_package_invalid_characters(self): + """PyPI package names with invalid characters should be rejected.""" + from cpex.framework.models import PluginPackageInfo + + with pytest.raises(ValueError, match="Invalid PyPI package name"): + PluginPackageInfo(pypi_package="my package") + + with pytest.raises(ValueError, match="Invalid PyPI package name"): + PluginPackageInfo(pypi_package="my@package") + + with pytest.raises(ValueError, match="Invalid PyPI package name"): + PluginPackageInfo(pypi_package="my/package") + + def test_pypi_package_too_long(self): + """PyPI package names exceeding 214 characters should be rejected.""" + from cpex.framework.models import PluginPackageInfo + + long_name = "a" * 215 + with pytest.raises(ValueError, match="exceeds maximum length of 214 characters"): + PluginPackageInfo(pypi_package=long_name) + + def test_pypi_package_none_allowed(self): + """None should be allowed for pypi_package when git_repository is provided.""" + from cpex.framework.models import PluginPackageInfo + + pkg = PluginPackageInfo(git_repository="https://github.com/user/repo.git") + assert pkg.pypi_package is None + + # ------------------------------------------------------------------------- + # Git Repository Validator Tests + # ------------------------------------------------------------------------- + + def test_git_repository_valid_https(self): + """Valid HTTPS Git repository URLs should be accepted.""" + from cpex.framework.models import PluginPackageInfo + + pkg = PluginPackageInfo(git_repository="https://github.com/user/repo.git") + assert pkg.git_repository == "https://github.com/user/repo.git" + + pkg = PluginPackageInfo(git_repository="https://gitlab.com/user/repo.git") + assert pkg.git_repository == "https://gitlab.com/user/repo.git" + + pkg = PluginPackageInfo(git_repository="https://github.com/user/repo") + assert pkg.git_repository == "https://github.com/user/repo" + + def test_git_repository_valid_http(self): + """Valid HTTP Git repository URLs should be accepted.""" + from cpex.framework.models import PluginPackageInfo + + pkg = PluginPackageInfo(git_repository="http://example.com/user/repo.git") + assert pkg.git_repository == "http://example.com/user/repo.git" + + def test_git_repository_valid_git_protocol(self): + """Valid git:// protocol URLs should be accepted.""" + from cpex.framework.models import PluginPackageInfo + + pkg = PluginPackageInfo(git_repository="git://github.com/user/repo.git") + assert pkg.git_repository == "git://github.com/user/repo.git" + + def test_git_repository_valid_ssh(self): + """Valid SSH Git repository URLs should be accepted.""" + from cpex.framework.models import PluginPackageInfo + + pkg = PluginPackageInfo(git_repository="git@github.com:user/repo.git") + assert pkg.git_repository == "git@github.com:user/repo.git" + + def test_git_repository_invalid_empty(self): + """Empty or whitespace-only Git repository URLs should be rejected.""" + from cpex.framework.models import PluginPackageInfo + + # Empty string is treated as None, so model validator catches it + with pytest.raises(ValueError, match="At least one installation method"): + PluginPackageInfo(git_repository="") + + with pytest.raises(ValueError, match="cannot be empty or whitespace"): + PluginPackageInfo(git_repository=" ") + + def test_git_repository_invalid_format(self): + """Invalid Git repository URL formats should be rejected.""" + from cpex.framework.models import PluginPackageInfo + + with pytest.raises(ValueError, match="Invalid Git repository URL"): + PluginPackageInfo(git_repository="not-a-valid-url") + + with pytest.raises(ValueError, match="Invalid Git repository URL"): + PluginPackageInfo(git_repository="ftp://example.com/repo.git") + + def test_git_repository_none_allowed(self): + """None should be allowed for git_repository when pypi_package is provided.""" + from cpex.framework.models import PluginPackageInfo + + pkg = PluginPackageInfo(pypi_package="my-package") + assert pkg.git_repository is None + + # ------------------------------------------------------------------------- + # Git Branch/Tag/Commit Validator Tests + # ------------------------------------------------------------------------- + + def test_git_branch_tag_commit_valid(self): + """Valid Git branch/tag/commit references should be accepted.""" + from cpex.framework.models import PluginPackageInfo + + # Branch names + pkg = PluginPackageInfo( + git_repository="https://github.com/user/repo.git", + git_branch_tag_commit="main" + ) + assert pkg.git_branch_tag_commit == "main" + + pkg = PluginPackageInfo( + git_repository="https://github.com/user/repo.git", + git_branch_tag_commit="feature/new-feature" + ) + assert pkg.git_branch_tag_commit == "feature/new-feature" + + # Tag names + pkg = PluginPackageInfo( + git_repository="https://github.com/user/repo.git", + git_branch_tag_commit="v1.0.0" + ) + assert pkg.git_branch_tag_commit == "v1.0.0" + + # Commit hashes + pkg = PluginPackageInfo( + git_repository="https://github.com/user/repo.git", + git_branch_tag_commit="abc123def456" + ) + assert pkg.git_branch_tag_commit == "abc123def456" + + pkg = PluginPackageInfo( + git_repository="https://github.com/user/repo.git", + git_branch_tag_commit="a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0" + ) + assert pkg.git_branch_tag_commit == "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0" + + def test_git_branch_tag_commit_invalid_empty(self): + """Empty or whitespace-only Git references should be rejected.""" + from cpex.framework.models import PluginPackageInfo + + # Empty string is treated as None, which is valid + pkg = PluginPackageInfo( + git_repository="https://github.com/user/repo.git", + git_branch_tag_commit="" + ) + assert pkg.git_branch_tag_commit is None + + with pytest.raises(ValueError, match="cannot be empty or whitespace"): + PluginPackageInfo( + git_repository="https://github.com/user/repo.git", + git_branch_tag_commit=" " + ) + + def test_git_branch_tag_commit_invalid_characters(self): + """Git references with invalid characters should be rejected.""" + from cpex.framework.models import PluginPackageInfo + + with pytest.raises(ValueError, match="Invalid Git branch/tag/commit"): + PluginPackageInfo( + git_repository="https://github.com/user/repo.git", + git_branch_tag_commit="branch with spaces" + ) + + with pytest.raises(ValueError, match="Invalid Git branch/tag/commit"): + PluginPackageInfo( + git_repository="https://github.com/user/repo.git", + git_branch_tag_commit="branch@invalid" + ) + + def test_git_branch_tag_commit_invalid_start_end(self): + """Git references with invalid start/end characters should be rejected.""" + from cpex.framework.models import PluginPackageInfo + + with pytest.raises(ValueError, match="Cannot start with"): + PluginPackageInfo( + git_repository="https://github.com/user/repo.git", + git_branch_tag_commit="/invalid" + ) + + with pytest.raises(ValueError, match="Cannot start with"): + PluginPackageInfo( + git_repository="https://github.com/user/repo.git", + git_branch_tag_commit=".invalid" + ) + + with pytest.raises(ValueError, match="Cannot start with"): + PluginPackageInfo( + git_repository="https://github.com/user/repo.git", + git_branch_tag_commit="-invalid" + ) + + with pytest.raises(ValueError, match="end with"): + PluginPackageInfo( + git_repository="https://github.com/user/repo.git", + git_branch_tag_commit="invalid/" + ) + + with pytest.raises(ValueError, match="end with"): + PluginPackageInfo( + git_repository="https://github.com/user/repo.git", + git_branch_tag_commit="invalid." + ) + + def test_git_branch_tag_commit_too_long(self): + """Git references exceeding 255 characters should be rejected.""" + from cpex.framework.models import PluginPackageInfo + + long_ref = "a" * 256 + with pytest.raises(ValueError, match="exceeds maximum length of 255 characters"): + PluginPackageInfo( + git_repository="https://github.com/user/repo.git", + git_branch_tag_commit=long_ref + ) + + def test_git_branch_tag_commit_none_allowed(self): + """None should be allowed for git_branch_tag_commit.""" + from cpex.framework.models import PluginPackageInfo + + pkg = PluginPackageInfo(git_repository="https://github.com/user/repo.git") + assert pkg.git_branch_tag_commit is None + + # ------------------------------------------------------------------------- + # Version Constraint Validator Tests + # ------------------------------------------------------------------------- + + def test_version_constraint_valid_single(self): + """Valid single version constraints should be accepted.""" + from cpex.framework.models import PluginPackageInfo + + pkg = PluginPackageInfo(pypi_package="my-package", version_constraint=">=1.0.0") + assert pkg.version_constraint == ">=1.0.0" + + pkg = PluginPackageInfo(pypi_package="my-package", version_constraint="==1.2.3") + assert pkg.version_constraint == "==1.2.3" + + pkg = PluginPackageInfo(pypi_package="my-package", version_constraint="~=1.2.3") + assert pkg.version_constraint == "~=1.2.3" + + pkg = PluginPackageInfo(pypi_package="my-package", version_constraint="<2.0.0") + assert pkg.version_constraint == "<2.0.0" + + def test_version_constraint_valid_multiple(self): + """Valid multiple version constraints should be accepted.""" + from cpex.framework.models import PluginPackageInfo + + pkg = PluginPackageInfo( + pypi_package="my-package", + version_constraint=">=1.0.0,<2.0.0" + ) + assert pkg.version_constraint == ">=1.0.0,<2.0.0" + + pkg = PluginPackageInfo( + pypi_package="my-package", + version_constraint=">=1.0.0, <2.0.0, !=1.5.0" + ) + assert pkg.version_constraint == ">=1.0.0, <2.0.0, !=1.5.0" + + def test_version_constraint_valid_with_prerelease(self): + """Version constraints with pre-release identifiers should be accepted.""" + from cpex.framework.models import PluginPackageInfo + + pkg = PluginPackageInfo( + pypi_package="my-package", + version_constraint=">=1.0.0-alpha" + ) + assert pkg.version_constraint == ">=1.0.0-alpha" + + pkg = PluginPackageInfo( + pypi_package="my-package", + version_constraint="==1.0.0rc1" + ) + assert pkg.version_constraint == "==1.0.0rc1" + + def test_version_constraint_invalid_empty(self): + """Empty or whitespace-only version constraints should be rejected.""" + from cpex.framework.models import PluginPackageInfo + + # Empty string is treated as None, which is valid + pkg = PluginPackageInfo(pypi_package="my-package", version_constraint="") + assert pkg.version_constraint is None + + with pytest.raises(ValueError, match="cannot be empty or whitespace"): + PluginPackageInfo(pypi_package="my-package", version_constraint=" ") + + def test_version_constraint_invalid_format(self): + """Invalid version constraint formats should be rejected.""" + from cpex.framework.models import PluginPackageInfo + + with pytest.raises(ValueError, match="Invalid version constraint"): + PluginPackageInfo(pypi_package="my-package", version_constraint="invalid") + + with pytest.raises(ValueError, match="Invalid version constraint"): + PluginPackageInfo(pypi_package="my-package", version_constraint="1.0.0") + + def test_version_constraint_invalid_empty_parts(self): + """Version constraints with empty parts should be rejected.""" + from cpex.framework.models import PluginPackageInfo + + with pytest.raises(ValueError, match="cannot contain empty parts"): + PluginPackageInfo( + pypi_package="my-package", + version_constraint=">=1.0.0,," + ) + + def test_version_constraint_too_long(self): + """Version constraints exceeding 255 characters should be rejected.""" + from cpex.framework.models import PluginPackageInfo + + long_constraint = ">=1.0.0," + ",".join([f"!={i}.0.0" for i in range(100)]) + with pytest.raises(ValueError, match="exceeds maximum length of 255 characters"): + PluginPackageInfo(pypi_package="my-package", version_constraint=long_constraint) + + def test_version_constraint_none_allowed(self): + """None should be allowed for version_constraint.""" + from cpex.framework.models import PluginPackageInfo + + pkg = PluginPackageInfo(pypi_package="my-package") + assert pkg.version_constraint is None + + # ------------------------------------------------------------------------- + # Model Validator Tests + # ------------------------------------------------------------------------- + + def test_installation_method_required(self): + """At least one installation method must be specified.""" + from cpex.framework.models import PluginPackageInfo + + with pytest.raises(ValueError, match="At least one installation method must be specified"): + PluginPackageInfo() + + def test_installation_method_pypi_only(self): + """PyPI package alone should be valid.""" + from cpex.framework.models import PluginPackageInfo + + pkg = PluginPackageInfo(pypi_package="my-package") + assert pkg.pypi_package == "my-package" + assert pkg.git_repository is None + + def test_installation_method_git_only(self): + """Git repository alone should be valid.""" + from cpex.framework.models import PluginPackageInfo + + pkg = PluginPackageInfo(git_repository="https://github.com/user/repo.git") + assert pkg.git_repository == "https://github.com/user/repo.git" + assert pkg.pypi_package is None + + def test_installation_method_both_allowed(self): + """Both PyPI package and Git repository can be specified.""" + from cpex.framework.models import PluginPackageInfo + + pkg = PluginPackageInfo( + pypi_package="my-package", + git_repository="https://github.com/user/repo.git" + ) + assert pkg.pypi_package == "my-package" + assert pkg.git_repository == "https://github.com/user/repo.git" + + def test_git_branch_requires_repository(self): + """git_branch_tag_commit requires git_repository.""" + from cpex.framework.models import PluginPackageInfo + + with pytest.raises(ValueError, match="can only be specified when 'git_repository' is provided"): + PluginPackageInfo( + pypi_package="my-package", + git_branch_tag_commit="main" + ) + + def test_complete_git_installation(self): + """Complete Git installation with all fields should be valid.""" + from cpex.framework.models import PluginPackageInfo + + pkg = PluginPackageInfo( + git_repository="https://github.com/user/repo.git", + git_branch_tag_commit="v1.0.0", + version_constraint=">=1.0.0" + ) + assert pkg.git_repository == "https://github.com/user/repo.git" + assert pkg.git_branch_tag_commit == "v1.0.0" + assert pkg.version_constraint == ">=1.0.0" + + def test_complete_pypi_installation(self): + """Complete PyPI installation with version constraint should be valid.""" + from cpex.framework.models import PluginPackageInfo + + pkg = PluginPackageInfo( + pypi_package="my-package", + version_constraint=">=1.0.0,<2.0.0" + ) + assert pkg.pypi_package == "my-package" + assert pkg.version_constraint == ">=1.0.0,<2.0.0" + + +# ============================================================================= +# PluginVersionRegistry Tests +# ============================================================================= + + +class TestPluginVersionRegistry: + """Tests for PluginVersionRegistry class.""" + + def test_get_version_returns_latest(self): + """get_version should return the latest version.""" + from cpex.framework.models import PluginVersionInfo, PluginVersionRegistry + + latest_version = PluginVersionInfo( + version="2.0.0", + released="2024-01-01", + manifest_file="manifest.json", + min_max_framework_version="0.1.0,0.2.0" + ) + + registry = PluginVersionRegistry( + latest=latest_version, + versions=[latest_version] + ) + + assert registry.get_version() == latest_version + + def test_get_version_returns_none_when_no_latest(self): + """get_version should return None when latest is not set.""" + from cpex.framework.models import PluginVersionInfo, PluginVersionRegistry + + version = PluginVersionInfo( + version="1.0.0", + released="2024-01-01", + manifest_file="manifest.json" + ) + + registry = PluginVersionRegistry( + latest=None, + versions=[version] + ) + + assert registry.get_version() is None + + def test_get_latest_compatible_finds_compatible_version(self): + """get_latest_compatible should find a version compatible with the framework version.""" + from cpex.framework.models import PluginVersionInfo, PluginVersionRegistry + + v1 = PluginVersionInfo( + version="1.0.0", + released="2024-01-01", + manifest_file="manifest.json", + min_max_framework_version="0.1.0,0.1.5" + ) + v2 = PluginVersionInfo( + version="2.0.0", + released="2024-02-01", + manifest_file="manifest.json", + min_max_framework_version="0.1.5,0.2.0" + ) + + registry = PluginVersionRegistry( + latest=v2, + versions=[v1, v2] + ) + + # Framework version 0.1.3 should match v1 + result = registry.get_latest_compatible("0.1.3") + assert result == v1 + + # Framework version 0.1.8 should match v2 + result = registry.get_latest_compatible("0.1.8") + assert result == v2 + + def test_get_latest_compatible_returns_latest_when_multiple_match(self): + """get_latest_compatible should return the latest version when multiple versions match.""" + from cpex.framework.models import PluginVersionInfo, PluginVersionRegistry + + v1 = PluginVersionInfo( + version="1.0.0", + released="2024-01-01", + manifest_file="manifest.json", + min_max_framework_version="0.1.0,0.2.0" + ) + v2 = PluginVersionInfo( + version="1.5.0", + released="2024-02-01", + manifest_file="manifest.json", + min_max_framework_version="0.1.0,0.2.0" + ) + v3 = PluginVersionInfo( + version="2.0.0", + released="2024-03-01", + manifest_file="manifest.json", + min_max_framework_version="0.1.0,0.2.0" + ) + + registry = PluginVersionRegistry( + latest=v3, + versions=[v1, v2, v3] + ) + + # All versions support 0.1.5, should return the latest (v3) + result = registry.get_latest_compatible("0.1.5") + assert result == v3 + assert result.version == "2.0.0" + + def test_get_latest_compatible_returns_none_when_no_match(self): + """get_latest_compatible should return None when no version is compatible.""" + from cpex.framework.models import PluginVersionInfo, PluginVersionRegistry + + v1 = PluginVersionInfo( + version="1.0.0", + released="2024-01-01", + manifest_file="manifest.json", + min_max_framework_version="0.1.0,0.1.5" + ) + + registry = PluginVersionRegistry( + latest=v1, + versions=[v1] + ) + + # Framework version 0.2.0 is outside the range + result = registry.get_latest_compatible("0.2.0") + assert result is None + + def test_get_latest_compatible_handles_invalid_framework_version(self): + """get_latest_compatible should return None for invalid framework version.""" + from cpex.framework.models import PluginVersionInfo, PluginVersionRegistry + + v1 = PluginVersionInfo( + version="1.0.0", + released="2024-01-01", + manifest_file="manifest.json", + min_max_framework_version="0.1.0,0.2.0" + ) + + registry = PluginVersionRegistry( + latest=v1, + versions=[v1] + ) + + # Invalid version format + result = registry.get_latest_compatible("not-a-version") + assert result is None + + def test_get_latest_compatible_skips_versions_without_min_max(self): + """get_latest_compatible should skip versions without min_max_framework_version.""" + from cpex.framework.models import PluginVersionInfo, PluginVersionRegistry + + v1 = PluginVersionInfo( + version="1.0.0", + released="2024-01-01", + manifest_file="manifest.json", + min_max_framework_version=None + ) + v2 = PluginVersionInfo( + version="2.0.0", + released="2024-02-01", + manifest_file="manifest.json", + min_max_framework_version="0.1.0,0.2.0" + ) + + registry = PluginVersionRegistry( + latest=v2, + versions=[v1, v2] + ) + + # Should only find v2 since v1 has no min_max_framework_version + result = registry.get_latest_compatible("0.1.5") + assert result == v2 + + def test_get_latest_compatible_handles_malformed_min_max(self): + """get_latest_compatible should skip versions with malformed min_max_framework_version.""" + from cpex.framework.models import PluginVersionInfo, PluginVersionRegistry + + v1 = PluginVersionInfo( + version="1.0.0", + released="2024-01-01", + manifest_file="manifest.json", + min_max_framework_version="0.1.0" # Missing max version + ) + v2 = PluginVersionInfo( + version="2.0.0", + released="2024-02-01", + manifest_file="manifest.json", + min_max_framework_version="invalid,version" # Invalid versions + ) + v3 = PluginVersionInfo( + version="3.0.0", + released="2024-03-01", + manifest_file="manifest.json", + min_max_framework_version="0.1.0,0.2.0" # Valid + ) + + registry = PluginVersionRegistry( + latest=v3, + versions=[v1, v2, v3] + ) + + # Should only find v3 + result = registry.get_latest_compatible("0.1.5") + assert result == v3 + + def test_get_latest_compatible_with_prerelease_versions(self): + """get_latest_compatible should handle pre-release versions correctly.""" + from cpex.framework.models import PluginVersionInfo, PluginVersionRegistry + + v1 = PluginVersionInfo( + version="1.0.0rc1", + released="2024-01-01", + manifest_file="manifest.json", + min_max_framework_version="0.1.0.dev1,0.1.0.dev5" + ) + v2 = PluginVersionInfo( + version="1.0.0", + released="2024-02-01", + manifest_file="manifest.json", + min_max_framework_version="0.1.0,0.2.0" + ) + + registry = PluginVersionRegistry( + latest=v2, + versions=[v1, v2] + ) + + # Dev version should match v1 + result = registry.get_latest_compatible("0.1.0.dev3") + assert result == v1 + + # Stable version should match v2 + result = registry.get_latest_compatible("0.1.5") + assert result == v2 + + def test_get_latest_compatible_boundary_conditions(self): + """get_latest_compatible should correctly handle boundary conditions.""" + from cpex.framework.models import PluginVersionInfo, PluginVersionRegistry + + v1 = PluginVersionInfo( + version="1.0.0", + released="2024-01-01", + manifest_file="manifest.json", + min_max_framework_version="0.1.0,0.2.0" + ) + + registry = PluginVersionRegistry( + latest=v1, + versions=[v1] + ) + + # Exact min boundary + result = registry.get_latest_compatible("0.1.0") + assert result == v1 + + # Exact max boundary + result = registry.get_latest_compatible("0.2.0") + assert result == v1 + + # Just below min + result = registry.get_latest_compatible("0.0.9") + assert result is None + + # Just above max + result = registry.get_latest_compatible("0.2.1") + assert result is None + + def test_get_latest_compatible_with_empty_versions_list(self): + """get_latest_compatible should return None when versions list is empty.""" + from cpex.framework.models import PluginVersionRegistry + + registry = PluginVersionRegistry( + latest=None, + versions=[] + ) + + result = registry.get_latest_compatible("0.1.0") + assert result is None