Coverage for  / home / runner / work / bijux-cli / bijux-cli / src / bijux_cli / cli / core / command.py: 97%

132 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"""Command helpers for policy-aware execution and exit intents.""" 

5 

6from __future__ import annotations 

7 

8import os 

9from pathlib import Path 

10import re 

11import sys 

12from typing import Any, NoReturn 

13 

14from bijux_cli.cli.core.constants import ENV_CONFIG, ENV_PREFIX 

15from bijux_cli.core.enums import ErrorType, ExitCode, LogLevel, OutputFormat 

16from bijux_cli.core.exit_policy import ExitIntent, ExitIntentError 

17from bijux_cli.core.precedence import current_execution_policy, resolve_exit_intent 

18from bijux_cli.infra.contracts import Emitter, Serializer 

19 

20_ALLOWED_CTRL = {"\n", "\r", "\t"} 

21_ENV_LINE_RX = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*=[A-Za-z0-9_./\\-]*$") 

22 

23 

24def record_history(command: str, exit_code: int) -> None: 

25 """Record a history entry, ignoring failures.""" 

26 if command == "history": 

27 return 

28 try: 

29 from bijux_cli.core.di import DIContainer 

30 from bijux_cli.services.history.contracts import HistoryProtocol 

31 

32 hist = DIContainer.current().resolve(HistoryProtocol) 

33 hist.add( 

34 command=command, 

35 params=[], 

36 success=(exit_code == 0), 

37 return_code=exit_code, 

38 duration_ms=0.0, 

39 ) 

40 except PermissionError as exc: 

41 print(f"Permission denied writing history: {exc}", file=sys.stderr) 

42 except OSError as exc: 

43 import errno as _errno 

44 

45 if exc.errno in (_errno.EACCES, _errno.EPERM): 

46 print(f"Permission denied writing history: {exc}", file=sys.stderr) 

47 elif exc.errno in (_errno.ENOSPC, _errno.EDQUOT): 

48 print( 

49 f"No space left on device while writing history: {exc}", 

50 file=sys.stderr, 

51 ) 

52 else: 

53 print(f"Error writing history: {exc}", file=sys.stderr) 

54 except Exception as exc: 

55 print(f"Error writing history: {exc}", file=sys.stderr) 

56 

57 

58def new_run_command( 

59 command_name: str, 

60 payload_builder: Any, 

61 quiet: bool, 

62 fmt: OutputFormat, 

63 pretty: bool, 

64 log_level: str, 

65 exit_code: int = 0, 

66) -> NoReturn: 

67 """Build a payload and raise an ExitIntentError with resolved behavior.""" 

68 from bijux_cli.core.di import DIContainer 

69 from bijux_cli.infra.contracts import Emitter 

70 from bijux_cli.services.contracts import TelemetryProtocol 

71 

72 _ = (quiet, fmt, pretty, log_level) 

73 DIContainer.current().resolve(Emitter) 

74 DIContainer.current().resolve(TelemetryProtocol) 

75 

76 resolved = current_execution_policy() 

77 include_runtime = resolved.include_runtime 

78 output_format = validate_common_flags( 

79 fmt, 

80 command_name, 

81 resolved.quiet, 

82 include_runtime=include_runtime, 

83 log_level=resolved.log_level, 

84 ) 

85 effective_pretty = resolved.pretty 

86 try: 

87 payload = payload_builder(include_runtime) 

88 except ValueError as exc: 

89 intent = resolve_exit_intent( 

90 message=str(exc), 

91 code=2, 

92 failure="ascii", 

93 command=command_name, 

94 fmt=output_format, 

95 quiet=resolved.quiet, 

96 include_runtime=include_runtime, 

97 error_type=ErrorType.ASCII, 

98 log_level=resolved.log_level, 

99 ) 

100 raise ExitIntentError(intent) from exc 

101 

102 record_history(command_name, exit_code) 

103 

104 if resolved.quiet: 

105 intent = ExitIntent( 

106 code=ExitCode(exit_code), 

107 stream=None, 

108 payload=None, 

109 fmt=output_format, 

110 pretty=effective_pretty, 

111 show_traceback=False, 

112 ) 

113 raise ExitIntentError(intent) 

114 

115 intent = ExitIntent( 

116 code=ExitCode(exit_code), 

117 stream="stdout", 

118 payload=payload, 

119 fmt=output_format, 

120 pretty=effective_pretty, 

121 show_traceback=False, 

122 ) 

123 raise ExitIntentError(intent) 

124 

125 

126def raise_exit_intent(*args: Any, **kwargs: Any) -> NoReturn: 

127 """Raise an ExitIntentError from resolved error intent.""" 

128 if args: 128 ↛ 132line 128 didn't jump to line 132 because the condition on line 128 was always true

129 if len(args) != 1: 

130 raise TypeError("raise_exit_intent accepts at most one positional arg") 

131 kwargs["message"] = args[0] 

132 raise ExitIntentError(resolve_exit_intent(**kwargs)) 

133 

134 

135def resolve_serializer() -> Serializer: 

136 """Resolve the serializer adapter.""" 

137 from bijux_cli.core.di import DIContainer 

138 

139 serializer = DIContainer.current().resolve(Serializer) 

140 if not hasattr(serializer, "dumps"): 

141 raise RuntimeError("Serializer does not implement dumps()") 

142 return serializer 

143 

144 

145def resolve_emitter() -> Emitter | None: 

146 """Resolve the emitter adapter or return None.""" 

147 from bijux_cli.core.di import DIContainer 

148 

149 try: 

150 return DIContainer.current().resolve(Emitter) 

151 except Exception: 

152 return None 

153 

154 

155def emit_payload( 

156 payload: object, 

157 *, 

158 serializer: Serializer, 

159 emitter: Emitter | None, 

160 fmt: OutputFormat, 

161 pretty: bool, 

162 stream: str, 

163) -> None: 

164 """Emit a payload to the requested stream.""" 

165 out = sys.stdout if stream == "stdout" else sys.stderr 

166 _ = emitter 

167 output = serializer.dumps(payload, fmt=fmt, pretty=pretty).rstrip("\n") 

168 print(output, file=out, flush=True) 

169 

170 

171def ascii_safe(text: Any, _field: str = "") -> str: 

172 """Return a printable ASCII-only string.""" 

173 text_str = text if isinstance(text, str) else str(text) 

174 return "".join( 

175 ch if (32 <= ord(ch) <= 126) or ch in _ALLOWED_CTRL else "?" for ch in text_str 

176 ) 

177 

178 

179def normalize_format(fmt: str | OutputFormat | None) -> OutputFormat | None: 

180 """Normalize a format value into OutputFormat.""" 

181 if isinstance(fmt, OutputFormat): 

182 return fmt 

183 if isinstance(fmt, str): 

184 value = fmt.strip().lower() 

185 if value in ("json", "yaml"): 

186 return OutputFormat(value) 

187 return None 

188 

189 

190def contains_non_ascii_env() -> bool: 

191 """Return True when config env or file contents are non-ASCII.""" 

192 config_path_str = os.environ.get(ENV_CONFIG) 

193 if config_path_str: 

194 if not config_path_str.isascii(): 

195 return True 

196 try: 

197 config_path = Path(config_path_str) 

198 except NotImplementedError: 

199 return False 

200 if config_path.exists(): 200 ↛ 208line 200 didn't jump to line 208 because the condition on line 200 was always true

201 try: 

202 config_path.read_text(encoding="ascii") 

203 except UnicodeDecodeError: 

204 return True 

205 except (IsADirectoryError, PermissionError, FileNotFoundError, OSError): 

206 pass 

207 

208 for k, v in os.environ.items(): 

209 if k.startswith(ENV_PREFIX) and not v.isascii(): 

210 return True 

211 return False 

212 

213 

214def validate_common_flags( 

215 fmt: str | OutputFormat, 

216 command: str, 

217 quiet: bool, 

218 include_runtime: bool = False, 

219 log_level: LogLevel = LogLevel.INFO, 

220) -> OutputFormat: 

221 """Validate output format and ASCII environment.""" 

222 format_value = normalize_format(fmt) 

223 if format_value is None: 

224 intent = resolve_exit_intent( 

225 message=f"Unsupported format: {fmt}", 

226 code=ExitCode.USAGE, 

227 failure="format", 

228 command=command, 

229 fmt=OutputFormat.JSON, 

230 quiet=quiet, 

231 include_runtime=include_runtime, 

232 error_type=ErrorType.USAGE, 

233 log_level=log_level, 

234 ) 

235 raise ExitIntentError(intent) 

236 if format_value not in (OutputFormat.JSON, OutputFormat.YAML): 236 ↛ 237line 236 didn't jump to line 237 because the condition on line 236 was never true

237 intent = resolve_exit_intent( 

238 message="Invalid output format.", 

239 code=ExitCode.USAGE, 

240 failure="format", 

241 command=command, 

242 fmt=format_value, 

243 quiet=quiet, 

244 include_runtime=include_runtime, 

245 error_type=ErrorType.USAGE, 

246 log_level=log_level, 

247 ) 

248 raise ExitIntentError(intent) 

249 

250 if contains_non_ascii_env(): 

251 intent = resolve_exit_intent( 

252 message="Non-ASCII in configuration or environment", 

253 code=ExitCode.ASCII, 

254 failure="ascii", 

255 command=command, 

256 fmt=format_value, 

257 quiet=quiet, 

258 include_runtime=include_runtime, 

259 error_type=ErrorType.ASCII, 

260 log_level=log_level, 

261 ) 

262 raise ExitIntentError(intent) 

263 

264 return format_value 

265 

266 

267def validate_env_file_if_present(path_str: str) -> None: 

268 """Validate env file format if present.""" 

269 if not path_str or not Path(path_str).exists(): 

270 return 

271 try: 

272 text = Path(path_str).read_text(encoding="utf-8", errors="strict") 

273 except Exception as exc: 

274 raise ValueError(f"Cannot read config file: {exc}") from exc 

275 

276 for i, line in enumerate(text.splitlines(), start=1): 

277 s = line.strip() 

278 if s and not s.startswith("#") and not _ENV_LINE_RX.match(s): 

279 raise ValueError(f"Malformed line {i} in config: {line!r}") 

280 

281 

282__all__ = [ 

283 "ascii_safe", 

284 "contains_non_ascii_env", 

285 "emit_payload", 

286 "new_run_command", 

287 "normalize_format", 

288 "record_history", 

289 "resolve_emitter", 

290 "resolve_serializer", 

291 "raise_exit_intent", 

292 "validate_common_flags", 

293 "validate_env_file_if_present", 

294]