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

69 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"""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 * Error: `{"error": str, "code": int}` 

16 

17Exit Codes: 

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

19 * `1`: Unexpected/internal error. 

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

21 * `3`: ASCII/encoding error. 

22""" 

23 

24from __future__ import annotations 

25 

26import os 

27from pathlib import Path 

28import platform 

29 

30import typer 

31 

32from bijux_cli.cli.core.command import ( 

33 ascii_safe, 

34 contains_non_ascii_env, 

35 new_run_command, 

36 normalize_format, 

37 raise_exit_intent, 

38 validate_common_flags, 

39 validate_env_file_if_present, 

40) 

41from bijux_cli.cli.core.constants import ( 

42 ENV_CONFIG, 

43 OPT_FORMAT, 

44 OPT_LOG_LEVEL, 

45 OPT_PRETTY, 

46 OPT_QUIET, 

47) 

48from bijux_cli.cli.core.help_text import ( 

49 HELP_FORMAT, 

50 HELP_LOG_LEVEL, 

51 HELP_NO_PRETTY, 

52 HELP_QUIET, 

53) 

54from bijux_cli.core.di import DIContainer 

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

56from bijux_cli.core.exit_policy import ExitIntentError 

57from bijux_cli.core.precedence import current_execution_policy 

58from bijux_cli.core.runtime import AsyncTyper 

59from bijux_cli.infra.contracts import Emitter 

60 

61typer.core.rich = None # type: ignore[attr-defined] 

62 

63audit_app = AsyncTyper( 

64 name="audit", 

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

66 rich_markup_mode=None, 

67 context_settings={ 

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

69 "ignore_unknown_options": True, 

70 "allow_extra_args": True, 

71 }, 

72 no_args_is_help=False, 

73) 

74 

75 

76OUTPUT_OPTION = typer.Option( 

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

78) 

79DRY_RUN_OPTION = typer.Option( 

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

81) 

82 

83 

84def _build_payload(include_runtime: bool, dry_run: bool) -> dict[str, object]: 

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

86 

87 Args: 

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

89 platform) is included in the payload. 

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

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

92 

93 Returns: 

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

95 """ 

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

97 if include_runtime: 

98 return { 

99 "status": payload["status"], 

100 "python": ascii_safe(platform.python_version(), "python_version"), 

101 "platform": ascii_safe(platform.platform(), "platform"), 

102 } 

103 return payload 

104 

105 

106def _write_output_file( 

107 output_path: Path, 

108 payload: object, 

109 emitter: Emitter, 

110 fmt: OutputFormat, 

111 pretty: bool, 

112 emit_diagnostics: bool, 

113 dry_run: bool, 

114) -> None: 

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

116 

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

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

119 

120 Args: 

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

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

123 emitter (Emitter): The service responsible for serialization and 

124 output. 

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

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

127 emit_diagnostics (bool): Whether diagnostics should be emitted. 

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

129 

130 Returns: 

131 None: 

132 

133 Raises: 

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

135 """ 

136 if not output_path.parent.exists(): 

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

138 

139 emitter.emit( 

140 payload, 

141 fmt=fmt, 

142 pretty=pretty, 

143 level=LogLevel.INFO, 

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

145 output=str(output_path), 

146 emit_output=True, 

147 emit_diagnostics=emit_diagnostics, 

148 ) 

149 

150 

151@audit_app.callback(invoke_without_command=True) 

152def audit( 

153 ctx: typer.Context, 

154 dry_run: bool = DRY_RUN_OPTION, 

155 output: Path | None = OUTPUT_OPTION, 

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

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

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

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

160) -> None: 

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

162 

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

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

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

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

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

168 

169 Args: 

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

171 and detect stray arguments. 

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

173 status without performing actions. 

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

175 to the specified file instead of stdout. 

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

177 exit code is the primary indicator of the outcome. 

178 output payload. 

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

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

181 This is overridden by `log_level`. 

182 log_level (str): Logging level; determines diagnostics and verbosity. 

183 and `pretty`. 

184 

185 Returns: 

186 None: 

187 

188 Raises: 

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

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

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

192 contract defined in the module docstring. 

193 """ 

194 if ctx.invoked_subcommand: 

195 return 

196 

197 command = "audit" 

198 policy = current_execution_policy() 

199 include_runtime = policy.include_runtime 

200 effective_pretty = policy.pretty 

201 log_level_value = policy.log_level 

202 quiet = policy.quiet 

203 fmt_lower = validate_common_flags( 

204 fmt, 

205 command, 

206 quiet, 

207 include_runtime=include_runtime, 

208 log_level=log_level_value, 

209 ) 

210 

211 try: 

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

213 if stray_args: 

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

215 out_format = fmt_lower 

216 if contains_non_ascii_env(): 

217 raise_exit_intent( 

218 "Non-ASCII environment variables detected", 

219 code=3, 

220 failure="ascii_env", 

221 command=command, 

222 fmt=fmt_lower, 

223 quiet=quiet, 

224 include_runtime=include_runtime, 

225 error_type=ErrorType.ASCII, 

226 log_level=log_level_value, 

227 ) 

228 try: 

229 validate_env_file_if_present(os.environ.get(ENV_CONFIG, "")) 

230 except ValueError as exc: 

231 raise_exit_intent( 

232 str(exc), 

233 code=3, 

234 failure="ascii", 

235 command=command, 

236 fmt=fmt_lower, 

237 quiet=quiet, 

238 include_runtime=include_runtime, 

239 error_type=ErrorType.ASCII, 

240 log_level=log_level_value, 

241 ) 

242 

243 except typer.BadParameter as exc: 

244 error_fmt = normalize_format(fmt) or OutputFormat.JSON 

245 raise_exit_intent( 

246 exc.message, 

247 code=2, 

248 failure="args", 

249 command=command, 

250 fmt=error_fmt, 

251 quiet=quiet, 

252 include_runtime=include_runtime, 

253 error_type=ErrorType.USAGE, 

254 log_level=log_level_value, 

255 ) 

256 

257 try: 

258 emitter = DIContainer.current().resolve(Emitter) 

259 payload = _build_payload(include_runtime, dry_run) 

260 

261 if output is not None: 

262 _write_output_file( 

263 output_path=output, 

264 payload=payload, 

265 emitter=emitter, 

266 fmt=out_format, 

267 pretty=effective_pretty, 

268 emit_diagnostics=policy.log_policy.show_internal, 

269 dry_run=dry_run, 

270 ) 

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

272 if include_runtime: 

273 payload = { 

274 "status": payload["status"], 

275 "file": payload["file"], 

276 "python": ascii_safe(platform.python_version(), "python_version"), 

277 "platform": ascii_safe(platform.platform(), "platform"), 

278 } 

279 

280 new_run_command( 

281 command_name=command, 

282 payload_builder=lambda _: payload, 

283 quiet=quiet, 

284 fmt=fmt_lower, 

285 pretty=effective_pretty, 

286 log_level=log_level_value, 

287 ) 

288 

289 except ValueError as exc: 

290 raise_exit_intent( 

291 str(exc), 

292 code=3, 

293 failure="ascii", 

294 command=command, 

295 fmt=fmt_lower, 

296 quiet=quiet, 

297 include_runtime=include_runtime, 

298 error_type=ErrorType.ASCII, 

299 log_level=log_level_value, 

300 ) 

301 except OSError as exc: 

302 raise_exit_intent( 

303 str(exc), 

304 code=2, 

305 failure="output_file", 

306 command=command, 

307 fmt=fmt_lower, 

308 quiet=quiet, 

309 include_runtime=include_runtime, 

310 error_type=ErrorType.USER_INPUT, 

311 log_level=log_level_value, 

312 ) 

313 except (typer.Exit, ExitIntentError): 

314 raise 

315 except Exception as exc: 

316 raise_exit_intent( 

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

318 code=1, 

319 failure="unexpected", 

320 command=command, 

321 fmt=fmt_lower, 

322 quiet=quiet, 

323 include_runtime=include_runtime, 

324 error_type=ErrorType.INTERNAL, 

325 log_level=log_level_value, 

326 )