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

82 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"""Docs command for the Bijux CLI. 

5 

6Generates a machine-readable specification of the entire CLI, outputting it as 

7JSON or YAML. This command is designed for automation, enabling integration 

8with external documentation tools or APIs. It supports outputting to stdout or 

9a file and ensures all text is ASCII-safe. 

10 

11Output Contract: 

12 * Success (file): `{"status": "written", "file": "<path>"}` 

13 * Success (stdout): The raw specification string is printed directly. 

14 * Spec fields: `{"version": str, "commands": list, ...}` 

15 * Verbose: Adds `{"python": str, "platform": str}` to the spec. 

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

17 

18Exit Codes: 

19 * `0`: Success. 

20 * `1`: Fatal or internal error. 

21 * `2`: CLI argument, flag, or format error. 

22 * `3`: ASCII or encoding error. 

23""" 

24 

25from __future__ import annotations 

26 

27from collections.abc import Mapping 

28import os 

29from pathlib import Path 

30import platform 

31 

32import typer 

33import typer.core 

34 

35from bijux_cli.__version__ import __version__ 

36from bijux_cli.commands.utilities import ( 

37 contains_non_ascii_env, 

38 emit_and_exit, 

39 emit_error_and_exit, 

40 validate_common_flags, 

41) 

42from bijux_cli.core.constants import ( 

43 HELP_DEBUG, 

44 HELP_FORMAT, 

45 HELP_NO_PRETTY, 

46 HELP_QUIET, 

47 HELP_VERBOSE, 

48) 

49from bijux_cli.core.enums import OutputFormat 

50 

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

52 

53docs_app = typer.Typer( # pytype: skip-file 

54 name="docs", 

55 help="(-h, --help) Generate API specifications (OpenAPI-like) for Bijux CLI.", 

56 rich_markup_mode=None, 

57 context_settings={"help_option_names": ["-h", "--help"]}, 

58 no_args_is_help=False, 

59) 

60 

61CLI_VERSION = __version__ 

62 

63 

64def _default_output_path(base: Path, fmt: str) -> Path: 

65 """Computes the default output file path for a CLI spec. 

66 

67 Args: 

68 base (Path): The output directory path. 

69 fmt (str): The output format extension, either "json" or "yaml". 

70 

71 Returns: 

72 Path: The fully resolved path to the output specification file. 

73 """ 

74 return base / f"spec.{fmt}" 

75 

76 

77def _resolve_output_target(out: Path | None, fmt: str) -> tuple[str, Path | None]: 

78 """Resolves the output target and file path for the CLI spec. 

79 

80 Determines if the output should go to stdout or a file, resolving the 

81 final path if a directory is provided. 

82 

83 Args: 

84 out (Path | None): The user-provided output path, which can be a file, 

85 a directory, or '-' for stdout. 

86 fmt (str): The output format extension ("json" or "yaml"). 

87 

88 Returns: 

89 tuple[str, Path | None]: A tuple containing the target and path. The 

90 target is a string ("-" for stdout or a file path), and the path 

91 is the resolved `Path` object or `None` for stdout. 

92 """ 

93 if out is None: 

94 path = _default_output_path(Path.cwd(), fmt) 

95 return str(path), path 

96 if str(out) == "-": 

97 return "-", None 

98 if out.is_dir(): 

99 path = _default_output_path(out, fmt) 

100 return str(path), path 

101 return str(out), out 

102 

103 

104def _build_spec_payload(include_runtime: bool) -> Mapping[str, object]: 

105 """Builds the CLI specification payload. 

106 

107 Args: 

108 include_runtime (bool): If True, includes Python and platform metadata 

109 in the specification. 

110 

111 Returns: 

112 Mapping[str, object]: A dictionary containing the CLI version, a list 

113 of registered commands, and optional runtime details. 

114 

115 Raises: 

116 ValueError: If the version string or platform metadata contains 

117 non-ASCII characters. 

118 """ 

119 from bijux_cli.commands import list_registered_command_names 

120 from bijux_cli.commands.utilities import ascii_safe 

121 

122 version_str = ascii_safe(CLI_VERSION, "version") 

123 payload: dict[str, object] = { 

124 "version": version_str, 

125 "commands": list_registered_command_names(), 

126 } 

127 if include_runtime: 

128 payload["python"] = ascii_safe(platform.python_version(), "python_version") 

129 payload["platform"] = ascii_safe(platform.platform(), "platform") 

130 return payload 

131 

132 

133OUT_OPTION = typer.Option( 

134 None, 

135 "--out", 

136 "-o", 

137 help="Output file path or '-' for stdout. If a directory is given, a default file name is used.", 

138) 

139 

140 

141@docs_app.callback(invoke_without_command=True) 

142def docs( 

143 ctx: typer.Context, 

144 out: Path | None = OUT_OPTION, 

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

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

147 fmt: str = typer.Option("json", "-f", "--format", help=HELP_FORMAT), 

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

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

150) -> None: 

151 """Defines the entrypoint and logic for the `bijux docs` command. 

152 

153 This function orchestrates the entire specification generation process. It 

154 validates CLI flags, checks for ASCII-safe environment variables, resolves 

155 the output destination, builds the specification payload, and writes the 

156 result to a file or stdout. All errors are handled and emitted in a 

157 structured format before exiting with a specific code. 

158 

159 Args: 

160 ctx (typer.Context): The Typer context, used for managing command state. 

161 out (Path | None): The output destination: a file path, a directory, or 

162 '-' to signify stdout. 

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

164 verbose (bool): If True, includes Python and platform metadata in the spec. 

165 fmt (str): The output format, either "json" or "yaml". Defaults to "json". 

166 pretty (bool): If True, pretty-prints the output for human readability. 

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

168 and `pretty`. 

169 

170 Returns: 

171 None: 

172 

173 Raises: 

174 SystemExit: Exits the application with a contract-compliant status code 

175 and payload upon any error, including argument validation, ASCII 

176 violations, serialization failures, or I/O issues. 

177 """ 

178 from bijux_cli.commands.utilities import normalize_format 

179 from bijux_cli.infra.serializer import OrjsonSerializer, PyYAMLSerializer 

180 from bijux_cli.infra.telemetry import NullTelemetry 

181 

182 command = "docs" 

183 effective_include_runtime = (verbose or debug) and not quiet 

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

185 

186 fmt_lower = normalize_format(fmt) 

187 

188 if ctx.args: 

189 stray = ctx.args[0] 

190 msg = ( 

191 f"No such option: {stray}" 

192 if stray.startswith("-") 

193 else f"Too many arguments: {' '.join(ctx.args)}" 

194 ) 

195 emit_error_and_exit( 

196 msg, 

197 code=2, 

198 failure="args", 

199 command=command, 

200 fmt=fmt_lower, 

201 quiet=quiet, 

202 include_runtime=effective_include_runtime, 

203 debug=debug, 

204 ) 

205 

206 validate_common_flags( 

207 fmt, 

208 command, 

209 quiet, 

210 include_runtime=effective_include_runtime, 

211 ) 

212 

213 if contains_non_ascii_env(): 

214 emit_error_and_exit( 

215 "Non-ASCII characters in environment variables", 

216 code=3, 

217 failure="ascii_env", 

218 command=command, 

219 fmt=fmt_lower, 

220 quiet=quiet, 

221 include_runtime=effective_include_runtime, 

222 debug=debug, 

223 ) 

224 

225 out_env = os.environ.get("BIJUXCLI_DOCS_OUT") 

226 if out is None and out_env: 

227 out = Path(out_env) 

228 

229 target, path = _resolve_output_target(out, fmt_lower) 

230 

231 try: 

232 spec = _build_spec_payload(effective_include_runtime) 

233 except ValueError as exc: 

234 emit_error_and_exit( 

235 str(exc), 

236 code=3, 

237 failure="ascii", 

238 command=command, 

239 fmt=fmt_lower, 

240 quiet=quiet, 

241 include_runtime=effective_include_runtime, 

242 debug=debug, 

243 ) 

244 

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

246 serializer = ( 

247 PyYAMLSerializer(NullTelemetry()) 

248 if output_format is OutputFormat.YAML 

249 else OrjsonSerializer(NullTelemetry()) 

250 ) 

251 try: 

252 content = serializer.dumps(spec, fmt=output_format, pretty=effective_pretty) 

253 except Exception as exc: 

254 emit_error_and_exit( 

255 f"Serialization failed: {exc}", 

256 code=1, 

257 failure="serialize", 

258 command=command, 

259 fmt=fmt_lower, 

260 quiet=quiet, 

261 include_runtime=effective_include_runtime, 

262 debug=debug, 

263 ) 

264 

265 if os.environ.get("BIJUXCLI_TEST_IO_FAIL") == "1": 

266 emit_error_and_exit( 

267 "Simulated I/O failure for test", 

268 code=1, 

269 failure="io_fail", 

270 command=command, 

271 fmt=fmt_lower, 

272 quiet=quiet, 

273 include_runtime=effective_include_runtime, 

274 debug=debug, 

275 ) 

276 

277 if target == "-": 

278 if not quiet: 

279 typer.echo(content) 

280 raise typer.Exit(0) 

281 

282 if path is None: 

283 emit_error_and_exit( 

284 "Internal error: expected non-null output path", 

285 code=1, 

286 failure="internal", 

287 command=command, 

288 fmt=fmt_lower, 

289 quiet=quiet, 

290 include_runtime=effective_include_runtime, 

291 debug=debug, 

292 ) 

293 

294 parent = path.parent 

295 if not parent.exists(): 

296 emit_error_and_exit( 

297 f"Output directory does not exist: {parent}", 

298 code=2, 

299 failure="output_dir", 

300 command=command, 

301 fmt=fmt_lower, 

302 quiet=quiet, 

303 include_runtime=effective_include_runtime, 

304 debug=debug, 

305 ) 

306 

307 try: 

308 path.write_text(content, encoding="utf-8") 

309 except Exception as exc: 

310 emit_error_and_exit( 

311 f"Failed to write spec: {exc}", 

312 code=2, 

313 failure="write", 

314 command=command, 

315 fmt=fmt_lower, 

316 quiet=quiet, 

317 include_runtime=effective_include_runtime, 

318 debug=debug, 

319 ) 

320 

321 emit_and_exit( 

322 {"status": "written", "file": str(path)}, 

323 output_format, 

324 effective_pretty, 

325 verbose, 

326 debug, 

327 quiet, 

328 command, 

329 )