Coverage for frappe_manager / output_manager / json_output.py: 85%

106 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-07-02 18:13 +0530

1""" 

2JSON event output handler. 

3 

4This implementation captures all output events as structured JSON data, 

5suitable for API responses, logging, or testing. No actual output is 

6displayed to the terminal. 

7""" 

8 

9import json 

10from collections.abc import Iterable 

11from typing import Any 

12 

13from frappe_manager.output_manager.base import OutputHandler 

14 

15 

16class OutputEvent: 

17 """Represents a single output event.""" 

18 

19 def __init__(self, event_type: str, data: dict): 

20 """ 

21 Initialize an output event. 

22 

23 Args: 

24 event_type: Type of event (e.g., "start", "print", "error") 

25 data: Event-specific data 

26 """ 

27 self.event_type = event_type 

28 self.data = data 

29 

30 def to_dict(self) -> dict: 

31 """ 

32 Convert event to dictionary. 

33 

34 Returns: 

35 Dictionary representation of the event 

36 """ 

37 return {"event_type": self.event_type, "data": self.data} 

38 

39 def to_json(self) -> str: 

40 """ 

41 Convert event to JSON string. 

42 

43 Returns: 

44 JSON string representation of the event 

45 """ 

46 return json.dumps(self.to_dict()) 

47 

48 

49class JSONOutputHandler(OutputHandler): 

50 """ 

51 Output handler that captures events as structured JSON data. 

52 

53 This handler is suitable for: 

54 - API responses (FastAPI, Flask, etc.) 

55 - Structured logging 

56 - Testing and assertions 

57 - WebSocket communication 

58 

59 All events are captured in the `events` list and can be retrieved 

60 as dictionaries or JSON strings. 

61 """ 

62 

63 def __init__(self, verbose: bool = False, persist_to_file: Any = None): 

64 """ 

65 Initialize the JSON output handler. 

66 

67 Args: 

68 verbose: Capture info and debug level messages 

69 persist_to_file: Optional file path to persist events (JSONL format) 

70 """ 

71 super().__init__(verbose) 

72 self.events: list[OutputEvent] = [] 

73 self._current_head: str | None = None 

74 self._is_started: bool = False 

75 self.persist_file = persist_to_file 

76 

77 def _add_event(self, event: OutputEvent) -> None: 

78 """ 

79 Add event to list and optionally persist to file. 

80 

81 Args: 

82 event: OutputEvent to add 

83 """ 

84 self.events.append(event) 

85 

86 if self.persist_file: 

87 with open(self.persist_file, "a") as f: 

88 f.write(event.to_json() + "\n") 

89 

90 def start(self, text: str) -> None: 

91 """ 

92 Start a new operation with a status message. 

93 

94 Args: 

95 text: The initial status message 

96 """ 

97 super().start(text) 

98 self._current_head = text 

99 self._is_started = True 

100 self._add_event(OutputEvent("start", {"text": text})) 

101 

102 def change_head(self, text: str, style: str | None = None) -> None: 

103 """ 

104 Update the current operation status message. 

105 

106 Args: 

107 text: The new status message 

108 style: Optional style hint (ignored in JSON output) 

109 """ 

110 previous_head = self._current_head 

111 self._current_head = text 

112 self._add_event( 

113 OutputEvent("change_head", {"text": text, "previous": previous_head, "style": style}), 

114 ) 

115 

116 def update_head(self, text: str) -> None: 

117 """ 

118 Update the head text. 

119 

120 Args: 

121 text: The new head text 

122 """ 

123 previous_head = self._current_head 

124 self._current_head = text 

125 self._add_event(OutputEvent("update_head", {"text": text, "previous": previous_head})) 

126 

127 def stop(self) -> None: 

128 """ 

129 Stop the current operation status display. 

130 """ 

131 super().stop() 

132 self._is_started = False 

133 self._add_event(OutputEvent("stop", {})) 

134 

135 def print(self, text: str, emoji_code: str = ":zap:", prefix: str | None = None, **kwargs) -> None: 

136 """ 

137 Print a message. 

138 

139 Args: 

140 text: The message to print 

141 emoji_code: Emoji code (captured but not rendered) 

142 prefix: Optional prefix for the message 

143 **kwargs: Additional arguments (captured in data) 

144 """ 

145 self._add_event( 

146 OutputEvent( 

147 "print", 

148 {"text": text, "emoji_code": emoji_code, "prefix": prefix, "kwargs": kwargs}, 

149 ), 

150 ) 

151 

152 def debug(self, text: str, emoji_code: str = ":bug:", **kwargs) -> None: 

153 """ 

154 Capture debug message if debug mode is enabled. 

155 

156 Args: 

157 text: Debug message 

158 emoji_code: Emoji code (captured but not rendered) 

159 **kwargs: Additional arguments (captured in data) 

160 """ 

161 if self.verbose: 

162 self._add_event(OutputEvent("debug", {"text": text, "emoji_code": emoji_code, "kwargs": kwargs})) 

163 

164 def info(self, text: str, emoji_code: str = ":information:", **kwargs) -> None: 

165 """ 

166 Capture info message if verbose mode is enabled. 

167 

168 Args: 

169 text: Info message 

170 emoji_code: Emoji code (captured but not rendered) 

171 **kwargs: Additional arguments (captured in data) 

172 """ 

173 if self.verbose: 

174 self._add_event(OutputEvent("info", {"text": text, "emoji_code": emoji_code, "kwargs": kwargs})) 

175 

176 def display_error(self, text: str, emoji_code: str = ":no_entry:") -> None: 

177 """ 

178 Capture error message without raising exception. 

179 

180 Args: 

181 text: Error message 

182 emoji_code: Emoji code (captured but not rendered) 

183 """ 

184 self._add_event(OutputEvent("display_error", {"text": text, "emoji_code": emoji_code})) 

185 

186 def error(self, text: str, exception: Exception, emoji_code: str = ":no_entry:") -> None: 

187 """ 

188 Display an error message and raise the exception. 

189 

190 This method always raises the provided exception after capturing the event. 

191 

192 Args: 

193 text: The error message 

194 exception: Exception to raise after capturing (required) 

195 emoji_code: Emoji code (captured but not rendered) 

196 

197 Raises: 

198 Exception: Always raises the provided exception 

199 """ 

200 self._add_event( 

201 OutputEvent( 

202 "error", 

203 { 

204 "text": text, 

205 "emoji_code": emoji_code, 

206 "exception": str(exception), 

207 "exception_type": type(exception).__name__, 

208 }, 

209 ), 

210 ) 

211 raise exception 

212 

213 def warning(self, text: str, emoji_code: str = ":warning:") -> None: 

214 """ 

215 Display a warning message. 

216 

217 Args: 

218 text: The warning message 

219 emoji_code: Emoji code (captured but not rendered) 

220 """ 

221 self._add_event(OutputEvent("warning", {"text": text, "emoji_code": emoji_code})) 

222 

223 def live_lines( 

224 self, 

225 data: Iterable[tuple[str, bytes]], 

226 stdout: bool = True, 

227 stderr: bool = True, 

228 lines: int = 4, 

229 padding: tuple[int, int, int, int] = (0, 0, 0, 2), 

230 stop_string: str | None = None, 

231 log_prefix: str = "=>", 

232 ) -> None: 

233 """ 

234 Display live streaming output from a process. 

235 

236 Args: 

237 data: Iterator yielding (source, line) tuples 

238 stdout: Whether to capture stdout lines 

239 stderr: Whether to capture stderr lines 

240 lines: Maximum number of lines (hint only) 

241 padding: Padding (ignored in JSON output) 

242 stop_string: String that stops capture when found 

243 log_prefix: Prefix for each line 

244 """ 

245 captured_lines: list[dict] = [] 

246 

247 for source, line in data: 

248 try: 

249 decoded_line = line.decode() 

250 except Exception: 

251 decoded_line = str(line) 

252 

253 if source == "stdout" and not stdout: 

254 continue 

255 if source == "stderr" and not stderr: 

256 continue 

257 

258 captured_lines.append({"source": source, "line": decoded_line}) 

259 

260 if stop_string and stop_string.lower() in decoded_line.lower(): 

261 break 

262 

263 self._add_event( 

264 OutputEvent( 

265 "live_lines", 

266 { 

267 "lines": captured_lines, 

268 "stdout": stdout, 

269 "stderr": stderr, 

270 "max_lines": lines, 

271 "stop_string": stop_string, 

272 "log_prefix": log_prefix, 

273 }, 

274 ), 

275 ) 

276 

277 def update_live(self, renderable: Any = None, padding: tuple[int, int, int, int] = (0, 0, 0, 0)) -> None: 

278 """ 

279 Update the live display with new content. 

280 

281 Args: 

282 renderable: Content to display (captured as string) 

283 padding: Padding (ignored in JSON output) 

284 """ 

285 self._add_event( 

286 OutputEvent("update_live", {"renderable": str(renderable) if renderable else None, "padding": padding}), 

287 ) 

288 

289 def prompt_ask( 

290 self, 

291 prompt: str = "", 

292 choices: list | None = None, 

293 default: str | None = None, 

294 force_yes: bool = False, 

295 required_flag: str | None = None, 

296 **kwargs, 

297 ) -> str: 

298 from frappe_manager.exceptions import NonInteractiveError 

299 

300 self._add_event( 

301 OutputEvent( 

302 "prompt_ask", 

303 { 

304 "prompt": prompt, 

305 "choices": choices, 

306 "default": default, 

307 "force_yes": force_yes, 

308 "required_flag": required_flag, 

309 "kwargs": kwargs, 

310 }, 

311 ), 

312 ) 

313 

314 if force_yes: 

315 return "yes" 

316 

317 if required_flag: 

318 raise NonInteractiveError( 

319 f"Cannot prompt in JSON output mode: {prompt}", 

320 suggestions=[f"Provide: {required_flag}"], 

321 ) 

322 

323 if default is not None: 

324 return default 

325 

326 raise NonInteractiveError( 

327 f"Cannot prompt in JSON output mode: {prompt}", 

328 suggestions=["Use interactive mode for prompts"], 

329 ) 

330 

331 def prompt_fuzzy( 

332 self, 

333 prompt: str, 

334 choices: list[str], 

335 default: str | None = None, 

336 required_flag: str | None = None, 

337 **kwargs, 

338 ) -> str: 

339 from frappe_manager.exceptions import NonInteractiveError 

340 

341 self._add_event( 

342 OutputEvent( 

343 "prompt_fuzzy", 

344 { 

345 "prompt": prompt, 

346 "choices": choices, 

347 "default": default, 

348 "required_flag": required_flag, 

349 "kwargs": kwargs, 

350 }, 

351 ), 

352 ) 

353 

354 if required_flag: 

355 raise NonInteractiveError( 

356 f"Cannot prompt in JSON output mode: {prompt}", 

357 suggestions=[f"Provide: {required_flag}"], 

358 ) 

359 

360 if default is not None: 

361 return default 

362 

363 raise NonInteractiveError( 

364 f"Cannot prompt in JSON output mode: {prompt}", 

365 suggestions=["Use interactive mode for prompts"], 

366 ) 

367 

368 @property 

369 def should_stream_docker(self) -> bool: 

370 return False 

371 

372 def print_data(self, data: Any, **kwargs) -> None: 

373 self._add_event(OutputEvent("print_data", {"data": data, "kwargs": kwargs})) 

374 

375 def print_status(self, text: str, emoji_code: str = ":zap:", **kwargs) -> None: 

376 self._add_event(OutputEvent("print_status", {"text": text, "emoji_code": emoji_code, "kwargs": kwargs})) 

377 

378 def get_events(self) -> list[dict]: 

379 """ 

380 Get all captured events as dictionaries. 

381 

382 Returns: 

383 List of event dictionaries 

384 """ 

385 return [event.to_dict() for event in self.events] 

386 

387 def get_events_json(self) -> str: 

388 """ 

389 Get all captured events as JSON string. 

390 

391 Returns: 

392 JSON string containing all events 

393 """ 

394 return json.dumps(self.get_events(), indent=2) 

395 

396 def clear_events(self) -> None: 

397 """ 

398 Clear all captured events. 

399 """ 

400 self.events.clear() 

401 self._current_head = None 

402 self._is_started = False