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

91 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 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 

25from dataclasses import dataclass 

26import json 

27import keyword 

28from pathlib import Path 

29import shutil 

30import unicodedata 

31 

32import typer 

33 

34from bijux_cli.cli.core.command import ( 

35 new_run_command, 

36 raise_exit_intent, 

37 validate_common_flags, 

38) 

39from bijux_cli.cli.core.constants import ( 

40 OPT_FORMAT, 

41 OPT_LOG_LEVEL, 

42 OPT_PRETTY, 

43 OPT_QUIET, 

44) 

45from bijux_cli.cli.core.help_text import ( 

46 HELP_FORMAT, 

47 HELP_LOG_LEVEL, 

48 HELP_NO_PRETTY, 

49 HELP_QUIET, 

50) 

51from bijux_cli.core.enums import ErrorType, LogLevel, OutputFormat 

52from bijux_cli.core.precedence import current_execution_policy 

53from bijux_cli.plugins.catalog import PLUGIN_NAME_RE 

54 

55 

56@dataclass(frozen=True) 

57class ScaffoldIntent: 

58 """Resolved intent for plugin scaffolding.""" 

59 

60 name: str 

61 template: str 

62 target: Path 

63 force: bool 

64 quiet: bool 

65 include_runtime: bool 

66 log_level: LogLevel 

67 fmt: OutputFormat 

68 

69 

70def _build_scaffold_intent( 

71 *, 

72 name: str, 

73 output_dir: str, 

74 template: str | None, 

75 force: bool, 

76 command: str, 

77 fmt: OutputFormat, 

78 quiet: bool, 

79 include_runtime: bool, 

80 log_level: LogLevel, 

81) -> ScaffoldIntent: 

82 """Validate inputs and build a scaffold intent.""" 

83 if name in keyword.kwlist: 

84 raise_exit_intent( 

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

86 code=1, 

87 failure="reserved_keyword", 

88 command=command, 

89 fmt=fmt, 

90 quiet=quiet, 

91 include_runtime=include_runtime, 

92 log_level=log_level, 

93 error_type=ErrorType.USER_INPUT, 

94 ) 

95 

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

97 raise_exit_intent( 

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

99 code=1, 

100 failure="invalid_name", 

101 command=command, 

102 fmt=fmt, 

103 quiet=quiet, 

104 include_runtime=include_runtime, 

105 log_level=log_level, 

106 error_type=ErrorType.USER_INPUT, 

107 ) 

108 

109 if not template: 

110 raise_exit_intent( 

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

112 code=1, 

113 failure="no_template", 

114 command=command, 

115 fmt=fmt, 

116 quiet=quiet, 

117 include_runtime=include_runtime, 

118 log_level=log_level, 

119 error_type=ErrorType.USER_INPUT, 

120 ) 

121 if template is None: 121 ↛ 122line 121 didn't jump to line 122 because the condition on line 121 was never true

122 raise RuntimeError("Template must be provided") 

123 

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

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

126 target = parent / slug 

127 

128 if not parent.exists(): 

129 try: 

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

131 except Exception as exc: 

132 raise_exit_intent( 

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

134 code=1, 

135 failure="create_dir_failed", 

136 command=command, 

137 fmt=fmt, 

138 quiet=quiet, 

139 include_runtime=include_runtime, 

140 log_level=log_level, 

141 ) 

142 elif not parent.is_dir(): 

143 raise_exit_intent( 

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

145 code=1, 

146 failure="not_dir", 

147 command=command, 

148 fmt=fmt, 

149 quiet=quiet, 

150 include_runtime=include_runtime, 

151 log_level=log_level, 

152 ) 

153 

154 normalized = name.lower() 

155 for existing in parent.iterdir(): 

156 if ( 

157 (existing.is_dir() or existing.is_symlink()) 

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

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

160 ): 

161 raise_exit_intent( 

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

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

164 code=1, 

165 failure="name_conflict", 

166 command=command, 

167 fmt=fmt, 

168 quiet=quiet, 

169 include_runtime=include_runtime, 

170 log_level=log_level, 

171 ) 

172 

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

174 if not force: 

175 raise_exit_intent( 

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

177 code=1, 

178 failure="dir_not_empty", 

179 command=command, 

180 fmt=fmt, 

181 quiet=quiet, 

182 include_runtime=include_runtime, 

183 log_level=log_level, 

184 ) 

185 try: 

186 if target.is_symlink(): 

187 target.unlink() 

188 elif target.is_dir(): 

189 shutil.rmtree(target) 

190 else: 

191 target.unlink() 

192 except Exception as exc: 

193 raise_exit_intent( 

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

195 code=1, 

196 failure="remove_failed", 

197 command=command, 

198 fmt=fmt, 

199 quiet=quiet, 

200 include_runtime=include_runtime, 

201 log_level=log_level, 

202 ) 

203 

204 return ScaffoldIntent( 

205 name=name, 

206 template=template, 

207 target=target, 

208 force=force, 

209 quiet=quiet, 

210 include_runtime=include_runtime, 

211 log_level=log_level, 

212 fmt=fmt, 

213 ) 

214 

215 

216def _scaffold_project(intent: ScaffoldIntent) -> dict[str, str]: 

217 """Run cookiecutter and validate the output.""" 

218 try: 

219 from cookiecutter.main import cookiecutter 

220 

221 cookiecutter( 

222 intent.template, 

223 no_input=True, 

224 output_dir=str(intent.target.parent), 

225 extra_context={ 

226 "project_name": intent.name, 

227 "project_slug": intent.target.name, 

228 }, 

229 ) 

230 if not intent.target.is_dir(): 

231 raise RuntimeError("Template copy failed") 

232 except ModuleNotFoundError: 

233 raise_exit_intent( 

234 "cookiecutter is required but not installed.", 

235 code=1, 

236 failure="cookiecutter_missing", 

237 command="plugins scaffold", 

238 fmt=intent.fmt, 

239 quiet=intent.quiet, 

240 include_runtime=intent.include_runtime, 

241 log_level=intent.log_level, 

242 ) 

243 except Exception as exc: 

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

245 raise_exit_intent( 

246 msg, 

247 code=1, 

248 failure="scaffold_failed", 

249 command="plugins scaffold", 

250 fmt=intent.fmt, 

251 quiet=intent.quiet, 

252 include_runtime=intent.include_runtime, 

253 log_level=intent.log_level, 

254 ) 

255 

256 plugin_json = intent.target / "plugin.json" 

257 if not plugin_json.is_file(): 

258 raise_exit_intent( 

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

260 code=1, 

261 failure="plugin_json_missing", 

262 command="plugins scaffold", 

263 fmt=intent.fmt, 

264 quiet=intent.quiet, 

265 include_runtime=intent.include_runtime, 

266 log_level=intent.log_level, 

267 ) 

268 try: 

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

270 if not ( 

271 isinstance(meta, dict) 

272 and meta.get("name") 

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

274 and meta.get("schema_version") 

275 and meta.get("bijux_cli_version") 

276 ): 

277 raise ValueError("Missing required fields") 

278 except Exception as exc: 

279 raise_exit_intent( 

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

281 code=1, 

282 failure="plugin_json_invalid", 

283 command="plugins scaffold", 

284 fmt=intent.fmt, 

285 quiet=intent.quiet, 

286 include_runtime=intent.include_runtime, 

287 log_level=intent.log_level, 

288 ) 

289 

290 return {"status": "created", "plugin": intent.name, "dir": str(intent.target)} 

291 

292 

293def scaffold_plugin( 

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

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

296 template: str | None = typer.Option( 

297 None, 

298 "--template", 

299 "-t", 

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

301 ), 

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

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

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

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

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

307) -> None: 

308 """Creates a new plugin project from a cookiecutter template. 

309 

310 This function orchestrates the scaffolding process. It performs numerous 

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

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

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

314 

315 Args: 

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

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

318 created. 

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

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

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

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

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

324 log_level (str): Logging level for diagnostics. 

325 

326 Returns: 

327 None: 

328 

329 Raises: 

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

331 payload, indicating success or detailing an error. 

332 """ 

333 command = "plugins scaffold" 

334 

335 policy = current_execution_policy() 

336 quiet = policy.quiet 

337 include_runtime = policy.include_runtime 

338 log_level_value = policy.log_level 

339 pretty = policy.pretty 

340 fmt_lower = validate_common_flags( 

341 fmt, 

342 command, 

343 quiet, 

344 include_runtime=include_runtime, 

345 log_level=log_level_value, 

346 ) 

347 

348 intent = _build_scaffold_intent( 

349 name=name, 

350 output_dir=output_dir, 

351 template=template, 

352 force=force, 

353 command=command, 

354 fmt=fmt_lower, 

355 quiet=quiet, 

356 include_runtime=include_runtime, 

357 log_level=log_level_value, 

358 ) 

359 payload = _scaffold_project(intent) 

360 

361 new_run_command( 

362 command_name=command, 

363 payload_builder=lambda include: payload, 

364 quiet=quiet, 

365 fmt=fmt_lower, 

366 pretty=pretty, 

367 log_level=log_level_value, 

368 )