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

62 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 install` subcommand for the Bijux CLI. 

5 

6This module installs a plugin from PyPI by package name only. It validates that 

7the installed package exposes a `bijux_cli.plugins` entry point and that its 

8metadata declares compatibility with the running bijux-cli version. 

9 

10Output Contract: 

11 * Install Success: `{"status": "installed", "plugin": str, "dest": str}` 

12 * Dry Run Success: `{"status": "dry-run", "plugin": str, ...}` 

13 * Error: `{"error": "...", "code": int}` 

14 

15Exit Codes: 

16 * `0`: Success. 

17 * `1`: A fatal error occurred (e.g., source not found, invalid name, 

18 version incompatibility, filesystem error). 

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

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

21""" 

22 

23from __future__ import annotations 

24 

25import os 

26from pathlib import Path 

27import shutil 

28import subprocess # noqa: S603 # nosec B404 - intentional CLI invocation 

29import sys 

30 

31import typer 

32 

33from bijux_cli.cli.core.command import ( 

34 new_run_command, 

35 raise_exit_intent, 

36 validate_common_flags, 

37) 

38from bijux_cli.cli.core.constants import ( 

39 OPT_FORMAT, 

40 OPT_LOG_LEVEL, 

41 OPT_PRETTY, 

42 OPT_QUIET, 

43) 

44from bijux_cli.cli.core.help_text import ( 

45 HELP_FORMAT, 

46 HELP_LOG_LEVEL, 

47 HELP_NO_PRETTY, 

48 HELP_QUIET, 

49) 

50from bijux_cli.core.enums import ErrorType 

51from bijux_cli.core.precedence import current_execution_policy 

52from bijux_cli.plugins import get_plugins_dir 

53from bijux_cli.plugins import install_plugin as install_local_plugin 

54from bijux_cli.plugins.catalog import PLUGIN_NAME_RE 

55from bijux_cli.plugins.metadata import ( 

56 discover_plugins, 

57 get_plugin_metadata, 

58 invalidate_plugin_cache, 

59 plugins_for_package, 

60) 

61 

62 

63def install_plugin( 

64 name: str = typer.Argument(..., help="PyPI package name"), 

65 dry_run: bool = typer.Option(False, "--dry-run"), 

66 force: bool = typer.Option(False, "--force", "-F"), 

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

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

69 pretty: bool = typer.Option(False, OPT_PRETTY, help=HELP_NO_PRETTY), 

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

71) -> None: 

72 """Installs a plugin from PyPI by package name. 

73 

74 Args: 

75 name (str): The package name to install from PyPI. 

76 dry_run (bool): If True, simulates the installation without making changes. 

77 force (bool): If True, overwrites an existing plugin of the same name. 

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

79 fmt (str): The output format for confirmation or error messages. 

80 pretty (bool): If True, pretty-prints the output. log_level (str): Logging level for diagnostics. 

81 

82 Returns: 

83 None: 

84 

85 Raises: 

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

87 payload, indicating success or detailing an error. 

88 """ 

89 command = "plugins install" 

90 

91 policy = current_execution_policy() 

92 quiet = policy.quiet 

93 include_runtime = policy.include_runtime 

94 log_level_value = policy.log_level 

95 pretty = policy.pretty 

96 fmt_lower = validate_common_flags( 

97 fmt, 

98 command, 

99 quiet, 

100 include_runtime=include_runtime, 

101 log_level=log_level_value, 

102 ) 

103 local_path = Path(name) 

104 if not local_path.exists() and ( 

105 not PLUGIN_NAME_RE.fullmatch(name) or not name.isascii() 

106 ): 

107 raise_exit_intent( 

108 "Invalid package name: only ASCII letters, digits, dash and underscore are allowed.", 

109 code=1, 

110 failure="invalid_name", 

111 error_type=ErrorType.USER_INPUT, 

112 command=command, 

113 fmt=fmt_lower, 

114 quiet=quiet, 

115 include_runtime=include_runtime, 

116 log_level=log_level_value, 

117 ) 

118 

119 if dry_run: 

120 payload: dict[str, object] = {"status": "dry-run", "package": name} 

121 elif local_path.exists(): 

122 invalidate_plugin_cache() 

123 try: 

124 install_local_plugin(str(local_path), force=force) 

125 invalidate_plugin_cache() 

126 discover_plugins() 

127 meta = get_plugin_metadata(local_path.name) 

128 except Exception as exc: 

129 plug_dir = get_plugins_dir() / local_path.name 

130 if plug_dir.exists(): 130 ↛ 132line 130 didn't jump to line 132 because the condition on line 130 was always true

131 shutil.rmtree(plug_dir, ignore_errors=True) 

132 raise_exit_intent( 

133 str(exc), 

134 code=1, 

135 failure="metadata_error", 

136 error_type=ErrorType.PLUGIN, 

137 command=command, 

138 fmt=fmt_lower, 

139 quiet=quiet, 

140 include_runtime=include_runtime, 

141 log_level=log_level_value, 

142 ) 

143 payload = { 

144 "status": "installed", 

145 "package": str(local_path), 

146 "plugins": [meta.name], 

147 } 

148 else: 

149 invalidate_plugin_cache() 

150 cmd = [sys.executable, "-m", "pip", "install", name] 

151 if force: 151 ↛ 152line 151 didn't jump to line 152 because the condition on line 151 was never true

152 cmd.append("--upgrade") 

153 env = os.environ.copy() 

154 env.setdefault("PIP_DISABLE_PIP_VERSION_CHECK", "1") 

155 proc = subprocess.run( # noqa: S603 # nosec B603 - controlled command list 

156 cmd, 

157 env=env, 

158 capture_output=True, 

159 text=True, 

160 ) 

161 if proc.returncode != 0: 

162 detail = proc.stderr.strip() or proc.stdout.strip() 

163 raise_exit_intent( 

164 f"pip install failed: {detail}", 

165 code=1, 

166 failure="pip_install_failed", 

167 error_type=ErrorType.PLUGIN, 

168 command=command, 

169 fmt=fmt_lower, 

170 quiet=quiet, 

171 include_runtime=include_runtime, 

172 log_level=log_level_value, 

173 ) 

174 

175 invalidate_plugin_cache() 

176 try: 

177 discover_plugins() 

178 plugins = plugins_for_package(name) 

179 except Exception as exc: 

180 raise_exit_intent( 

181 str(exc), 

182 code=1, 

183 failure="metadata_error", 

184 error_type=ErrorType.PLUGIN, 

185 command=command, 

186 fmt=fmt_lower, 

187 quiet=quiet, 

188 include_runtime=include_runtime, 

189 log_level=log_level_value, 

190 ) 

191 

192 if not plugins: 

193 raise_exit_intent( 

194 "Package installed but no bijux_cli.plugins entry point found.", 

195 code=1, 

196 failure="entrypoint_missing", 

197 error_type=ErrorType.PLUGIN, 

198 command=command, 

199 fmt=fmt_lower, 

200 quiet=quiet, 

201 include_runtime=include_runtime, 

202 log_level=log_level_value, 

203 ) 

204 

205 payload = { 

206 "status": "installed", 

207 "package": name, 

208 "plugins": [p.name for p in plugins], 

209 } 

210 

211 new_run_command( 

212 command_name=command, 

213 payload_builder=lambda include: payload, 

214 quiet=quiet, 

215 fmt=fmt_lower, 

216 pretty=pretty, 

217 log_level=log_level, 

218 )