Coverage for / home / runner / work / bijux-cli / bijux-cli / src / bijux_cli / core / engine.py: 99%
73 statements
« prev ^ index » next coverage.py v7.13.2, created at 2026-01-26 17:59 +0000
« prev ^ index » next coverage.py v7.13.2, created at 2026-01-26 17:59 +0000
1# SPDX-License-Identifier: Apache-2.0
2# Copyright © 2025 Bijan Mousavi
4"""Provides the core runtime engine for the Bijux CLI.
6This module defines the `Engine` class, which is responsible for orchestrating
7the application's runtime environment after initial setup. Its key
8responsibilities include:
10 * Initializing and registering all default services with the Dependency
11 Injection (DI) container.
12 * Discovering, loading, and registering all external plugins.
13 * Providing a central method for dispatching commands to plugins.
14 * Managing the graceful shutdown of services.
16The engine acts as the bridge between the CLI command layer and the
17underlying services and plugins.
18"""
20from __future__ import annotations
22import asyncio
23import inspect
24import os
25from typing import TYPE_CHECKING, Any
27if TYPE_CHECKING:
28 from bijux_cli.core.di import DIContainer
30from bijux_cli.core.enums import ColorMode, LogLevel, OutputFormat
31from bijux_cli.core.errors import PluginError
32from bijux_cli.core.precedence import resolve_output_flags
33from bijux_cli.plugins import get_plugins_dir, load_plugin
34from bijux_cli.plugins.contracts import RegistryProtocol
35from bijux_cli.plugins.services import register_plugin_services
36from bijux_cli.services import register_default_services
37from bijux_cli.services.history import History
38from bijux_cli.services.logging.contracts import LoggingConfig
39from bijux_cli.services.logging.observability import Observability
42class Engine:
43 """Orchestrates the CLI's runtime services and plugin lifecycle.
45 Attributes:
46 _di (DIContainer): The dependency injection container.
47 _format (OutputFormat): The default output format.
48 _quiet (bool): The quiet mode flag.
49 _registry (RegistryProtocol): The plugin registry service.
50 """
52 def __init__(
53 self,
54 di: Any = None,
55 *,
56 log_level: LogLevel = LogLevel.INFO,
57 fmt: OutputFormat = OutputFormat.JSON,
58 quiet: bool = False,
59 logging_config: LoggingConfig | None = None,
60 ) -> None:
61 """Initializes the engine and its core services.
63 This sets up the DI container, registers default services, and loads
64 all discoverable plugins.
66 Args:
67 di (Any, optional): An existing dependency injection container. If
68 None, the global singleton instance is used. Defaults to None.
69 log_level (LogLevel): The default log level for services.
70 fmt (OutputFormat): The default output format for services.
71 quiet (bool): If True, suppresses output from services.
72 logging_config (LoggingConfig | None): Optional logging configuration
73 override for service registration.
74 """
75 from bijux_cli.core.di import DIContainer
77 self._di = di or DIContainer.current()
78 self._format = fmt
79 self._quiet = quiet
80 if logging_config is None: 80 ↛ 92line 80 didn't jump to line 92 because the condition on line 80 was always true
81 resolved = resolve_output_flags(
82 quiet=quiet,
83 pretty=True,
84 log_level=log_level,
85 color=ColorMode.AUTO,
86 )
87 logging_config = LoggingConfig(
88 quiet=quiet,
89 log_level=resolved.log_level,
90 color=resolved.color,
91 )
92 register_default_services(
93 self._di,
94 logging_config=logging_config,
95 output_format=fmt,
96 )
97 register_plugin_services(self._di)
98 self._di.register(Engine, self)
99 self._registry: RegistryProtocol = self._di.resolve(RegistryProtocol)
100 self._register_plugins()
102 async def run_command(self, name: str, *args: Any, **kwargs: Any) -> Any:
103 """Executes a plugin's command with a configured timeout.
105 Args:
106 name (str): The name of the command or plugin to run.
107 *args (Any): Positional arguments to pass to the plugin's `execute`
108 method.
109 **kwargs (Any): Keyword arguments to pass to the plugin's `execute`
110 method.
112 Returns:
113 Any: The result of the command's execution.
115 Raises:
116 PluginError: If the plugin is not found, its `execute` method
117 is invalid, or if it fails during execution.
118 """
119 plugin = self._registry.get(name)
120 execute = getattr(plugin, "execute", None)
121 if not callable(execute):
122 raise PluginError(
123 f"Plugin '{name}' has no callable 'execute' method.", http_status=404
124 )
125 if not inspect.iscoroutinefunction(execute):
126 raise PluginError(
127 f"Plugin '{name}' 'execute' is not async/coroutine.", http_status=400
128 )
129 try:
130 return await asyncio.wait_for(execute(*args, **kwargs), self._timeout())
131 except Exception as exc: # pragma: no cover
132 raise PluginError(f"Failed to run plugin '{name}': {exc}") from exc
134 async def run_repl(self) -> None:
135 """Runs the interactive shell (REPL).
137 Note: This is a placeholder for future REPL integration.
138 """
139 pass
141 async def shutdown(self) -> None:
142 """Gracefully shuts down the engine and all resolved services.
144 This method orchestrates the termination sequence for the application's
145 runtime. It first attempts to flush any buffered command history to
146 disk and then proceeds to shut down the main dependency injection
147 container, which in turn cleans up all resolved services.
149 Returns:
150 None:
151 """
152 try:
153 self._di.resolve(History).flush()
154 except KeyError:
155 pass
156 finally:
157 await self._di.shutdown()
159 def _register_plugins(self) -> None:
160 """Discovers, loads, and registers all plugins from the filesystem.
162 This method scans the plugins directory for valid plugin subdirectories.
163 For each one found, it dynamically imports the `plugin.py` file,
164 executes an optional `startup(di)` hook if present, and registers the
165 plugin with the application's plugin registry. Errors encountered while
166 loading a single plugin are logged and suppressed to allow other
167 plugins to load.
169 Returns:
170 None:
171 """
172 plugins_dir = get_plugins_dir()
173 plugins_dir.mkdir(parents=True, exist_ok=True)
174 telemetry = self._di.resolve(Observability)
175 for folder in plugins_dir.iterdir():
176 if not folder.is_dir():
177 continue
178 path = folder / "src" / folder.name.replace("-", "_") / "plugin.py"
179 if not path.exists():
180 continue
181 module_name = (
182 folder.name.replace("-", "_")
183 if folder.name.startswith("bijux_plugin_")
184 else f"bijux_plugin_{folder.name.replace('-', '_')}"
185 )
186 try:
187 plugin = load_plugin(path, module_name)
188 if startup := getattr(plugin, "startup", None):
189 startup(self._di)
190 self._registry.register(
191 plugin.name, plugin, alias=None, version=plugin.version
192 )
193 except Exception as e: # pragma: no cover
194 telemetry.log("error", f"Loading plugin {folder.name} failed: {e}")
196 def _timeout(self) -> float:
197 """Retrieves the command timeout from the configuration service.
199 Returns:
200 float: The command timeout duration in seconds.
202 Raises:
203 ValueError: If the timeout value in the configuration is malformed.
204 """
205 from bijux_cli.cli.core.constants import ENV_COMMAND_TIMEOUT
207 raw = os.getenv(ENV_COMMAND_TIMEOUT, "30.0")
208 try:
209 return float(raw)
210 except (TypeError, ValueError) as err:
211 raise ValueError(f"Invalid timeout configuration: {raw!r}") from err
213 @property
214 def di(self) -> DIContainer:
215 """Read-only access to the DI container."""
216 return self._di
219__all__ = ["Engine"]