Coverage for /home/runner/work/bijux-cli/bijux-cli/src/bijux_cli/commands/plugins/check.py: 100%
80 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"""Implements the `plugins check` subcommand for the Bijux CLI.
6This module contains the logic for performing a health check on a specific
7installed plugin. It validates the plugin's files, dynamically imports its
8code, and executes a `health()` hook function if available. The result is
9reported in a structured, machine-readable format.
11Output Contract:
12 * Healthy: `{"plugin": str, "status": "healthy"}`
13 * Unhealthy: `{"plugin": str, "status": "unhealthy"}` (exits with code 1)
14 * Verbose: Adds `{"python": str, "platform": str}` to the payload.
15 * Error: `{"error": "...", "code": int}` (for pre-check failures)
17Exit Codes:
18 * `0`: The plugin is healthy.
19 * `1`: The plugin is unhealthy, could not be found, or an error occurred
20 during import or execution.
21 * `2`: An invalid flag was provided (e.g., bad format).
22 * `3`: An ASCII or encoding error was detected in the environment.
23"""
25from __future__ import annotations
27import asyncio
28from collections.abc import Mapping
29import importlib.util
30import inspect
31import json
32import platform
33import sys
34import traceback
35import types
36from typing import Any
38import typer
40from bijux_cli.commands.utilities import (
41 ascii_safe,
42 emit_error_and_exit,
43 new_run_command,
44 validate_common_flags,
45)
46from bijux_cli.core.constants import (
47 HELP_DEBUG,
48 HELP_FORMAT,
49 HELP_NO_PRETTY,
50 HELP_QUIET,
51 HELP_VERBOSE,
52)
53from bijux_cli.services.plugins import get_plugins_dir
56def check_plugin(
57 name: str = typer.Argument(..., help="Plugin name"),
58 quiet: bool = typer.Option(False, "-q", "--quiet", help=HELP_QUIET),
59 verbose: bool = typer.Option(False, "-v", "--verbose", help=HELP_VERBOSE),
60 fmt: str = typer.Option("json", "-f", "--format", help=HELP_FORMAT),
61 pretty: bool = typer.Option(True, "--pretty/--no-pretty", help=HELP_NO_PRETTY),
62 debug: bool = typer.Option(False, "-d", "--debug", help=HELP_DEBUG),
63) -> None:
64 """Runs a health check on a specific installed plugin.
66 This function validates a plugin's structure, dynamically imports its
67 `plugin.py` file, and executes its `health()` hook to determine its
68 operational status. The final status is emitted as a structured payload.
70 Args:
71 name (str): The name of the plugin to check.
72 quiet (bool): If True, suppresses all output except for errors.
73 verbose (bool): If True, includes Python/platform details in the output.
74 fmt (str): The output format, "json" or "yaml".
75 pretty (bool): If True, pretty-prints the output.
76 debug (bool): If True, enables debug diagnostics.
78 Returns:
79 None:
81 Raises:
82 SystemExit: Always exits with a contract-compliant status code and
83 payload, indicating the health status or detailing an error.
84 """
85 command = "plugins check"
87 fmt_lower = validate_common_flags(fmt, command, quiet)
89 plug_dir = get_plugins_dir() / name
90 plug_py = plug_dir / "plugin.py"
91 meta_json = plug_dir / "plugin.json"
93 if not plug_py.is_file():
94 emit_error_and_exit(
95 f'Plugin "{name}" not found',
96 code=1,
97 failure="not_found",
98 command=command,
99 fmt=fmt_lower,
100 quiet=quiet,
101 include_runtime=verbose,
102 debug=debug,
103 extra={"plugin": name},
104 )
106 if not meta_json.is_file():
107 emit_error_and_exit(
108 f'Plugin "{name}" metadata (plugin.json) is missing',
109 code=1,
110 failure="metadata_missing",
111 command=command,
112 fmt=fmt_lower,
113 quiet=quiet,
114 include_runtime=verbose,
115 debug=debug,
116 )
118 try:
119 meta = json.loads(meta_json.read_text("utf-8"))
120 if not (isinstance(meta, dict) and meta.get("name") and meta.get("desc")):
121 raise ValueError("Incomplete metadata")
122 except Exception as exc:
123 emit_error_and_exit(
124 f'Plugin "{name}" metadata is corrupt: {exc}',
125 code=1,
126 failure="metadata_corrupt",
127 command=command,
128 fmt=fmt_lower,
129 quiet=quiet,
130 include_runtime=verbose,
131 debug=debug,
132 )
134 mod_name = f"_bijux_cli_plugin_{name}"
135 try:
136 spec = importlib.util.spec_from_file_location(mod_name, plug_py)
137 if not spec or not spec.loader:
138 raise ImportError("Cannot create import spec")
139 module = types.ModuleType(mod_name)
140 sys.modules[mod_name] = module
141 spec.loader.exec_module(module)
142 except Exception as exc:
143 err = f"Import error: {exc}"
144 if debug:
145 err += "\n" + traceback.format_exc()
146 emit_error_and_exit(
147 err,
148 code=1,
149 failure="import_error",
150 command=command,
151 fmt=fmt_lower,
152 quiet=quiet,
153 include_runtime=verbose,
154 debug=debug,
155 )
157 async def _run_health() -> dict[str, Any]:
158 """Isolates and executes the plugin's `health()` hook.
160 This function finds and calls the `health()` function within the
161 imported plugin module. It handles both synchronous and asynchronous
162 hooks, validates their signatures, and safely captures any exceptions
163 during execution.
165 Returns:
166 dict[str, Any]: A dictionary containing the health check result,
167 which includes the plugin name and a status ('healthy' or
168 'unhealthy'), or an error message.
169 """
170 hook = getattr(module, "health", None)
171 if not callable(hook):
172 return {"plugin": name, "error": "No health() hook"}
173 try:
174 sig = inspect.signature(hook)
175 if len(sig.parameters) != 1:
176 return {
177 "plugin": name,
178 "error": "health() hook must take exactly one argument (di)",
179 }
180 except Exception as exc1:
181 return {"plugin": name, "error": f"health() signature error: {exc1}"}
182 try:
183 if asyncio.iscoroutinefunction(hook):
184 res = await hook(None)
185 else:
186 loop = asyncio.get_running_loop()
187 res = await loop.run_in_executor(None, hook, None)
188 except BaseException as exc2:
189 return {"plugin": name, "error": str(exc2) or exc2.__class__.__name__}
191 if res is True:
192 return {"plugin": name, "status": "healthy"}
193 if res is False:
194 return {"plugin": name, "status": "unhealthy"}
195 if isinstance(res, dict) and res.get("status") in ("healthy", "unhealthy"):
196 return {"plugin": name, "status": res["status"]}
197 return {"plugin": name, "status": "unhealthy"}
199 result = asyncio.run(_run_health())
200 sys.modules.pop(mod_name, None)
201 exit_code = 1 if result.get("status") == "unhealthy" else 0
203 if result.get("error"):
204 emit_error_and_exit(
205 result["error"],
206 code=1,
207 failure="health_error",
208 command=command,
209 fmt=fmt_lower,
210 quiet=quiet,
211 include_runtime=verbose,
212 debug=debug,
213 )
215 def _build_payload(include: bool) -> Mapping[str, object]:
216 """Constructs the final result payload.
218 Args:
219 include (bool): If True, adds Python and platform info to the payload.
221 Returns:
222 Mapping[str, object]: The payload containing the health check
223 result and optional runtime metadata.
224 """
225 payload = result
226 if include:
227 payload["python"] = ascii_safe(platform.python_version(), "python_version")
228 payload["platform"] = ascii_safe(platform.platform(), "platform")
229 return payload
231 new_run_command(
232 command_name=command,
233 payload_builder=_build_payload,
234 quiet=quiet,
235 verbose=verbose,
236 fmt=fmt_lower,
237 pretty=pretty,
238 debug=debug,
239 exit_code=exit_code,
240 )