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

134 statements  

« prev     ^ index     » next       coverage.py v7.14.3, created at 2026-06-24 03:51 +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 

12from io import StringIO 

13from dataclasses import dataclass 

14from typing import Optional, IO 

15from rich.console import Console 

16from rich.ansi import AnsiDecoder 

17from rich.theme import Theme 

18from rich.text import Text 

19from rich.table import Table 

20from apio.common import rich_lib_windows 

21from apio.common.apio_styles import WARNING, ERROR 

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

23from apio.common.proto.apio_pb2 import ( 

24 TerminalMode, 

25 FORCE_PIPE, 

26 FORCE_TERMINAL, 

27 AUTO_TERMINAL, 

28) 

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: 92 ↛ 97line 92 didn't jump to line 97 because the condition on line 92 was always true

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 = "auto" if theme.colors_enabled else None 

110 

111 # -- Determine console's force_terminal parameter. 

112 if terminal_mode == FORCE_TERMINAL: 

113 force_terminal = True 

114 elif terminal_mode == FORCE_PIPE: 

115 force_terminal = False 

116 else: 

117 assert terminal_mode == AUTO_TERMINAL, terminal_mode 

118 force_terminal = None 

119 

120 # -- Construct the new console. 

121 console_ = Console( 

122 color_system=color_system, 

123 force_terminal=force_terminal, 

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

125 ) 

126 

127 # -- Construct the helper decoder. 

128 decoder = AnsiDecoder() 

129 

130 # -- Save the state 

131 _state = ConsoleState( 

132 terminal_mode=terminal_mode, 

133 theme=theme, 

134 console=console_, 

135 decoder=decoder, 

136 ) 

137 

138 # -- For debugging. 

139 # print() 

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

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

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

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

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

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

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

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

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

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

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

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

152 # print() 

153 

154 

155def check_apio_console_configured(): 

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

157 assert _state is not None 

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

159 

160 

161def is_colors_enabled() -> bool: 

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

163 check_apio_console_configured() 

164 assert _state is not None 

165 return _state.theme.colors_enabled 

166 

167 

168def current_theme_name() -> str: 

169 """Return the current theme name.""" 

170 check_apio_console_configured() 

171 assert _state is not None 

172 return _state.theme.name 

173 

174 

175def console(): 

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

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

178 check_apio_console_configured() 

179 assert _state is not None 

180 return _state.console 

181 

182 

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

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

185 from a string.""" 

186 check_apio_console_configured() 

187 assert _state is not None 

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

189 return text_obj.plain 

190 

191 

192def cflush() -> None: 

193 """Flush the console output.""" 

194 

195 # pylint: disable=protected-access 

196 

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

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

199 # -- THE console's buffer. 

200 console()._check_buffer() 

201 # -- Flush the output stream. 

202 console().file.flush() 

203 

204 

205def cout( 

206 *text_lines: str, 

207 style: str = "", 

208 nl: bool = True, 

209) -> None: 

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

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

212 if not text_lines: 

213 text_lines = ("",) 

214 

215 for text_line in text_lines: 

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

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

218 

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

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

221 if not console().color_system: 

222 text_line = cunstyle(text_line) 

223 

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

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

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

227 console().print( 

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

229 highlight=False, 

230 end="", 

231 ) 

232 

233 # console().file.flush() 

234 

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

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

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

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

239 # -- messages from scons. 

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

241 console().print("") 

242 

243 # console().file.flush() 

244 # console()._check_buffer() 

245 cflush() 

246 

247 

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

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

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

251 console().print(table) 

252 cflush() 

253 

254 

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

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

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

258 console().print(markdown_text) 

259 cflush() 

260 

261 

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

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

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

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

266 cflush() 

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

268 # -- console's buffer. 

269 console().file.write(s) 

270 # -- Flush again. 

271 cflush() 

272 

273 

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

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

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

277 # -- Output the first line. 

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

279 # -- Output the rest of the lines. 

280 for text_line in text_lines[1:]: 

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

282 cflush() 

283 

284 

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

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

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

288 # -- Emit first line. 

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

290 # -- Emit the rest of the lines 

291 for text_line in text_lines[1:]: 291 ↛ 292line 291 didn't jump to line 292 because the loop on line 291 never started

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

293 cflush() 

294 

295 

296class ConsoleCapture: 

297 """A context manager to output into a string.""" 

298 

299 def __init__(self): 

300 self._saved_file: Optional[IO[str]] = None 

301 self._buffer: Optional[StringIO] = None 

302 

303 def __enter__(self): 

304 cflush() 

305 self._saved_file = console().file 

306 self._buffer = StringIO() 

307 console().file = self._buffer 

308 return self 

309 

310 def __exit__(self, exc_type, exc_value, traceback): 

311 if self._saved_file is not None: 311 ↛ exitline 311 didn't return from function '__exit__' because the condition on line 311 was always true

312 console().file = self._saved_file 

313 # console().file = self._saved_file 

314 

315 @property 

316 def value(self): 

317 """Returns the captured text.""" 

318 if self._buffer is not None: 318 ↛ 320line 318 didn't jump to line 320 because the condition on line 318 was always true

319 return self._buffer.getvalue() 

320 return "" 

321 

322 

323def cstyle(text: str, style: Optional[str] = None) -> str: 

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

325 with ConsoleCapture() as capture: 

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

327 return capture.value 

328 

329 

330def docs_text( 

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

332) -> None: 

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

334 help and docs.""" 

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

336 

337 

338def is_terminal(): 

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

340 return console().is_terminal 

341 

342 

343def cwidth(): 

344 """Return the console width.""" 

345 return console().width 

346 

347 

348def get_theme() -> ApioTheme: 

349 """Return the the current theme.""" 

350 check_apio_console_configured() 

351 

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

353 return _state.theme