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

146 statements  

« prev     ^ index     » next       coverage.py v7.17.2, created at 2026-11-07 05:08 +0000

1# This file is part of the Apio project 

2# (C) 2016-present FPGAwars 

3# Project authors: 

4# * Jesús Arroyo (Jesus89) (2016-2019) 

5# * Juan Gonzalez (obijuan) (2019-2024) 

6# * The Apio Team (2024-present) 

7# License: GPLv2 

8 

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

10 

11import sys 

12import os 

13import traceback 

14from dataclasses import dataclass 

15from typing import NoReturn, Literal 

16from rich.console import Console 

17from rich.ansi import AnsiDecoder 

18from rich.theme import Theme 

19from rich.text import Text 

20from rich.table import Table 

21from apio.common import rich_lib_windows 

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

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

24from apio.common.proto.stubs.apio_scons_pb2 import ( 

25 TerminalMode, 

26 FORCE_PIPE, 

27 FORCE_TERMINAL, 

28 AUTO_TERMINAL, 

29) 

30 

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

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

33 

34 

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

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

37 

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

39DOCS_WIDTH = 70 

40 

41 

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

43@dataclass 

44class ConsoleState: 

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

46 

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

48 terminal_mode: TerminalMode 

49 # The theme object. 

50 theme: ApioTheme 

51 # The current console object. 

52 console: Console 

53 # The latest AnsiDecoder we use for capture printing. 

54 decoder: AnsiDecoder 

55 

56 def __post_init__(self): 

57 assert self.terminal_mode is not None 

58 assert self.theme is not None 

59 assert self.console is not None 

60 assert self.decoder is not None 

61 

62 

63# -- Initialized by Configure(). 

64_state: ConsoleState | None = None 

65 

66 

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

68# causes the tests to fail with python 3.9. 

69def configure( 

70 *, 

71 terminal_mode: TerminalMode | None = None, 

72 theme_name: str | None = None, 

73) -> None: 

74 """Change the apio console settings.""" 

75 

76 # pylint: disable=global-statement 

77 

78 global _state 

79 

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

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

82 # -- 

83 stdout_fixed = rich_lib_windows.fix_windows_stdout_encoding() 

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

85 

86 # -- Determine the theme. 

87 if theme_name: 

88 # -- Used caller specified theme. 

89 assert theme_name in THEMES_TABLE, theme_name 

90 theme = THEMES_TABLE[theme_name] 

91 assert theme.name == theme_name, theme 

92 elif _state: 

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

94 theme = _state.theme 

95 else: 

96 # -- Fall to default theme. 

97 theme = DEFAULT_THEME 

98 

99 # -- Determine terminal mode. 

100 if terminal_mode is None: 

101 if _state: 

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

103 terminal_mode = _state.terminal_mode 

104 else: 

105 # -- Fall to default. 

106 terminal_mode = AUTO_TERMINAL 

107 

108 # -- Determine console color system parameter. 

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

110 "auto" if theme.colors_enabled else None 

111 ) 

112 

113 # -- Determine console's force_terminal parameter. 

114 if terminal_mode == FORCE_TERMINAL: 

115 force_terminal = True 

116 elif terminal_mode == FORCE_PIPE: 

117 force_terminal = False 

118 else: 

119 assert terminal_mode == AUTO_TERMINAL, terminal_mode 

120 force_terminal = None 

121 

122 # -- Construct the new console. 

123 console_ = Console( 

124 color_system=color_system, 

125 force_terminal=force_terminal, 

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

127 ) 

128 

129 # -- Construct the helper decoder. 

130 decoder = AnsiDecoder() 

131 

132 # -- Save the state 

133 _state = ConsoleState( 

134 terminal_mode=terminal_mode, 

135 theme=theme, 

136 console=console_, 

137 decoder=decoder, 

138 ) 

139 

140 # -- For debugging. 

141 # print() 

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

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

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

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

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

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

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

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

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

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

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

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

154 # print() 

155 

156 

157def check_apio_console_configured(): 

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

159 assert _state is not None 

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

161 

162 

163def is_colors_enabled() -> bool: 

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

165 check_apio_console_configured() 

166 assert _state is not None 

167 return _state.theme.colors_enabled 

168 

169 

170def current_theme_name() -> str: 

171 """Return the current theme name.""" 

172 check_apio_console_configured() 

173 assert _state is not None 

174 return _state.theme.name 

175 

176 

177def console(): 

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

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

180 check_apio_console_configured() 

181 assert _state is not None 

182 return _state.console 

183 

184 

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

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

187 from a string.""" 

188 check_apio_console_configured() 

189 assert _state is not None 

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

191 return text_obj.plain 

192 

193 

194def cflush() -> None: 

195 """Flush the console output.""" 

196 

197 # pylint: disable=protected-access 

198 

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

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

201 # -- THE console's buffer. 

202 console()._check_buffer() 

203 # -- Flush the output stream. 

204 console().file.flush() 

205 

206 

207def cout( 

208 *text_lines: str, 

209 style: str = "", 

210 nl: bool = True, 

211) -> None: 

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

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

214 if not text_lines: 

215 text_lines = ("",) 

216 

217 for text_line in text_lines: 

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

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

220 

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

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

223 if not console().color_system: 

224 text_line = cunstyle(text_line) 

225 

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

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

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

229 console().print( 

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

231 highlight=False, 

232 end="", 

233 ) 

234 

235 # console().file.flush() 

236 

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

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

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

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

241 # -- messages from scons. 

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

243 console().print("") 

244 

245 # console().file.flush() 

246 # console()._check_buffer() 

247 cflush() 

248 

249 

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

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

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

253 console().print(table) 

254 cflush() 

255 

256 

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

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

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

260 console().print(markdown_text) 

261 cflush() 

262 

263 

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

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

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

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

268 cflush() 

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

270 # -- console's buffer. 

271 console().file.write(s) 

272 # -- Flush again. 

273 cflush() 

274 

275 

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

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

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

279 # -- Output the first line. 

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

281 # -- Output the rest of the lines. 

282 for text_line in text_lines[1:]: 

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

284 cflush() 

285 

286 

287def fatal_error( 

288 *error_text_lines: str, 

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

290 cause: Exception | None = None, 

291) -> NoReturn: 

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

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

294 

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

296 if isinstance(info, str): 

297 info = [info] 

298 

299 # -- Construct a list of error lines. 

300 error_lines = list(error_text_lines) 

301 if cause: 

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

303 

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

305 # -- any info text. 

306 if not error_lines: 

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

308 info = [] # Drop any info text 

309 

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

311 # -- as stand alone as possible. 

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

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

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

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

316 

317 # -- Print the error line(s) 

318 cerror(*error_lines) 

319 

320 # -- Print the optional info lines. 

321 if info: 

322 cout(*info, style=INFO) 

323 

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

325 # -- exception if available. 

326 if is_debug: 

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

328 traceback.print_stack() 

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

330 print("\nException") 

331 traceback.print_exception(cause) 

332 else: 

333 cout( 

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

335 style=INFO, 

336 ) 

337 

338 # -- Exit with error. 

339 sys.exit(1) 

340 

341 

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

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

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

345 # -- Emit first line. 

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

347 # -- Emit the rest of the lines 

348 for text_line in text_lines[1:]: 

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

350 cflush() 

351 

352 

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

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

355 

356 # -- Render into a string buffer. 

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

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

359 

360 return capture.get() 

361 

362 

363def docs_text( 

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

365) -> None: 

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

367 help and docs.""" 

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

369 

370 

371def docs_text_to_str( 

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

373) -> str: 

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

375 stdout.""" 

376 # -- Render into a string. 

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

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

379 

380 return capture.get() 

381 

382 

383def is_terminal(): 

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

385 return console().is_terminal 

386 

387 

388def cwidth(): 

389 """Return the console width.""" 

390 return console().width 

391 

392 

393def get_theme() -> ApioTheme: 

394 """Return the the current theme.""" 

395 check_apio_console_configured() 

396 

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

398 return _state.theme