Coverage for /home/runner/work/bijux-cli/bijux-cli/src/bijux_cli/core/engine.py: 100%
71 statements
« prev ^ index » next coverage.py v7.10.4, created at 2025-08-19 23:36 +0000
« prev ^ index » next coverage.py v7.10.4, created at 2025-08-19 23:36 +0000
1# SPDX-License-Identifier: MIT
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
24from typing import TYPE_CHECKING, Any
26if TYPE_CHECKING:
27 from bijux_cli.core.di import DIContainer
29from bijux_cli.contracts import ConfigProtocol, RegistryProtocol
30from bijux_cli.core.enums import OutputFormat
31from bijux_cli.core.exceptions import CommandError
32from bijux_cli.infra.observability import Observability
33from bijux_cli.services import register_default_services
34from bijux_cli.services.history import History
35from bijux_cli.services.plugins import get_plugins_dir, load_plugin
38class Engine:
39 """Orchestrates the CLI's runtime services and plugin lifecycle.
41 Attributes:
42 _di (DIContainer): The dependency injection container.
43 _debug (bool): The debug mode flag.
44 _format (OutputFormat): The default output format.
45 _quiet (bool): The quiet mode flag.
46 _registry (RegistryProtocol): The plugin registry service.
47 """
49 def __init__(
50 self,
51 di: Any = None,
52 *,
53 debug: bool = False,
54 fmt: OutputFormat = OutputFormat.JSON,
55 quiet: bool = False,
56 ) -> None:
57 """Initializes the engine and its core services.
59 This sets up the DI container, registers default services, and loads
60 all discoverable plugins.
62 Args:
63 di (Any, optional): An existing dependency injection container. If
64 None, the global singleton instance is used. Defaults to None.
65 debug (bool): If True, enables debug mode for services.
66 fmt (OutputFormat): The default output format for services.
67 quiet (bool): If True, suppresses output from services.
68 """
69 from bijux_cli.core.di import DIContainer
71 self._di = di or DIContainer.current()
72 self._debug = debug
73 self._format = fmt
74 self._quiet = quiet
75 self._di.register(Observability, lambda: Observability(debug=debug))
76 register_default_services(self._di, debug=debug, output_format=fmt, quiet=quiet)
77 self._di.register(Engine, self)
78 self._registry: RegistryProtocol = self._di.resolve(RegistryProtocol)
79 self._register_plugins()
81 async def run_command(self, name: str, *args: Any, **kwargs: Any) -> Any:
82 """Executes a plugin's command with a configured timeout.
84 Args:
85 name (str): The name of the command or plugin to run.
86 *args (Any): Positional arguments to pass to the plugin's `execute`
87 method.
88 **kwargs (Any): Keyword arguments to pass to the plugin's `execute`
89 method.
91 Returns:
92 Any: The result of the command's execution.
94 Raises:
95 CommandError: If the plugin is not found, its `execute` method
96 is invalid, or if it fails during execution.
97 """
98 plugin = self._registry.get(name)
99 execute = getattr(plugin, "execute", None)
100 if not callable(execute):
101 raise CommandError(
102 f"Plugin '{name}' has no callable 'execute' method.", http_status=404
103 )
104 if not inspect.iscoroutinefunction(execute):
105 raise CommandError(
106 f"Plugin '{name}' 'execute' is not async/coroutine.", http_status=400
107 )
108 try:
109 return await asyncio.wait_for(execute(*args, **kwargs), self._timeout())
110 except Exception as exc: # pragma: no cover
111 raise CommandError(f"Failed to run plugin '{name}': {exc}") from exc
113 async def run_repl(self) -> None:
114 """Runs the interactive shell (REPL).
116 Note: This is a placeholder for future REPL integration.
117 """
118 pass
120 async def shutdown(self) -> None:
121 """Gracefully shuts down the engine and all resolved services.
123 This method orchestrates the termination sequence for the application's
124 runtime. It first attempts to flush any buffered command history to
125 disk and then proceeds to shut down the main dependency injection
126 container, which in turn cleans up all resolved services.
128 Returns:
129 None:
130 """
131 try:
132 self._di.resolve(History).flush()
133 except KeyError:
134 pass
135 finally:
136 await self._di.shutdown()
138 def _register_plugins(self) -> None:
139 """Discovers, loads, and registers all plugins from the filesystem.
141 This method scans the plugins directory for valid plugin subdirectories.
142 For each one found, it dynamically imports the `plugin.py` file,
143 executes an optional `startup(di)` hook if present, and registers the
144 plugin with the application's plugin registry. Errors encountered while
145 loading a single plugin are logged and suppressed to allow other
146 plugins to load.
148 Returns:
149 None:
150 """
151 plugins_dir = get_plugins_dir()
152 plugins_dir.mkdir(parents=True, exist_ok=True)
153 telemetry = self._di.resolve(Observability)
154 for folder in plugins_dir.iterdir():
155 if not folder.is_dir():
156 continue
157 path = folder / "src" / folder.name.replace("-", "_") / "plugin.py"
158 if not path.exists():
159 continue
160 module_name = (
161 folder.name.replace("-", "_")
162 if folder.name.startswith("bijux_plugin_")
163 else f"bijux_plugin_{folder.name.replace('-', '_')}"
164 )
165 try:
166 plugin = load_plugin(path, module_name)
167 if startup := getattr(plugin, "startup", None):
168 startup(self._di)
169 self._registry.register(plugin.name, plugin, version=plugin.version)
170 except Exception as e: # pragma: no cover
171 telemetry.log("error", f"Loading plugin {folder.name} failed: {e}")
173 def _timeout(self) -> float:
174 """Retrieves the command timeout from the configuration service.
176 Returns:
177 float: The command timeout duration in seconds.
179 Raises:
180 ValueError: If the timeout value in the configuration is malformed.
181 """
182 try:
183 cfg = self._di.resolve(ConfigProtocol)
184 raw = cfg.get("BIJUXCLI_COMMAND_TIMEOUT", default=30.0)
185 except KeyError:
186 raw = 30.0
187 value = raw.get("value", 30.0) if isinstance(raw, dict) else raw
188 try:
189 return float(value)
190 except (TypeError, ValueError) as err:
191 raise ValueError(f"Invalid timeout configuration: {raw!r}") from err
193 @property
194 def di(self) -> DIContainer:
195 """Read-only access to the DI container."""
196 return self._di
199__all__ = ["Engine"]