Coverage for /home/runner/work/bijux-cli/bijux-cli/src/bijux_cli/commands/help.py: 99%

154 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 `help` command for the Bijux CLI. 

5 

6This module provides a contextual help system that can generate and display 

7help text for any command or subcommand. It supports multiple output formats, 

8including human-readable text for interactive use and structured JSON or YAML 

9for automation and integration purposes. It also includes special logic to 

10suppress known noisy warnings from the plugin system during help generation. 

11 

12Output Contract: 

13 * Human: Standard CLI help text is printed to stdout. 

14 * JSON/YAML: `{"help": str}` 

15 * Verbose: Adds `{"python": str, "platform": str, "runtime_ms": int}`. 

16 * Error: `{"error": str, "code": int}` 

17 

18Exit Codes: 

19 * `0`: Success. 

20 * `1`: Fatal or internal error. 

21 * `2`: CLI argument, flag, or "command not found" error. 

22 * `3`: ASCII or encoding error. 

23""" 

24 

25from __future__ import annotations 

26 

27from collections.abc import Mapping 

28import platform as _platform 

29import sys 

30import sys as _sys 

31import time 

32from typing import Any 

33 

34import click 

35import click as _click 

36import typer 

37import typer as _typer 

38 

39from bijux_cli.commands.utilities import ( 

40 ascii_safe, 

41 contains_non_ascii_env, 

42 emit_and_exit, 

43 emit_error_and_exit, 

44 validate_common_flags, 

45) 

46from bijux_cli.contracts import EmitterProtocol 

47from bijux_cli.core.constants import ( 

48 HELP_DEBUG, 

49 HELP_FORMAT_HELP, 

50 HELP_NO_PRETTY, 

51 HELP_QUIET, 

52 HELP_VERBOSE, 

53) 

54from bijux_cli.core.di import DIContainer 

55from bijux_cli.core.enums import OutputFormat 

56 

57if len(_sys.argv) > 1 and _sys.argv[1] == "help" and "--quiet" in _sys.argv: 

58 import io 

59 import sys 

60 from typing import IO, Any, AnyStr 

61 

62 import click as _click 

63 import typer as _typer 

64 

65 _orig_stderr = sys.stderr 

66 _orig_click_echo = _click.echo 

67 _orig_click_secho = _click.secho 

68 

69 class _FilteredStderr(io.TextIOBase): 

70 """A proxy for sys.stderr that filters known noisy plugin warnings.""" 

71 

72 def write(self, data: str) -> int: 

73 """Writes to stderr, suppressing specific noisy plugin warnings. 

74 

75 Args: 

76 data (str): The string to write to the stream. 

77 

78 Returns: 

79 int: The number of characters written, or 0 if suppressed. 

80 """ 

81 if data.strip() == "": 

82 return 0 

83 if ( 

84 "Plugin 'test-src' does not expose a Typer app via 'cli()' or 'app'" 

85 in data 

86 or "does not expose a Typer app" in data 

87 ): 

88 return 0 

89 return _orig_stderr.write(data) 

90 

91 def flush(self) -> None: 

92 """Flushes the underlying stderr stream.""" 

93 _orig_stderr.flush() 

94 

95 def __getattr__(self, name: str) -> Any: 

96 """Delegates attribute access to the original `sys.stderr`. 

97 

98 Args: 

99 name (str): The name of the attribute to access. 

100 

101 Returns: 

102 Any: The attribute from the original `sys.stderr`. 

103 """ 

104 return getattr(_orig_stderr, name) 

105 

106 sys.stderr = _FilteredStderr() 

107 

108 def _filtered_echo( 

109 message: Any = None, 

110 file: IO[AnyStr] | None = None, 

111 nl: bool = True, 

112 err: bool = False, 

113 color: bool | None = None, 

114 **styles: Any, 

115 ) -> None: 

116 """A proxy for click.echo that filters known plugin warnings. 

117 

118 Args: 

119 message (Any): The message to print. 

120 file (IO[AnyStr] | None): The output stream. 

121 nl (bool): If True, print a newline character at the end. 

122 err (bool): If True, print to stderr instead of stdout. 

123 color (bool | None): If True, enable color output. 

124 **styles: Additional style keyword arguments for `click.secho`. 

125 """ 

126 text = "" if message is None else str(message) 

127 if not text.strip(): 

128 return 

129 if ( 

130 text.startswith("[WARN] Plugin 'test-src'") 

131 and "does not expose a Typer app" in text 

132 ): 

133 return 

134 if styles: 134 ↛ 135line 134 didn't jump to line 135 because the condition on line 134 was never true

135 _orig_click_secho(message, file=file, nl=nl, err=err, color=color, **styles) 

136 else: 

137 _orig_click_echo(message, file=file, nl=nl, err=err, color=color) 

138 

139 _click.echo = _filtered_echo 

140 _click.secho = _filtered_echo 

141 _typer.echo = _filtered_echo 

142 _typer.secho = _filtered_echo 

143 

144_HUMAN = "human" 

145_VALID_FORMATS = ("human", "json", "yaml") 

146 

147 

148def _find_target_command( 

149 ctx: typer.Context, path: list[str] 

150) -> tuple[click.Command, click.Context] | None: 

151 """Locates the Click command and context for a given command path. 

152 

153 Args: 

154 ctx (typer.Context): The Typer context object for the CLI. 

155 path (list[str]): A list of command and subcommand tokens. 

156 

157 Returns: 

158 tuple[click.Command, click.Context] | None: A tuple containing the 

159 matched command and its context, or None if not found. 

160 """ 

161 root_cmd: click.Command | None = ctx.parent.command if ctx.parent else None 

162 if not root_cmd: 

163 return None 

164 

165 current_cmd: click.Command | None = root_cmd 

166 current_ctx = click.Context(root_cmd, info_name="bijux") 

167 

168 for token in path: 

169 if not isinstance(current_cmd, click.Group): 

170 return None 

171 next_cmd = current_cmd.get_command(current_ctx, token) 

172 if not next_cmd: 

173 return None 

174 current_ctx = click.Context(next_cmd, info_name=token, parent=current_ctx) 

175 current_cmd = next_cmd 

176 

177 assert current_cmd is not None # noqa: S101 # nosec: B101 

178 return current_cmd, current_ctx 

179 

180 

181def _get_formatted_help(cmd: click.Command, ctx: click.Context) -> str: 

182 """Gets and formats the help text for a command. 

183 

184 This helper ensures that the short help option '-h' is included in the 

185 final help text if it was defined in the command's context settings. 

186 

187 Args: 

188 cmd (click.Command): The Click command object. 

189 ctx (click.Context): The Click context for the command. 

190 

191 Returns: 

192 str: The formatted help text. 

193 """ 

194 help_text = cmd.get_help(ctx) 

195 if ( 

196 hasattr(cmd, "context_settings") 

197 and cmd.context_settings 

198 and "-h" in cmd.context_settings.get("help_option_names", []) 

199 and "-h, --help" not in help_text 

200 ): 

201 help_text = help_text.replace("--help", "-h, --help") 

202 return help_text 

203 

204 

205def _build_help_payload( 

206 help_text: str, include_runtime: bool, started_at: float 

207) -> Mapping[str, Any]: 

208 """Builds a structured help payload for JSON/YAML output. 

209 

210 Args: 

211 help_text (str): The CLI help text to be included in the payload. 

212 include_runtime (bool): If True, adds Python, platform, and runtime 

213 metadata to the payload. 

214 started_at (float): The start time from `time.perf_counter()` to use 

215 for calculating the runtime duration. 

216 

217 Returns: 

218 Mapping[str, Any]: A payload containing help text and optional runtime 

219 fields. 

220 """ 

221 payload: dict[str, Any] = {"help": help_text} 

222 if include_runtime: 

223 payload["python"] = ascii_safe(sys.version.split()[0], "python_version") 

224 payload["platform"] = ascii_safe(_platform.platform(), "platform") 

225 payload["runtime_ms"] = int((time.perf_counter() - started_at) * 1_000) 

226 return payload 

227 

228 

229typer.core.rich = None # type: ignore[attr-defined,assignment] 

230 

231help_app = typer.Typer( # pytype: skip-file 

232 name="help", 

233 add_completion=False, 

234 help="Show help for any CLI command or subcommand.", 

235 context_settings={ 

236 "help_option_names": ["-h", "--help"], 

237 "ignore_unknown_options": True, 

238 "allow_extra_args": True, 

239 "allow_interspersed_args": True, 

240 }, 

241) 

242 

243ARGS = typer.Argument(None, help="Command path, e.g. 'config get'.") 

244 

245 

246@help_app.callback(invoke_without_command=True) 

247def help_callback( 

248 ctx: typer.Context, 

249 command_path: list[str] | None = ARGS, 

250 quiet: bool = typer.Option(False, "-q", "--quiet", help=HELP_QUIET), 

251 verbose: bool = typer.Option(False, "-v", "--verbose", help=HELP_VERBOSE), 

252 fmt: str = typer.Option(_HUMAN, "-f", "--format", help=HELP_FORMAT_HELP), 

253 pretty: bool = typer.Option(True, "--pretty/--no-pretty", help=HELP_NO_PRETTY), 

254 debug: bool = typer.Option(False, "-d", "--debug", help=HELP_DEBUG), 

255) -> None: 

256 """Defines the entrypoint and logic for the `bijux help` command. 

257 

258 This function orchestrates the entire help generation process. It parses the 

259 target command path, finds the corresponding command object, performs ASCII 

260 and format validation, and emits the help text in the specified format. 

261 

262 Args: 

263 ctx (typer.Context): The Typer context for the CLI. 

264 command_path (list[str] | None): A list of tokens representing the path 

265 to the target command (e.g., `["config", "get"]`). 

266 quiet (bool): If True, suppresses all output. The exit code is the 

267 primary indicator of outcome. 

268 verbose (bool): If True, includes Python and platform details in 

269 structured output formats. 

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

271 pretty (bool): If True, pretty-prints structured output. 

272 debug (bool): If True, enables debug diagnostics, implying `verbose` 

273 and `pretty`. 

274 

275 Returns: 

276 None: 

277 

278 Raises: 

279 SystemExit: Always exits with a contract-compliant exit code and payload 

280 upon completion or error. 

281 """ 

282 started_at = time.perf_counter() 

283 

284 if "-h" in sys.argv or "--help" in sys.argv: 

285 all_args = sys.argv[2:] 

286 known_flags_with_args = {"-f", "--format"} 

287 path_tokens = [] 

288 i = 0 

289 while i < len(all_args): 

290 arg = all_args[i] 

291 if arg in known_flags_with_args: 

292 i += 2 

293 elif arg.startswith("-"): 

294 i += 1 

295 else: 

296 path_tokens.append(arg) 

297 i += 1 

298 

299 target = _find_target_command(ctx, path_tokens) or _find_target_command(ctx, []) 

300 if target: 

301 target_cmd, target_ctx = target 

302 help_text = _get_formatted_help(target_cmd, target_ctx) 

303 typer.echo(help_text) 

304 raise typer.Exit(0) 

305 

306 tokens = command_path or [] 

307 command = "help" 

308 effective_include_runtime = (verbose or debug) and not quiet 

309 effective_pretty = True if (debug and not quiet) else pretty 

310 fmt_lower = fmt.strip().lower() 

311 error_fmt = fmt_lower if fmt_lower in ("json", "yaml") else "json" 

312 

313 if quiet: 

314 if fmt_lower not in _VALID_FORMATS: 

315 raise SystemExit(2) 

316 

317 for token in tokens: 

318 if "\x00" in token: 

319 raise SystemExit(3) 

320 try: 

321 token.encode("ascii") 

322 except UnicodeEncodeError as err: 

323 raise SystemExit(3) from err 

324 

325 if contains_non_ascii_env(): 

326 raise SystemExit(3) 

327 

328 if not _find_target_command(ctx, tokens): 

329 raise SystemExit(2) 

330 

331 raise SystemExit(0) 

332 

333 if fmt_lower != "human": 

334 validate_common_flags( 

335 fmt, 

336 command, 

337 quiet, 

338 include_runtime=effective_include_runtime, 

339 ) 

340 

341 if fmt_lower not in _VALID_FORMATS: 

342 emit_error_and_exit( 

343 f"Unsupported format: '{fmt}'", 

344 code=2, 

345 failure="format", 

346 command=command, 

347 fmt=error_fmt, 

348 quiet=quiet, 

349 include_runtime=effective_include_runtime, 

350 debug=debug, 

351 ) 

352 

353 for token in tokens: 

354 if "\x00" in token: 

355 emit_error_and_exit( 

356 "Embedded null byte in command path", 

357 code=3, 

358 failure="null_byte", 

359 command=command, 

360 fmt=error_fmt, 

361 quiet=quiet, 

362 include_runtime=effective_include_runtime, 

363 debug=debug, 

364 ) 

365 try: 

366 token.encode("ascii") 

367 except UnicodeEncodeError: 

368 emit_error_and_exit( 

369 f"Non-ASCII characters in command path: {token!r}", 

370 code=3, 

371 failure="ascii", 

372 command=command, 

373 fmt=error_fmt, 

374 quiet=quiet, 

375 include_runtime=effective_include_runtime, 

376 debug=debug, 

377 ) 

378 

379 if contains_non_ascii_env(): 

380 emit_error_and_exit( 

381 "Non-ASCII in environment", 

382 code=3, 

383 failure="ascii", 

384 command=command, 

385 fmt=error_fmt, 

386 quiet=quiet, 

387 include_runtime=effective_include_runtime, 

388 debug=debug, 

389 ) 

390 

391 target = _find_target_command(ctx, tokens) 

392 if not target: 

393 emit_error_and_exit( 

394 f"No such command: {' '.join(tokens)}", 

395 code=2, 

396 failure="not_found", 

397 command=command, 

398 fmt=error_fmt, 

399 quiet=quiet, 

400 include_runtime=effective_include_runtime, 

401 debug=debug, 

402 ) 

403 

404 DIContainer.current().resolve(EmitterProtocol) 

405 target_cmd, target_ctx = target 

406 help_text = _get_formatted_help(target_cmd, target_ctx) 

407 

408 if fmt_lower == _HUMAN: 

409 typer.echo(help_text) 

410 raise typer.Exit(0) 

411 

412 try: 

413 payload = _build_help_payload(help_text, effective_include_runtime, started_at) 

414 except ValueError as exc: 

415 emit_error_and_exit( 

416 str(exc), 

417 code=3, 

418 failure="ascii", 

419 command=command, 

420 fmt=fmt_lower, 

421 quiet=quiet, 

422 include_runtime=effective_include_runtime, 

423 debug=debug, 

424 ) 

425 

426 output_format = OutputFormat.YAML if fmt_lower == "yaml" else OutputFormat.JSON 

427 emit_and_exit( 

428 payload=payload, 

429 fmt=output_format, 

430 effective_pretty=effective_pretty, 

431 verbose=verbose, 

432 debug=debug, 

433 quiet=quiet, 

434 command=command, 

435 exit_code=0, 

436 )