Coverage for /home/runner/work/bijux-cli/bijux-cli/src/bijux_cli/services/plugins/__init__.py: 100%

150 statements  

« 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 

3 

4"""Provides the public API for the Bijux CLI's plugin management service. 

5 

6This module acts as the public facade for the plugin management service layer. 

7It exposes a curated set of high-level functions for core plugin operations, 

8ensuring a stable and convenient API for the rest of the application. 

9 

10To optimize CLI startup performance, this module's own submodules (such as 

11`registry`, `hooks`, and `entrypoints`) are loaded lazily upon first access. 

12""" 

13 

14from __future__ import annotations 

15 

16from contextlib import suppress 

17import importlib 

18import importlib.util 

19import os 

20from pathlib import Path 

21import shutil 

22import sys 

23from typing import Any, cast 

24 

25from packaging.specifiers import SpecifierSet 

26 

27from bijux_cli.__version__ import version as cli_version 

28from bijux_cli.contracts import ( 

29 ObservabilityProtocol, 

30 RegistryProtocol, 

31 TelemetryProtocol, 

32) 

33from bijux_cli.core.exceptions import BijuxError 

34 

35 

36def _di() -> Any | None: 

37 """Safely retrieves the current DIContainer instance. 

38 

39 Returns: 

40 DIContainer | None: The current dependency injection container, or None 

41 if it is not available or an error occurs. 

42 """ 

43 from bijux_cli.core.di import DIContainer 

44 

45 try: 

46 return DIContainer.current() 

47 except Exception: 

48 return None 

49 

50 

51def _obs() -> ObservabilityProtocol | None: 

52 """Safely resolves the `ObservabilityProtocol` service. 

53 

54 Returns: 

55 ObservabilityProtocol | None: The observability service, or None if it 

56 cannot be resolved. 

57 """ 

58 di = _di() 

59 if not di: 

60 return None 

61 try: 

62 return cast(ObservabilityProtocol, di.resolve(ObservabilityProtocol)) 

63 except KeyError: 

64 return None 

65 

66 

67def _tel() -> TelemetryProtocol | None: 

68 """Safely resolves the `TelemetryProtocol` service. 

69 

70 Returns: 

71 TelemetryProtocol | None: The telemetry service, or None if it 

72 cannot be resolved. 

73 """ 

74 di = _di() 

75 if not di: 

76 return None 

77 try: 

78 return cast(TelemetryProtocol, di.resolve(TelemetryProtocol)) 

79 except KeyError: 

80 return None 

81 

82 

83def get_plugins_dir() -> Path: 

84 """Returns the directory that stores installed plugins. 

85 

86 The path is determined by the `BIJUXCLI_PLUGINS_DIR` environment variable 

87 if set, otherwise it falls back to a default location. The directory is 

88 created if it does not exist. 

89 

90 Returns: 

91 Path: The resolved path to the plugins directory. 

92 """ 

93 env_path = os.environ.get("BIJUXCLI_PLUGINS_DIR") 

94 if env_path: 

95 plugins_dir = Path(env_path) 

96 else: 

97 from bijux_cli.core.paths import PLUGINS_DIR 

98 

99 plugins_dir = PLUGINS_DIR 

100 plugins_dir = plugins_dir.expanduser() 

101 

102 if plugins_dir.exists() and plugins_dir.is_symlink(): 

103 return plugins_dir 

104 if plugins_dir.is_dir(): 

105 return plugins_dir.resolve() 

106 if plugins_dir.exists(): 

107 return plugins_dir.resolve() 

108 plugins_dir.mkdir(parents=True, exist_ok=True) 

109 return plugins_dir.resolve() 

110 

111 

112def load_plugin_config(name: str) -> dict[str, Any]: 

113 """Loads a plugin's `config.yaml` file. 

114 

115 This function looks for a `config.yaml` file in the specified plugin's 

116 directory. It returns an empty dictionary if the file is missing. 

117 

118 Args: 

119 name (str): The name of the plugin whose config should be loaded. 

120 

121 Returns: 

122 dict[str, Any]: The plugin's configuration as a dictionary. 

123 

124 Raises: 

125 BijuxError: If the `PyYAML` library is not installed or if the file 

126 is corrupt and cannot be parsed. 

127 """ 

128 try: 

129 import yaml 

130 except ModuleNotFoundError as exc: 

131 raise BijuxError("PyYAML is required to read plugin configs") from exc 

132 

133 cfg_path = get_plugins_dir() / name / "config.yaml" 

134 if not cfg_path.exists(): 

135 return {} 

136 

137 try: 

138 data = yaml.safe_load(cfg_path.read_text(encoding="utf-8")) or {} 

139 except Exception as exc: 

140 tel = _tel() 

141 if tel: 

142 tel.event("plugin_config_failed", {"name": name, "error": str(exc)}) 

143 raise BijuxError(f"Failed to load config for '{name}': {exc}") from exc 

144 

145 obs = _obs() 

146 tel = _tel() 

147 if obs: 

148 obs.log("info", "Loaded plugin config", extra={"name": name}) 

149 if tel: 

150 tel.event("plugin_config_loaded", {"name": name}) 

151 return data 

152 

153 

154def verify_plugin_signature(path: Path, public_key: str | None) -> bool: 

155 """Verifies the signature of a plugin file. 

156 

157 Note: 

158 This is a placeholder function. Actual cryptographic verification is 

159 not implemented. 

160 

161 Args: 

162 path (Path): The path to the plugin file to verify. 

163 public_key (str | None): The public key to use for verification. 

164 

165 Returns: 

166 bool: True if the signature is "verified", False if no signature exists. 

167 

168 Raises: 

169 BijuxError: If a signature file exists but no public key is provided. 

170 """ 

171 sig = path.with_suffix(path.suffix + ".sig") 

172 tel = _tel() 

173 

174 if not sig.exists(): 

175 if tel: 

176 tel.event("plugin_unsigned", {"path": str(path)}) 

177 return False 

178 

179 if public_key is None: 

180 raise BijuxError(f"Signature found for {path} but no public_key provided") 

181 

182 if tel: 

183 tel.event("plugin_signature_verified", {"path": str(path)}) 

184 return True 

185 

186 

187def load_plugin( 

188 path: str | Path, 

189 module_name: str, 

190 *, 

191 public_key: str | None = None, 

192) -> Any: 

193 """Dynamically loads and instantiates a `Plugin` class from a `.py` file. 

194 

195 This function handles the dynamic import of a plugin's code, instantiates 

196 its main `Plugin` class, and performs compatibility checks. 

197 

198 Args: 

199 path (str | Path): The path to the `plugin.py` file. 

200 module_name (str): The name to assign to the imported module. 

201 public_key (str | None): An optional public key for signature verification. 

202 

203 Returns: 

204 Any: An instantiated object of the `Plugin` class from the file. 

205 

206 Raises: 

207 BijuxError: If the file is missing, the signature is invalid, the 

208 plugin is incompatible with the current CLI version, or an import 

209 error occurs. 

210 """ 

211 path = Path(path) 

212 if not path.is_file(): 

213 raise BijuxError(f"Plugin file not found: {path}") 

214 

215 if public_key: 

216 verify_plugin_signature(path, public_key) 

217 

218 spec = importlib.util.spec_from_file_location(module_name, str(path)) 

219 if not spec or not spec.loader: 

220 raise BijuxError(f"Cannot import plugin: {path}") 

221 

222 module = importlib.util.module_from_spec(spec) 

223 sys.modules[module_name] = module 

224 

225 try: 

226 spec.loader.exec_module(module) 

227 

228 plugin_class = getattr(module, "Plugin", None) 

229 if plugin_class is None: 

230 raise BijuxError("No `Plugin` class found in module") 

231 

232 plugin = plugin_class() 

233 

234 for target in (plugin_class, plugin): 

235 raw = getattr(target, "version", None) 

236 if raw is not None and not isinstance(raw, str): 

237 target.version = str(raw) 

238 

239 required = getattr(plugin, "requires_cli_version", f"=={cli_version}") 

240 spec_set = SpecifierSet(required) 

241 if not spec_set.contains(cli_version): 

242 raise BijuxError(f"Plugin requires CLI {required}, host is {cli_version}") 

243 

244 obs = _obs() 

245 cli_attr = getattr(plugin, "cli", None) 

246 if obs and (cli_attr is None or not callable(cli_attr)): 

247 obs.log( 

248 "warning", 

249 f"Plugin '{module_name}' has no callable `cli`", 

250 extra={"path": str(path)}, 

251 ) 

252 

253 tel = _tel() 

254 if tel: 

255 tel.event("plugin_loaded", {"name": getattr(plugin, "name", module_name)}) 

256 

257 return plugin 

258 

259 except Exception as exc: 

260 sys.modules.pop(module_name, None) 

261 raise BijuxError(f"Failed to load plugin '{path}': {exc}") from exc 

262 

263 

264def uninstall_plugin(name: str, registry: RegistryProtocol) -> bool: 

265 """Removes a plugin's directory and deregisters it. 

266 

267 Args: 

268 name (str): The name of the plugin to uninstall. 

269 registry (RegistryProtocol): The plugin registry service. 

270 

271 Returns: 

272 bool: True if the plugin was found and removed, otherwise False. 

273 """ 

274 plug_dir = get_plugins_dir() / name 

275 tel = _tel() 

276 

277 existed = registry.has(name) 

278 if not existed: 

279 if tel: 

280 tel.event("plugin_uninstall_not_found", {"name": name}) 

281 return False 

282 

283 with suppress(Exception): 

284 shutil.rmtree(plug_dir, ignore_errors=True) 

285 

286 registry.deregister(name) 

287 if tel: 

288 tel.event("plugin_uninstalled", {"name": name}) 

289 return True 

290 

291 

292def install_plugin(*args: Any, **kwargs: Any) -> None: 

293 """Stub for plugin installation. Use the CLI command instead. 

294 

295 Raises: 

296 NotImplementedError: Always, as this function is a stub. 

297 """ 

298 raise NotImplementedError("Use `bijux_cli.commands.plugins.install`") 

299 

300 

301_SUBMODULES: dict[str, str] = { 

302 "hooks": "bijux_cli.services.plugins.hooks", 

303 "entrypoints": "bijux_cli.services.plugins.entrypoints", 

304 "groups": "bijux_cli.services.plugins.groups", 

305 "registry": "bijux_cli.services.plugins.registry", 

306} 

307 

308 

309def __getattr__(name: str) -> Any: 

310 """Lazily imports submodules to optimize startup time. 

311 

312 This function is a module-level implementation of `__getattr__` (PEP 562), 

313 which allows submodules of the `plugins` service to be imported only when 

314 they are first accessed. 

315 

316 Args: 

317 name (str): The name of the submodule or attribute to access. 

318 

319 Returns: 

320 Any: The imported submodule or attribute. 

321 

322 Raises: 

323 AttributeError: If the requested name is not a valid submodule or 

324 attribute that can be lazily loaded. 

325 """ 

326 if name in _SUBMODULES: 

327 mod = importlib.import_module(_SUBMODULES[name]) 

328 setattr(sys.modules[__name__], name, mod) 

329 return mod 

330 if name in {"command_group", "dynamic_choices"}: 

331 groups_mod = importlib.import_module(_SUBMODULES["groups"]) 

332 return getattr(groups_mod, name) 

333 if name == "load_entrypoints": 

334 entrypoints_mod = importlib.import_module(_SUBMODULES["entrypoints"]) 

335 return entrypoints_mod.load_entrypoints 

336 raise AttributeError(f"module {__name__} has no attribute {name}") 

337 

338 

339__all__ = [ 

340 "get_plugins_dir", 

341 "load_plugin_config", 

342 "verify_plugin_signature", 

343 "load_plugin", 

344 "uninstall_plugin", 

345 "install_plugin", 

346]