Coverage for apio/common/apio_console.py: 93%

146 statements  

« prev     ^ index     » next       coverage.py v7.16.1, created at 2026-09-23 03:53 +0000

1# -*- coding: utf-8 -*- 

2# -- This file is part of the Apio project 

3# -- (C) 2016-2018 FPGAwars 

4# -- Author Jesús Arroyo 

5# -- License GPLv2 

6# -- Derived from: 

7# ---- Platformio project 

8# ---- (C) 2014-2016 Ivan Kravets <me@ikravets.com> 

9# ---- License Apache v2 

10"""A module with functions to manages the apio console output.""" 

11 

12import sys 

13import os 

14import traceback 

15from dataclasses import dataclass 

16from typing import NoReturn, Literal 

17from rich.console import Console 

18from rich.ansi import AnsiDecoder 

19from rich.theme import Theme 

20from rich.text import Text 

21from rich.table import Table 

22from apio.common import rich_lib_windows 

23from apio.common.apio_styles import WARNING, ERROR, INFO 

24from apio.common.apio_themes import ApioTheme, THEMES_TABLE, DEFAULT_THEME 

25from apio.common.proto.apio_scons_pb2 import ( 

26 TerminalMode, 

27 FORCE_PIPE, 

28 FORCE_TERMINAL, 

29 AUTO_TERMINAL, 

30) 

31 

32# -- The Rich library colors names are listed at: 

33# -- https://rich.readthedocs.io/en/stable/appendix/colors.html 

34 

35 

36# -- Redemanded table cell padding. 1 space on the left and 3 on the right. 

37PADDING = padding = (0, 3, 0, 1) 

38 

39# -- Line width when rendering help and docs. 

40DOCS_WIDTH = 70 

41 

42 

43# -- This console state is initialized at the end of this file. 

44@dataclass 

45class ConsoleState: 

46 """Contains the state of the apio console.""" 

47 

48 # None = auto. True and False force to terminal and pipe mode respectively. 

49 terminal_mode: TerminalMode 

50 # The theme object. 

51 theme: ApioTheme 

52 # The current console object. 

53 console: Console 

54 # The latest AnsiDecoder we use for capture printing. 

55 decoder: AnsiDecoder 

56 

57 def __post_init__(self): 

58 assert self.terminal_mode is not None 

59 assert self.theme is not None 

60 assert self.console is not None 

61 assert self.decoder is not None 

62 

63 

64# -- Initialized by Configure(). 

65_state: ConsoleState | None = None 

66 

67 

68# NOTE: not declaring terminal_mode and theme_name is optional because it 

69# causes the tests to fail with python 3.9. 

70def configure( 

71 *, 

72 terminal_mode: TerminalMode | None = None, 

73 theme_name: str | None = None, 

74) -> None: 

75 """Change the apio console settings.""" 

76 

77 # pylint: disable=global-statement 

78 

79 global _state 

80 

81 # -- Force utf-8 output encoding. This is a workaround for rich library 

82 # -- defaulting to non graphic ASCII border for tables. 

83 # -- 

84 stdout_fixed = rich_lib_windows.fix_windows_stdout_encoding() 

85 _ = stdout_fixed # For pylint, when debugging code below commented out. 

86 

87 # -- Determine the theme. 

88 if theme_name: 

89 # -- Used caller specified theme. 

90 assert theme_name in THEMES_TABLE, theme_name 

91 theme = THEMES_TABLE[theme_name] 

92 assert theme.name == theme_name, theme 

93 elif _state: 

94 # -- Fall to theme name from state, if available. 

95 theme = _state.theme 

96 else: 

97 # -- Fall to default theme. 

98 theme = DEFAULT_THEME 

99 

100 # -- Determine terminal mode. 

101 if terminal_mode is None: 

102 if _state: 

103 # -- Fall to terminal mode from the state. 

104 terminal_mode = _state.terminal_mode 

105 else: 

106 # -- Fall to default. 

107 terminal_mode = AUTO_TERMINAL 

108 

109 # -- Determine console color system parameter. 

110 color_system: Literal["auto"] | None = ( 

111 "auto" if theme.colors_enabled else None 

112 ) 

113 

114 # -- Determine console's force_terminal parameter. 

115 if terminal_mode == FORCE_TERMINAL: 

116 force_terminal = True 

117 elif terminal_mode == FORCE_PIPE: 

118 force_terminal = False 

119 else: 

120 assert terminal_mode == AUTO_TERMINAL, terminal_mode 

121 force_terminal = None 

122 

123 # -- Construct the new console. 

124 console_ = Console( 

125 color_system=color_system, 

126 force_terminal=force_terminal, 

127 theme=Theme(theme.styles, inherit=False), 

128 ) 

129 

130 # -- Construct the helper decoder. 

131 decoder = AnsiDecoder() 

132 

133 # -- Save the state 

134 _state = ConsoleState( 

135 terminal_mode=terminal_mode, 

136 theme=theme, 

137 console=console_, 

138 decoder=decoder, 

139 ) 

140 

141 # -- For debugging. 

142 # print() 

143 # print(f"*** {stdout_fixed=}") 

144 # print(f"*** {terminal_mode=}") 

145 # print(f"*** {theme_name=}") 

146 # print(f"*** {theme.name=}") 

147 # print(f"*** {color_system=}") 

148 # print(f"*** {terminal_mode=}") 

149 # print(f"*** {force_terminal=}") 

150 # print(f"*** {_state.console.is_terminal=}") 

151 # print(f"*** {_state.console.encoding=}") 

152 # print(f"*** {_state.console.is_dumb_terminal=}") 

153 # print(f"*** {_state.console.safe_box=}") 

154 # print(f"*** state={_state}") 

155 # print() 

156 

157 

158def check_apio_console_configured(): 

159 """A common check that the apio console has been configured.""" 

160 assert _state is not None 

161 assert _state.console, "The apio console is not configured." 

162 

163 

164def is_colors_enabled() -> bool: 

165 """Returns True if colors are enabled.""" 

166 check_apio_console_configured() 

167 assert _state is not None 

168 return _state.theme.colors_enabled 

169 

170 

171def current_theme_name() -> str: 

172 """Return the current theme name.""" 

173 check_apio_console_configured() 

174 assert _state is not None 

175 return _state.theme.name 

176 

177 

178def console(): 

179 """Returns the underlying console. This value should not be cached as 

180 the console object changes when the configure() or reset() are called.""" 

181 check_apio_console_configured() 

182 assert _state is not None 

183 return _state.console 

184 

185 

186def cunstyle(text: str) -> str: 

187 """A replacement for click unstyle(). This function removes ansi colors 

188 from a string.""" 

189 check_apio_console_configured() 

190 assert _state is not None 

191 text_obj: Text = _state.decoder.decode_line(text) 

192 return text_obj.plain 

193 

194 

195def cflush() -> None: 

196 """Flush the console output.""" 

197 

198 # pylint: disable=protected-access 

199 

200 # -- Flush the console buffer to the output stream. 

201 # -- NOTE: We couldn't find an official API for flushing 

202 # -- THE console's buffer. 

203 console()._check_buffer() 

204 # -- Flush the output stream. 

205 console().file.flush() 

206 

207 

208def cout( 

209 *text_lines: str, 

210 style: str = "", 

211 nl: bool = True, 

212) -> None: 

213 """Prints lines of text to the console, using the optional style.""" 

214 # -- If no args, just do an empty println. 

215 if not text_lines: 

216 text_lines = ("",) 

217 

218 for text_line in text_lines: 

219 # -- User is responsible to conversion to strings. 

220 assert isinstance(text_line, (str, Table)), type(text_line) 

221 

222 # -- If colors are off, strip potential coloring in the text. 

223 # -- This may be coloring that we received from the scons process. 

224 if not console().color_system: 

225 text_line = cunstyle(text_line) 

226 

227 # -- Write it out using the given style but without line break. 

228 # -- We first convert it to Text as a workaround for 

229 # -- https://github.com/Textualize/rich/discussions/3779. 

230 console().print( 

231 Text.from_ansi(text_line, style=style, end=""), 

232 highlight=False, 

233 end="", 

234 ) 

235 

236 # console().file.flush() 

237 

238 # -- If needed, write the line break. By writing the line break in 

239 # -- a separate call, we force the console().out() call above to 

240 # -- reset the colors before the line break rather than after. This 

241 # -- caused an additional blank lines after a colored fatal error 

242 # -- messages from scons. 

243 if nl: 243 ↛ 218line 243 didn't jump to line 218 because the condition on line 243 was always true

244 console().print("") 

245 

246 # console().file.flush() 

247 # console()._check_buffer() 

248 cflush() 

249 

250 

251def ctable(table: Table) -> None: 

252 """Write out a Rich lib Table.""" 

253 assert isinstance(table, Table), type(table) 

254 console().print(table) 

255 cflush() 

256 

257 

258def cmarkdown(markdown_text: str) -> None: 

259 """Write out a Rich markdown text.""" 

260 assert isinstance(markdown_text, str), type(markdown_text) 

261 console().print(markdown_text) 

262 cflush() 

263 

264 

265def cwrite(s: str) -> None: 

266 """A low level output that doesn't do any formatting, style, line 

267 terminator and so on. Flushing is important""" 

268 # -- Flush the existing console buffer and the output stream. 

269 cflush() 

270 # -- Write directly to the output stream, bypassing the 

271 # -- console's buffer. 

272 console().file.write(s) 

273 # -- Flush again. 

274 cflush() 

275 

276 

277def cerror(*text_lines: str) -> None: 

278 """Prints one or more text lines, adding to the first one the prefix 

279 'Error: ' and applying to all of them the red color.""" 

280 # -- Output the first line. 

281 console().out(f"Error: {text_lines[0]}", style=ERROR, highlight=False) 

282 # -- Output the rest of the lines. 

283 for text_line in text_lines[1:]: 

284 console().out(text_line, highlight=False, style=ERROR) 

285 cflush() 

286 

287 

288def fatal_error( 

289 *error_text_lines: str, 

290 info: list[str] | str | None = None, 

291 cause: Exception | None = None, 

292) -> NoReturn: 

293 """Prints one or more error lines, then optional info lines, and then 

294 exists the program with an error status.""" 

295 

296 # -- If info is a string, convert it to a list with a single string. 

297 if isinstance(info, str): 

298 info = [info] 

299 

300 # -- Construct a list of error lines. 

301 error_lines = list(error_text_lines) 

302 if cause: 

303 error_lines.append(f"{cause}") 

304 

305 # -- If there are no error lines, create a generic one and drop 

306 # -- any info text. 

307 if not error_lines: 

308 error_lines.append("Unspecified error detected.") 

309 info = [] # Drop any info text 

310 

311 # -- We use an independent ad-hoc is_debug flag to make this function 

312 # -- as stand alone as possible. 

313 var = os.environ.get("APIO_DEBUG", "0") 

314 if var.startswith('"') and var.endswith('"'): 314 ↛ 315line 314 didn't jump to line 315 because the condition on line 314 was never true

315 var = var[1:-1] # For windows 

316 is_debug = var.isdigit() and int(var) > 0 

317 

318 # -- Print the error line(s) 

319 cerror(*error_lines) 

320 

321 # -- Print the optional info lines. 

322 if info: 

323 cout(*info, style=INFO) 

324 

325 # -- If debug, print current stack information and underlying cause 

326 # -- exception if available. 

327 if is_debug: 

328 print("Stack (this thread):") 

329 traceback.print_stack() 

330 if cause is not None: 330 ↛ 340line 330 didn't jump to line 340 because the condition on line 330 was always true

331 print("\nException") 

332 traceback.print_exception(cause) 

333 else: 

334 cout( 

335 "Hint: For debugging information, set env var APIO_DEBUG=1.", 

336 style=INFO, 

337 ) 

338 

339 # -- Exit with error. 

340 sys.exit(1) 

341 

342 

343def cwarning(*text_lines: str) -> None: 

344 """Prints one or more text lines, adding to the first one the prefix 

345 'Warning: ' and applying to all of them the yellow color.""" 

346 # -- Emit first line. 

347 console().out(f"Warning: {text_lines[0]}", style=WARNING, highlight=False) 

348 # -- Emit the rest of the lines 

349 for text_line in text_lines[1:]: 

350 console().out(text_line, highlight=False, style=WARNING) 

351 cflush() 

352 

353 

354def cstyle(text: str, style: str | None = None) -> str: 

355 """Render the text to a string using an optional style.""" 

356 

357 # -- Render into a string buffer. 

358 with console().capture() as capture: 

359 console().out(text, style=style, highlight=False, end="") 

360 

361 return capture.get() 

362 

363 

364def docs_text( 

365 rich_text: str, width: int = DOCS_WIDTH, end: str = "\n" 

366) -> None: 

367 """A wrapper around Console.print that is specialized for rendering 

368 help and docs.""" 

369 console().print(rich_text, highlight=True, width=width, end=end) 

370 

371 

372def docs_text_to_str( 

373 rich_text: str, width: int = DOCS_WIDTH, end: str = "\n" 

374) -> str: 

375 """Same as docs_text() but renders to a string instead of 

376 stdout.""" 

377 # -- Render into a string. 

378 with console().capture() as capture: 

379 docs_text(rich_text=rich_text, width=width, end=end) 

380 

381 return capture.get() 

382 

383 

384def is_terminal(): 

385 """Returns True if the console writes to a terminal (vs a pipe).""" 

386 return console().is_terminal 

387 

388 

389def cwidth(): 

390 """Return the console width.""" 

391 return console().width 

392 

393 

394def get_theme() -> ApioTheme: 

395 """Return the the current theme.""" 

396 check_apio_console_configured() 

397 

398 assert _state is not None and _state.theme is not None 

399 return _state.theme