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

76 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 `dev di` subcommand for the Bijux CLI. 

5 

6This module provides a developer-focused command to introspect the internal 

7Dependency Injection (DI) container. It outputs a graph of all registered 

8service and factory protocols, which is useful for debugging the application's 

9architecture and service resolution. 

10 

11Output Contract: 

12 * Success: `{"factories": list, "services": list}` 

13 * Verbose: Adds `{"python": str, "platform": str}` to the payload. 

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

15 

16Exit Codes: 

17 * `0`: Success. 

18 * `1`: A fatal internal error occurred (e.g., during serialization). 

19 * `2`: An invalid argument or environment setting was provided (e.g., 

20 bad output path, unreadable config, invalid limit). 

21 * `3`: An ASCII or encoding error was detected in the environment. 

22""" 

23 

24from __future__ import annotations 

25 

26import json 

27import os 

28from pathlib import Path 

29import platform 

30from typing import Any 

31 

32import typer 

33import yaml 

34 

35from bijux_cli.commands.utilities import ( 

36 ascii_safe, 

37 emit_error_and_exit, 

38 new_run_command, 

39 validate_common_flags, 

40) 

41from bijux_cli.core.constants import ( 

42 HELP_DEBUG, 

43 HELP_FORMAT, 

44 HELP_NO_PRETTY, 

45 HELP_QUIET, 

46 HELP_VERBOSE, 

47) 

48from bijux_cli.core.di import DIContainer 

49 

50QUIET_OPTION = typer.Option(False, "-q", "--quiet", help=HELP_QUIET) 

51VERBOSE_OPTION = typer.Option(False, "-v", "--verbose", help=HELP_VERBOSE) 

52FORMAT_OPTION = typer.Option("json", "-f", "--format", help=HELP_FORMAT) 

53PRETTY_OPTION = typer.Option(True, "--pretty/--no-pretty", help=HELP_NO_PRETTY) 

54DEBUG_OPTION = typer.Option(False, "-d", "--debug", help=HELP_DEBUG) 

55OUTPUT_OPTION = typer.Option( 

56 None, 

57 "-o", 

58 "--output", 

59 help="Write result to file(s). May be provided multiple times.", 

60) 

61 

62 

63def _key_to_name(key: object) -> str: 

64 """Converts a DI container key to its string name for serialization. 

65 

66 Args: 

67 key (object): The key to convert, typically a class type or string. 

68 

69 Returns: 

70 str: The string representation of the key. 

71 """ 

72 if isinstance(key, str): 

73 return key 

74 name = getattr(key, "__name__", None) 

75 return str(name) if name else str(key) 

76 

77 

78def _build_dev_di_payload(include_runtime: bool) -> dict[str, Any]: 

79 """Builds the DI graph payload for structured output. 

80 

81 Args: 

82 include_runtime (bool): If True, includes Python and platform runtime 

83 metadata in the payload. 

84 

85 Returns: 

86 dict[str, Any]: A dictionary containing lists of registered 'factories' 

87 and 'services', along with optional runtime information. 

88 """ 

89 di = DIContainer.current() 

90 

91 factories = [ 

92 {"protocol": _key_to_name(protocol), "alias": alias} 

93 for protocol, alias in di.factories() 

94 ] 

95 services = [ 

96 {"protocol": _key_to_name(protocol), "alias": alias, "implementation": None} 

97 for protocol, alias in di.services() 

98 ] 

99 

100 payload: dict[str, Any] = {"factories": factories, "services": services} 

101 if include_runtime: 

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

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

104 return payload 

105 

106 

107def dev_di_graph( 

108 quiet: bool = QUIET_OPTION, 

109 verbose: bool = VERBOSE_OPTION, 

110 fmt: str = FORMAT_OPTION, 

111 pretty: bool = PRETTY_OPTION, 

112 debug: bool = DEBUG_OPTION, 

113 output: list[Path] = OUTPUT_OPTION, 

114) -> None: 

115 """Generates and outputs the Dependency Injection (DI) container graph. 

116 

117 This developer tool inspects the DI container, validates environment 

118 settings, and outputs the registration graph to stdout and/or one or more 

119 files. 

120 

121 Args: 

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

123 verbose (bool): If True, includes Python/platform details in the output. 

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

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

126 debug (bool): If True, enables debug diagnostics. 

127 output (list[Path]): A list of file paths to write the output to. 

128 

129 Returns: 

130 None: 

131 

132 Raises: 

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

134 payload, indicating success or detailing an error. 

135 """ 

136 command = "dev di" 

137 effective_include_runtime = (verbose or debug) and not quiet 

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

139 

140 fmt_lower = validate_common_flags( 

141 fmt, 

142 command, 

143 quiet, 

144 include_runtime=effective_include_runtime, 

145 ) 

146 

147 limit_env = os.environ.get("BIJUXCLI_DI_LIMIT") 

148 limit: int | None = None 

149 if limit_env is not None: 

150 try: 

151 limit = int(limit_env) 

152 if limit < 0: 

153 emit_error_and_exit( 

154 f"Invalid BIJUXCLI_DI_LIMIT value: '{limit_env}'", 

155 code=2, 

156 failure="limit", 

157 command=command, 

158 fmt=fmt_lower, 

159 quiet=quiet, 

160 include_runtime=effective_include_runtime, 

161 debug=debug, 

162 ) 

163 except (ValueError, TypeError): 

164 emit_error_and_exit( 

165 f"Invalid BIJUXCLI_DI_LIMIT value: '{limit_env}'", 

166 code=2, 

167 failure="limit", 

168 command=command, 

169 fmt=fmt_lower, 

170 quiet=quiet, 

171 include_runtime=effective_include_runtime, 

172 debug=debug, 

173 ) 

174 

175 config_env = os.environ.get("BIJUXCLI_CONFIG") 

176 if config_env and not config_env.isascii(): 

177 emit_error_and_exit( 

178 f"Config path contains non-ASCII characters: {config_env!r}", 

179 code=3, 

180 failure="ascii", 

181 command=command, 

182 fmt=fmt_lower, 

183 quiet=quiet, 

184 include_runtime=effective_include_runtime, 

185 debug=debug, 

186 ) 

187 

188 if config_env: 

189 cfg_path = Path(config_env) 

190 if cfg_path.exists() and not os.access(cfg_path, os.R_OK): 

191 emit_error_and_exit( 

192 f"Config path not readable: {cfg_path}", 

193 code=2, 

194 failure="config_unreadable", 

195 command=command, 

196 fmt=fmt_lower, 

197 quiet=quiet, 

198 include_runtime=effective_include_runtime, 

199 debug=debug, 

200 ) 

201 

202 try: 

203 payload = _build_dev_di_payload(effective_include_runtime) 

204 if limit is not None: 

205 payload["factories"] = payload["factories"][:limit] 

206 payload["services"] = payload["services"][:limit] 

207 except ValueError as exc: 

208 emit_error_and_exit( 

209 str(exc), 

210 code=3, 

211 failure="ascii", 

212 command=command, 

213 fmt=fmt_lower, 

214 quiet=quiet, 

215 include_runtime=effective_include_runtime, 

216 debug=debug, 

217 ) 

218 

219 outputs = output 

220 if outputs: 

221 for p in outputs: 

222 if p.is_dir(): 

223 emit_error_and_exit( 

224 f"Output path is a directory: {p}", 

225 code=2, 

226 failure="output_dir", 

227 command=command, 

228 fmt=fmt_lower, 

229 quiet=quiet, 

230 include_runtime=effective_include_runtime, 

231 debug=debug, 

232 ) 

233 p.parent.mkdir(parents=True, exist_ok=True) 

234 try: 

235 if fmt_lower == "json": 

236 p.write_text( 

237 json.dumps(payload, indent=2 if effective_pretty else None) 

238 + "\n", 

239 encoding="utf-8", 

240 ) 

241 else: 

242 p.write_text( 

243 yaml.safe_dump( 

244 payload, 

245 default_flow_style=False, 

246 indent=2 if effective_pretty else None, 

247 ), 

248 encoding="utf-8", 

249 ) 

250 except OSError as exc: 

251 emit_error_and_exit( 

252 f"Failed to write output file '{p}': {exc}", 

253 code=2, 

254 failure="output_write", 

255 command=command, 

256 fmt=fmt_lower, 

257 quiet=quiet, 

258 include_runtime=effective_include_runtime, 

259 debug=debug, 

260 ) 

261 

262 if quiet: 

263 raise typer.Exit(0) 

264 

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

266 emit_error_and_exit( 

267 "Forced serialization failure", 

268 code=1, 

269 failure="serialize", 

270 command=command, 

271 fmt=fmt_lower, 

272 quiet=quiet, 

273 include_runtime=effective_include_runtime, 

274 debug=debug, 

275 ) 

276 

277 new_run_command( 

278 command_name=command, 

279 payload_builder=lambda _: payload, 

280 quiet=quiet, 

281 verbose=effective_include_runtime, 

282 fmt=fmt_lower, 

283 pretty=effective_pretty, 

284 debug=debug, 

285 )