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
« 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."""
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)
30# -- The Rich library colors names are listed at:
31# -- https://rich.readthedocs.io/en/stable/appendix/colors.html
34# -- Redemanded table cell padding. 1 space on the left and 3 on the right.
35PADDING = padding = (0, 3, 0, 1)
37# -- Line width when rendering help and docs.
38DOCS_WIDTH = 70
41# -- This console state is initialized at the end of this file.
42@dataclass
43class ConsoleState:
44 """Contains the state of the apio console."""
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
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
62# -- Initialized by Configure().
63_state: ConsoleState = None
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."""
75 # pylint: disable=global-statement
77 global _state
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.
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
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
107 # -- Determine console color system parameter.
108 color_system = "auto" if theme.colors_enabled else None
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
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 )
126 # -- Construct the helper decoder.
127 decoder = AnsiDecoder()
129 # -- Save the state
130 _state = ConsoleState(
131 terminal_mode=terminal_mode,
132 theme=theme,
133 console=console_,
134 decoder=decoder,
135 )
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()
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."
159def is_colors_enabled() -> bool:
160 """Returns True if colors are enabled."""
161 check_apio_console_configured()
162 return _state.theme.colors_enabled
165def current_theme_name() -> str:
166 """Return the current theme name."""
167 check_apio_console_configured()
168 return _state.theme.name
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
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
186def cflush() -> None:
187 """Flush the console output."""
189 # pylint: disable=protected-access
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()
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 = [""]
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)
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)
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 )
227 # console().file.flush()
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("")
237 # console().file.flush()
238 # console()._check_buffer()
239 cflush()
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()
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()
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()
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()
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()
290class ConsoleCapture:
291 """A context manager to output into a string."""
293 def __init__(self):
294 self._saved_file = None
295 self._buffer = None
297 def __enter__(self):
298 cflush()
299 self._saved_file = console().file
300 self._buffer = StringIO()
301 console().file = self._buffer
302 return self
304 def __exit__(self, exc_type, exc_value, traceback):
305 console().file = self._saved_file
307 @property
308 def value(self):
309 """Returns the captured text."""
310 return self._buffer.getvalue()
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
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)
328def is_terminal():
329 """Returns True if the console writes to a terminal (vs a pipe)."""
330 return console().is_terminal
333def cwidth():
334 """Return the console width."""
335 return console().width
338def get_theme() -> ApioTheme:
339 """Return the the current theme."""
340 check_apio_console_configured()
341 return _state.theme