Coverage for  / home / runner / work / bijux-cli / bijux-cli / src / bijux_cli / cli / plugins / commands / check.py: 98%

80 statements  

« 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 

3 

4"""Implements the `plugins check` subcommand for the Bijux CLI. 

5 

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. 

10 

11Output Contract: 

12 * Healthy: `{"plugin": str, "status": "healthy"}` 

13 * Unhealthy: `{"plugin": str, "status": "unhealthy"}` (exits with code 1) 

14 * Error: `{"error": "...", "code": int}` (for pre-check failures) 

15 

16Exit Codes: 

17 * `0`: The plugin is healthy. 

18 * `1`: The plugin is unhealthy, could not be found, or an error occurred 

19 during import or execution. 

20 * `2`: An invalid flag was provided (e.g., bad format). 

21 * `3`: An ASCII or encoding error was detected in the environment. 

22""" 

23 

24from __future__ import annotations 

25 

26import asyncio 

27from collections.abc import Mapping 

28import importlib.util 

29import inspect 

30import platform 

31import sys 

32import types 

33from typing import Any 

34 

35import anyio 

36import typer 

37 

38from bijux_cli.cli.core.command import ( 

39 ascii_safe, 

40 new_run_command, 

41 raise_exit_intent, 

42 validate_common_flags, 

43) 

44from bijux_cli.cli.core.constants import ( 

45 OPT_FORMAT, 

46 OPT_LOG_LEVEL, 

47 OPT_PRETTY, 

48 OPT_QUIET, 

49) 

50from bijux_cli.cli.core.help_text import ( 

51 HELP_FORMAT, 

52 HELP_LOG_LEVEL, 

53 HELP_NO_PRETTY, 

54 HELP_QUIET, 

55) 

56from bijux_cli.core.precedence import current_execution_policy 

57from bijux_cli.plugins.metadata import get_plugin_metadata 

58 

59 

60async def check_plugin( 

61 name: str = typer.Argument(..., help="Plugin name"), 

62 quiet: bool = typer.Option(False, *OPT_QUIET, help=HELP_QUIET), 

63 fmt: str = typer.Option("json", *OPT_FORMAT, help=HELP_FORMAT), 

64 pretty: bool = typer.Option(True, OPT_PRETTY, help=HELP_NO_PRETTY), 

65 log_level: str = typer.Option("info", *OPT_LOG_LEVEL, help=HELP_LOG_LEVEL), 

66) -> None: 

67 """Runs a health check on a specific installed plugin. 

68 

69 This function validates a plugin's structure, dynamically imports its 

70 `plugin.py` file, and executes its `health()` hook to determine its 

71 operational status. The final status is emitted as a structured payload. 

72 

73 Args: 

74 name (str): The name of the plugin to check. 

75 quiet (bool): If True, suppresses all output except for errors. 

76 fmt (str): The output format, "json" or "yaml". 

77 pretty (bool): If True, pretty-prints the output. 

78 log_level (str): Logging level for diagnostics. 

79 

80 Returns: 

81 None: 

82 

83 Raises: 

84 SystemExit: Always exits with a contract-compliant status code and 

85 payload, indicating the health status or detailing an error. 

86 """ 

87 command = "plugins check" 

88 

89 policy = current_execution_policy() 

90 quiet = policy.quiet 

91 include_runtime = policy.include_runtime 

92 log_level_value = policy.log_level 

93 pretty = policy.pretty 

94 fmt_lower = validate_common_flags( 

95 fmt, 

96 command, 

97 quiet, 

98 include_runtime=include_runtime, 

99 log_level=log_level_value, 

100 ) 

101 

102 try: 

103 meta = await anyio.to_thread.run_sync(get_plugin_metadata, name) 

104 except Exception as exc: 

105 raise_exit_intent( 

106 str(exc), 

107 code=1, 

108 failure="metadata_error", 

109 command=command, 

110 fmt=fmt_lower, 

111 quiet=quiet, 

112 include_runtime=include_runtime, 

113 log_level=log_level_value, 

114 ) 

115 

116 if not meta.path: 116 ↛ 117line 116 didn't jump to line 117 because the condition on line 116 was never true

117 raise_exit_intent( 

118 f'Plugin "{name}" has no local health hook', 

119 code=1, 

120 failure="health_unavailable", 

121 command=command, 

122 fmt=fmt_lower, 

123 quiet=quiet, 

124 include_runtime=include_runtime, 

125 log_level=log_level_value, 

126 ) 

127 

128 plug_dir = meta.path 

129 plug_py = plug_dir / "plugin.py" 

130 if not plug_py.is_file(): 

131 raise_exit_intent( 

132 f'Plugin "{name}" not found', 

133 code=1, 

134 failure="not_found", 

135 command=command, 

136 fmt=fmt_lower, 

137 quiet=quiet, 

138 include_runtime=include_runtime, 

139 log_level=log_level_value, 

140 extra={"plugin": name}, 

141 ) 

142 

143 mod_name = f"_bijux_cli_plugin_{name}" 

144 try: 

145 spec = importlib.util.spec_from_file_location(mod_name, plug_py) 

146 if not spec or not spec.loader: 

147 raise ImportError("Cannot create import spec") 

148 module = types.ModuleType(mod_name) 

149 sys.modules[mod_name] = module 

150 spec.loader.exec_module(module) 

151 except Exception as exc: 

152 err = f"Import error: {exc}" 

153 raise_exit_intent( 

154 err, 

155 code=1, 

156 failure="import_error", 

157 command=command, 

158 fmt=fmt_lower, 

159 quiet=quiet, 

160 include_runtime=include_runtime, 

161 log_level=log_level_value, 

162 ) 

163 

164 async def _run_health() -> dict[str, Any]: 

165 """Isolates and executes the plugin's `health()` hook. 

166 

167 This function finds and calls the `health()` function within the 

168 imported plugin module. It handles both synchronous and asynchronous 

169 hooks, validates their signatures, and safely captures any exceptions 

170 during execution. 

171 

172 Returns: 

173 dict[str, Any]: A dictionary containing the health check result, 

174 which includes the plugin name and a status ('healthy' or 

175 'unhealthy'), or an error message. 

176 """ 

177 hook = getattr(module, "health", None) 

178 if not callable(hook): 

179 return {"plugin": name, "error": "No health() hook"} 

180 try: 

181 sig = inspect.signature(hook) 

182 if len(sig.parameters) != 1: 

183 return { 

184 "plugin": name, 

185 "error": "health() hook must take exactly one argument (di)", 

186 } 

187 except Exception as exc1: 

188 return {"plugin": name, "error": f"health() signature error: {exc1}"} 

189 try: 

190 if asyncio.iscoroutinefunction(hook): 

191 res = await hook(None) 

192 else: 

193 res = await asyncio.to_thread(hook, None) 

194 except BaseException as exc2: 

195 return {"plugin": name, "error": str(exc2) or exc2.__class__.__name__} 

196 

197 if res is True: 

198 return {"plugin": name, "status": "healthy"} 

199 if res is False: 

200 return {"plugin": name, "status": "unhealthy"} 

201 if isinstance(res, dict) and res.get("status") in ("healthy", "unhealthy"): 

202 return {"plugin": name, "status": res["status"]} 

203 return {"plugin": name, "status": "unhealthy"} 

204 

205 result = await _run_health() 

206 sys.modules.pop(mod_name, None) 

207 exit_code = 1 if result.get("status") == "unhealthy" else 0 

208 

209 if result.get("error"): 

210 raise_exit_intent( 

211 result["error"], 

212 code=1, 

213 failure="health_error", 

214 command=command, 

215 fmt=fmt_lower, 

216 quiet=quiet, 

217 include_runtime=include_runtime, 

218 log_level=log_level_value, 

219 ) 

220 

221 def _build_payload(include: bool) -> Mapping[str, object]: 

222 """Constructs the final result payload. 

223 

224 Args: 

225 include (bool): If True, adds Python and platform info to the payload. 

226 

227 Returns: 

228 Mapping[str, object]: The payload containing the health check 

229 result and optional runtime metadata. 

230 """ 

231 payload = result 

232 if include: 

233 payload["python"] = ascii_safe(platform.python_version(), "python_version") 

234 payload["platform"] = ascii_safe(platform.platform(), "platform") 

235 return payload 

236 

237 new_run_command( 

238 command_name=command, 

239 payload_builder=_build_payload, 

240 quiet=quiet, 

241 fmt=fmt_lower, 

242 pretty=pretty, 

243 log_level=log_level_value, 

244 exit_code=exit_code, 

245 )