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

63 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"""Audit command for the Bijux CLI. 

5 

6Audits the current environment and configuration, emitting machine-readable structured 

7output in JSON or YAML. Supports dry-run simulation and writing results to a file. 

8Handles ASCII hygiene and structured error contracts. Output is automation-safe and 

9suitable for scripting or monitoring. 

10 

11Output Contract: 

12 * Success: `{"status": "completed"}` 

13 * Dry-run: `{"status": "dry-run"}` 

14 * Written: `{"status": "written", "file": "<path>"}` 

15 * Verbose: `{"python": str, "platform": str}` 

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

17 

18Exit Codes: 

19 * `0`: Success, dry-run, or write success. 

20 * `1`: Unexpected/internal error. 

21 * `2`: CLI argument/flag/format or output-path error. 

22 * `3`: ASCII/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 

33 

34from bijux_cli.commands.utilities import ( 

35 ascii_safe, 

36 contains_non_ascii_env, 

37 emit_error_and_exit, 

38 new_run_command, 

39 validate_common_flags, 

40 validate_env_file_if_present, 

41) 

42from bijux_cli.contracts import EmitterProtocol 

43from bijux_cli.core.constants import ( 

44 HELP_DEBUG, 

45 HELP_FORMAT, 

46 HELP_NO_PRETTY, 

47 HELP_QUIET, 

48 HELP_VERBOSE, 

49) 

50from bijux_cli.core.di import DIContainer 

51from bijux_cli.core.enums import OutputFormat 

52 

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

54 

55audit_app = typer.Typer( # pytype: skip-file 

56 name="audit", 

57 help="Audit the current environment for configuration and state issues.", 

58 rich_markup_mode=None, 

59 context_settings={ 

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

61 "ignore_unknown_options": True, 

62 "allow_extra_args": True, 

63 }, 

64 no_args_is_help=False, 

65) 

66 

67 

68OUTPUT_OPTION = typer.Option( 

69 None, "--output", "-o", help="Write output to file instead of stdout." 

70) 

71DRY_RUN_OPTION = typer.Option( 

72 False, "--dry-run", help="Simulate audit without making changes." 

73) 

74 

75 

76def _build_payload(include_runtime: bool, dry_run: bool) -> Mapping[str, object]: 

77 """Builds the structured result payload for the audit command. 

78 

79 Args: 

80 include_runtime (bool): If True, runtime metadata (Python version, 

81 platform) is included in the payload. 

82 dry_run (bool): If True, indicates the audit is a simulation, which 

83 sets the status field in the payload to "dry-run". 

84 

85 Returns: 

86 Mapping[str, object]: A dictionary containing the structured audit results. 

87 """ 

88 payload: dict[str, object] = {"status": "dry-run" if dry_run else "completed"} 

89 if include_runtime: 

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

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

92 return payload 

93 

94 

95def _write_output_file( 

96 output_path: Path, 

97 payload: Mapping[str, object], 

98 emitter: EmitterProtocol, 

99 fmt: OutputFormat, 

100 pretty: bool, 

101 debug: bool, 

102 dry_run: bool, 

103) -> None: 

104 """Writes the audit payload to a specified file. 

105 

106 This function serializes the payload to JSON or YAML and writes it to the 

107 given file path. It will fail if the parent directory does not exist. 

108 

109 Args: 

110 output_path (Path): The file path where the payload will be written. 

111 payload (Mapping[str, object]): The data to serialize and write. 

112 emitter (EmitterProtocol): The service responsible for serialization and 

113 output. 

114 fmt (OutputFormat): The desired output format (JSON or YAML). 

115 pretty (bool): If True, the output is formatted for human readability. 

116 debug (bool): If True, enables debug-level logging during emission. 

117 dry_run (bool): If True, logs a message indicating a dry run. 

118 

119 Returns: 

120 None: 

121 

122 Raises: 

123 OSError: If the parent directory of `output_path` does not exist. 

124 """ 

125 if not output_path.parent.exists(): 

126 raise OSError(f"Output directory does not exist: {output_path.parent}") 

127 

128 emitter.emit( 

129 payload, 

130 fmt=fmt, 

131 pretty=pretty, 

132 message="Audit dry-run completed" if dry_run else "Audit completed", 

133 debug=debug, 

134 output=str(output_path), 

135 quiet=False, 

136 ) 

137 

138 

139@audit_app.callback(invoke_without_command=True) 

140def audit( 

141 ctx: typer.Context, 

142 dry_run: bool = DRY_RUN_OPTION, 

143 output: Path | None = OUTPUT_OPTION, 

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

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

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

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

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

149) -> None: 

150 """Defines the entrypoint and logic for the `bijux audit` command. 

151 

152 This function orchestrates the entire audit process. It validates all CLI 

153 flags and arguments, performs environment checks (e.g., for non-ASCII 

154 characters), builds the appropriate result payload, and emits it to 

155 stdout or a file in the specified format. All errors are handled and 

156 emitted in a structured format before exiting with a specific code. 

157 

158 Args: 

159 ctx (typer.Context): The Typer context, used to manage command state 

160 and detect stray arguments. 

161 dry_run (bool): If True, simulates the audit and reports a "dry-run" 

162 status without performing actions. 

163 output (Path | None): If a path is provided, writes the audit result 

164 to the specified file instead of stdout. 

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

166 exit code is the primary indicator of the outcome. 

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

168 output payload. 

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

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

171 This is overridden by `debug`. 

172 debug (bool): If True, enables debug diagnostics, which implies `verbose` 

173 and `pretty`. 

174 

175 Returns: 

176 None: 

177 

178 Raises: 

179 SystemExit: Exits with a status code and structured error payload upon 

180 validation failures (e.g., bad arguments, ASCII errors), I/O 

181 issues, or unexpected exceptions. The exit code follows the 

182 contract defined in the module docstring. 

183 """ 

184 if ctx.invoked_subcommand: 

185 return 

186 

187 command = "audit" 

188 

189 try: 

190 stray_args = [a for a in ctx.args if not a.startswith("-")] 

191 if stray_args: 

192 raise typer.BadParameter(f"No such argument: {stray_args[0]}") 

193 fmt_lower = validate_common_flags(fmt, command, quiet) 

194 include_runtime = verbose or debug 

195 effective_pretty = debug or pretty 

196 out_format = OutputFormat.YAML if fmt_lower == "yaml" else OutputFormat.JSON 

197 

198 if contains_non_ascii_env(): 

199 emit_error_and_exit( 

200 "Non-ASCII environment variables detected", 

201 code=3, 

202 failure="ascii_env", 

203 command=command, 

204 fmt=fmt_lower, 

205 quiet=quiet, 

206 include_runtime=include_runtime, 

207 ) 

208 try: 

209 validate_env_file_if_present(os.environ.get("BIJUXCLI_CONFIG", "")) 

210 except ValueError as exc: 

211 emit_error_and_exit( 

212 str(exc), 

213 code=3, 

214 failure="ascii", 

215 command=command, 

216 fmt=fmt_lower, 

217 quiet=quiet, 

218 include_runtime=include_runtime, 

219 ) 

220 

221 except typer.BadParameter as exc: 

222 error_fmt = fmt.lower() if fmt.lower() in ("json", "yaml") else "json" 

223 emit_error_and_exit( 

224 exc.message, 

225 code=2, 

226 failure="args", 

227 command=command, 

228 fmt=error_fmt, 

229 quiet=quiet, 

230 include_runtime=verbose or debug, 

231 ) 

232 

233 try: 

234 emitter = DIContainer.current().resolve(EmitterProtocol) 

235 payload = _build_payload(include_runtime, dry_run) 

236 

237 if output is not None: 

238 _write_output_file( 

239 output_path=output, 

240 payload=payload, 

241 emitter=emitter, 

242 fmt=out_format, 

243 pretty=effective_pretty, 

244 debug=debug, 

245 dry_run=dry_run, 

246 ) 

247 payload = {"status": "written", "file": str(output)} 

248 if include_runtime: 

249 payload["python"] = ascii_safe( 

250 platform.python_version(), "python_version" 

251 ) 

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

253 

254 new_run_command( 

255 command_name=command, 

256 payload_builder=lambda _: payload, 

257 quiet=quiet, 

258 verbose=(verbose or debug), 

259 fmt=fmt_lower, 

260 pretty=(debug or pretty), 

261 debug=debug, 

262 ) 

263 

264 except ValueError as exc: 

265 emit_error_and_exit( 

266 str(exc), 

267 code=3, 

268 failure="ascii", 

269 command=command, 

270 fmt=fmt_lower, 

271 quiet=quiet, 

272 include_runtime=include_runtime, 

273 ) 

274 except OSError as exc: 

275 emit_error_and_exit( 

276 str(exc), 

277 code=2, 

278 failure="output_file", 

279 command=command, 

280 fmt=fmt_lower, 

281 quiet=quiet, 

282 include_runtime=include_runtime, 

283 ) 

284 except Exception as exc: 

285 emit_error_and_exit( 

286 f"An unexpected error occurred: {exc}", 

287 code=1, 

288 failure="unexpected", 

289 command=command, 

290 fmt=fmt_lower, 

291 quiet=quiet, 

292 include_runtime=include_runtime, 

293 )