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

65 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"""Implements the `plugins scaffold` subcommand for the Bijux CLI. 

5 

6This module contains the logic for creating a new plugin project from a 

7`cookiecutter` template. It validates the proposed plugin name, handles the 

8destination directory setup (including forcing overwrites), and invokes 

9`cookiecutter` to generate the project structure. 

10 

11Output Contract: 

12 * Success: `{"status": "created", "plugin": str, "dir": str}` 

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

14 

15Exit Codes: 

16 * `0`: Success. 

17 * `1`: A fatal error occurred (e.g., cookiecutter not installed, invalid 

18 template, name conflict, 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 json 

26import keyword 

27from pathlib import Path 

28import shutil 

29import unicodedata 

30 

31import typer 

32 

33from bijux_cli.commands.plugins.utils import PLUGIN_NAME_RE 

34from bijux_cli.commands.utilities import ( 

35 emit_error_and_exit, 

36 new_run_command, 

37 validate_common_flags, 

38) 

39from bijux_cli.core.constants import ( 

40 HELP_DEBUG, 

41 HELP_FORMAT, 

42 HELP_NO_PRETTY, 

43 HELP_QUIET, 

44 HELP_VERBOSE, 

45) 

46 

47 

48def scaffold_plugin( 

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

50 output_dir: str = typer.Option(".", "--output-dir", "-o"), 

51 template: str | None = typer.Option( 

52 None, 

53 "--template", 

54 "-t", 

55 help="Path or URL to a cookiecutter template (required)", 

56 ), 

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

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 """Creates a new plugin project from a cookiecutter template. 

65 

66 This function orchestrates the scaffolding process. It performs numerous 

67 validations on the plugin name and output directory, handles existing 

68 directories with the `--force` flag, invokes the `cookiecutter` library 

69 to generate the project, and validates the resulting plugin metadata. 

70 

71 Args: 

72 name (str): The name for the new plugin (e.g., 'my-plugin'). 

73 output_dir (str): The directory where the new plugin project will be 

74 created. 

75 template (str | None): The path or URL to the `cookiecutter` template. 

76 force (bool): If True, overwrites the output directory if it exists. 

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

78 verbose (bool): If True, includes runtime metadata in error payloads. 

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

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

81 debug (bool): If True, enables debug diagnostics. 

82 

83 Returns: 

84 None: 

85 

86 Raises: 

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

88 payload, indicating success or detailing an error. 

89 """ 

90 command = "plugins scaffold" 

91 

92 fmt_lower = validate_common_flags(fmt, command, quiet) 

93 

94 if name in keyword.kwlist: 

95 emit_error_and_exit( 

96 f"Invalid plugin name: '{name}' is a reserved Python keyword.", 

97 code=1, 

98 failure="reserved_keyword", 

99 command=command, 

100 fmt=fmt_lower, 

101 quiet=quiet, 

102 include_runtime=verbose, 

103 debug=debug, 

104 ) 

105 

106 if not PLUGIN_NAME_RE.fullmatch(name) or not name.isascii(): 

107 emit_error_and_exit( 

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

109 code=1, 

110 failure="invalid_name", 

111 command=command, 

112 fmt=fmt_lower, 

113 quiet=quiet, 

114 include_runtime=verbose, 

115 debug=debug, 

116 ) 

117 

118 if not template: 

119 emit_error_and_exit( 

120 "No plugin template found. Please specify --template (path or URL).", 

121 code=1, 

122 failure="no_template", 

123 command=command, 

124 fmt=fmt_lower, 

125 quiet=quiet, 

126 include_runtime=verbose, 

127 debug=debug, 

128 ) 

129 

130 slug = unicodedata.normalize("NFC", name) 

131 parent = Path(output_dir).expanduser().resolve() 

132 target = parent / slug 

133 

134 if not parent.exists(): 

135 try: 

136 parent.mkdir(parents=True, exist_ok=True) 

137 except Exception as exc: 

138 emit_error_and_exit( 

139 f"Failed to create output directory '{parent}': {exc}", 

140 code=1, 

141 failure="create_dir_failed", 

142 command=command, 

143 fmt=fmt_lower, 

144 quiet=quiet, 

145 include_runtime=verbose, 

146 debug=debug, 

147 ) 

148 elif not parent.is_dir(): 

149 emit_error_and_exit( 

150 f"Output directory '{parent}' is not a directory.", 

151 code=1, 

152 failure="not_dir", 

153 command=command, 

154 fmt=fmt_lower, 

155 quiet=quiet, 

156 include_runtime=verbose, 

157 debug=debug, 

158 ) 

159 

160 normalized = name.lower() 

161 for existing in parent.iterdir(): 

162 if ( 

163 (existing.is_dir() or existing.is_symlink()) 

164 and existing.name.lower() == normalized 

165 and existing.resolve() != target.resolve() 

166 ): 

167 emit_error_and_exit( 

168 f"Plugin name '{name}' conflicts with existing directory '{existing.name}'. " 

169 "Plugin names must be unique (case-insensitive).", 

170 code=1, 

171 failure="name_conflict", 

172 command=command, 

173 fmt=fmt_lower, 

174 quiet=quiet, 

175 include_runtime=verbose, 

176 debug=debug, 

177 ) 

178 

179 if target.exists() or target.is_symlink(): 

180 if not force: 

181 emit_error_and_exit( 

182 f"Directory '{target}' is not empty – use --force to overwrite.", 

183 code=1, 

184 failure="dir_not_empty", 

185 command=command, 

186 fmt=fmt_lower, 

187 quiet=quiet, 

188 include_runtime=verbose, 

189 debug=debug, 

190 ) 

191 try: 

192 if target.is_symlink(): 

193 target.unlink() 

194 elif target.is_dir(): 

195 shutil.rmtree(target) 

196 else: 

197 target.unlink() 

198 except Exception as exc: 

199 emit_error_and_exit( 

200 f"Failed to remove existing '{target}': {exc}", 

201 code=1, 

202 failure="remove_failed", 

203 command=command, 

204 fmt=fmt_lower, 

205 quiet=quiet, 

206 include_runtime=verbose, 

207 debug=debug, 

208 ) 

209 

210 try: 

211 from cookiecutter.main import cookiecutter 

212 

213 cookiecutter( 

214 template, 

215 no_input=True, 

216 output_dir=str(parent), 

217 extra_context={"project_name": name, "project_slug": slug}, 

218 ) 

219 if not target.is_dir(): 

220 raise RuntimeError("Template copy failed") 

221 except ModuleNotFoundError: 

222 emit_error_and_exit( 

223 "cookiecutter is required but not installed.", 

224 code=1, 

225 failure="cookiecutter_missing", 

226 command=command, 

227 fmt=fmt_lower, 

228 quiet=quiet, 

229 include_runtime=verbose, 

230 debug=debug, 

231 ) 

232 except Exception as exc: 

233 msg = f"Scaffold failed: {exc} (template not found or invalid)" 

234 emit_error_and_exit( 

235 msg, 

236 code=1, 

237 failure="scaffold_failed", 

238 command=command, 

239 fmt=fmt_lower, 

240 quiet=quiet, 

241 include_runtime=verbose, 

242 debug=debug, 

243 ) 

244 

245 plugin_json = target / "plugin.json" 

246 if not plugin_json.is_file(): 

247 emit_error_and_exit( 

248 f"Scaffold failed: plugin.json not found in '{target}'.", 

249 code=1, 

250 failure="plugin_json_missing", 

251 command=command, 

252 fmt=fmt_lower, 

253 quiet=quiet, 

254 include_runtime=verbose, 

255 debug=debug, 

256 ) 

257 try: 

258 meta = json.loads(plugin_json.read_text("utf-8")) 

259 if not ( 

260 isinstance(meta, dict) 

261 and meta.get("name") 

262 and (meta.get("desc") or meta.get("description")) 

263 ): 

264 raise ValueError("Missing required fields") 

265 except Exception as exc: 

266 emit_error_and_exit( 

267 f"Scaffold failed: plugin.json invalid: {exc}", 

268 code=1, 

269 failure="plugin_json_invalid", 

270 command=command, 

271 fmt=fmt_lower, 

272 quiet=quiet, 

273 include_runtime=verbose, 

274 debug=debug, 

275 ) 

276 

277 payload = {"status": "created", "plugin": name, "dir": str(target)} 

278 

279 new_run_command( 

280 command_name=command, 

281 payload_builder=lambda include: payload, 

282 quiet=quiet, 

283 verbose=verbose, 

284 fmt=fmt_lower, 

285 pretty=pretty, 

286 debug=debug, 

287 )