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

125 statements  

« prev     ^ index     » next       coverage.py v7.11.0, created at 2025-11-06 10:20 +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 

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# -- The Rich library colors names are listed at: 

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

32 

33 

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

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

36 

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

38DOCS_WIDTH = 70 

39 

40 

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

42@dataclass 

43class ConsoleState: 

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

45 

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

47 terminal_mode: TerminalMode 

48 # The theme object. 

49 theme: ApioTheme 

50 # The current console object. 

51 console: Console 

52 # The latest AnsiDecoder we use for capture printing. 

53 decoder: AnsiDecoder 

54 

55 def __post_init__(self): 

56 assert self.terminal_mode is not None 

57 assert self.theme is not None 

58 assert self.console is not None 

59 assert self.decoder is not None 

60 

61 

62# -- Initialized by Configure(). 

63_state: ConsoleState = None 

64 

65 

66# NOTE: not declaring terminal_mode and theme_name is Optional[] because it 

67# causes the tests to fail with python 3.9. 

68def configure( 

69 *, 

70 terminal_mode: TerminalMode = None, 

71 theme_name: str = None, 

72) -> None: 

73 """Change the apio console settings.""" 

74 

75 # pylint: disable=global-statement 

76 

77 global _state 

78 

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

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

81 # -- 

82 stdout_fixed = rich_lib_windows.fix_windows_stdout_encoding() 

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

84 

85 # -- Determine the theme. 

86 if theme_name: 

87 # -- Used caller specified theme. 

88 assert theme_name in THEMES_TABLE, theme_name 

89 theme = THEMES_TABLE[theme_name] 

90 assert theme.name == theme_name, theme 

91 elif _state: 91 ↛ 96line 91 didn't jump to line 96 because the condition on line 91 was always true

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

93 theme = _state.theme 

94 else: 

95 # -- Fall to default theme. 

96 theme = DEFAULT_THEME 

97 

98 # -- Determine terminal mode. 

99 if terminal_mode is None: 

100 if _state: 

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

102 terminal_mode = _state.terminal_mode 

103 else: 

104 # -- Fall to default. 

105 terminal_mode = AUTO_TERMINAL 

106 

107 # -- Determine console color system parameter. 

108 color_system = "auto" if theme.colors_enabled else None 

109 

110 # -- Determine console's force_terminal parameter. 

111 if terminal_mode == FORCE_TERMINAL: 

112 force_terminal = True 

113 elif terminal_mode == FORCE_PIPE: 

114 force_terminal = False 

115 else: 

116 assert terminal_mode == AUTO_TERMINAL, terminal_mode 

117 force_terminal = None 

118 

119 # -- Construct the new console. 

120 console_ = Console( 

121 color_system=color_system, 

122 force_terminal=force_terminal, 

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

124 ) 

125 

126 # -- Construct the helper decoder. 

127 decoder = AnsiDecoder() 

128 

129 # -- Save the state 

130 _state = ConsoleState( 

131 terminal_mode=terminal_mode, 

132 theme=theme, 

133 console=console_, 

134 decoder=decoder, 

135 ) 

136 

137 # -- For debugging. 

138 # print() 

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

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

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

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

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

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

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

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

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

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

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

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

151 # print() 

152 

153 

154def check_apio_console_configured(): 

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

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

157 

158 

159def is_colors_enabled() -> bool: 

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

161 check_apio_console_configured() 

162 return _state.theme.colors_enabled 

163 

164 

165def current_theme_name() -> str: 

166 """Return the current theme name.""" 

167 check_apio_console_configured() 

168 return _state.theme.name 

169 

170 

171def console(): 

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

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

174 check_apio_console_configured() 

175 return _state.console 

176 

177 

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

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

180 from a string.""" 

181 check_apio_console_configured() 

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

183 return text_obj.plain 

184 

185 

186def cflush() -> None: 

187 """Flush the console output.""" 

188 

189 # pylint: disable=protected-access 

190 

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

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

193 # -- THE console's buffer. 

194 console()._check_buffer() 

195 # -- Flush the output stream. 

196 console().file.flush() 

197 

198 

199def cout( 

200 *text_lines: str, 

201 style: Optional[str] = None, 

202 nl: bool = True, 

203) -> None: 

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

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

206 if not text_lines: 

207 text_lines = [""] 

208 

209 for text_line in text_lines: 

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

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

212 

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

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

215 if not console().color_system: 

216 text_line = cunstyle(text_line) 

217 

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

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

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

221 console().print( 

222 Text.from_ansi(text_line, style=style, end=None), 

223 highlight=False, 

224 end=None, 

225 ) 

226 

227 # console().file.flush() 

228 

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

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

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

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

233 # -- messages from scons. 

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

235 console().print("") 

236 

237 # console().file.flush() 

238 # console()._check_buffer() 

239 cflush() 

240 

241 

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

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

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

245 console().print(table) 

246 cflush() 

247 

248 

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

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

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

252 console().print(markdown_text) 

253 cflush() 

254 

255 

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

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

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

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

260 cflush() 

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

262 # -- console's buffer. 

263 console().file.write(s) 

264 # -- Flush again. 

265 cflush() 

266 

267 

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

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

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

271 # -- Output the first line. 

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

273 # -- Output the rest of the lines. 

274 for text_line in text_lines[1:]: 

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

276 cflush() 

277 

278 

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

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

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

282 # -- Emit first line. 

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

284 # -- Emit the rest of the lines 

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

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

287 cflush() 

288 

289 

290class ConsoleCapture: 

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

292 

293 def __init__(self): 

294 self._saved_file = None 

295 self._buffer = None 

296 

297 def __enter__(self): 

298 cflush() 

299 self._saved_file = console().file 

300 self._buffer = StringIO() 

301 console().file = self._buffer 

302 return self 

303 

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

305 console().file = self._saved_file 

306 

307 @property 

308 def value(self): 

309 """Returns the captured text.""" 

310 return self._buffer.getvalue() 

311 

312 

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

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

315 with ConsoleCapture() as capture: 

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

317 return capture.value 

318 

319 

320def docs_text( 

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

322) -> None: 

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

324 help and docs.""" 

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

326 

327 

328def is_terminal(): 

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

330 return console().is_terminal 

331 

332 

333def cwidth(): 

334 """Return the console width.""" 

335 return console().width 

336 

337 

338def get_theme() -> ApioTheme: 

339 """Return the the current theme.""" 

340 check_apio_console_configured() 

341 return _state.theme