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
« 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."""
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)
31# -- The Rich library colors names are listed at:
32# -- https://rich.readthedocs.io/en/stable/appendix/colors.html
35# -- Redemanded table cell padding. 1 space on the left and 3 on the right.
36PADDING = padding = (0, 3, 0, 1)
38# -- Line width when rendering help and docs.
39DOCS_WIDTH = 70
42# -- This console state is initialized at the end of this file.
43@dataclass
44class ConsoleState:
45 """Contains the state of the apio console."""
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
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
63# -- Initialized by Configure().
64_state: ConsoleState | None = None
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."""
76 # pylint: disable=global-statement
78 global _state
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.
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
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
108 # -- Determine console color system parameter.
109 color_system = "auto" if theme.colors_enabled else None
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
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 )
127 # -- Construct the helper decoder.
128 decoder = AnsiDecoder()
130 # -- Save the state
131 _state = ConsoleState(
132 terminal_mode=terminal_mode,
133 theme=theme,
134 console=console_,
135 decoder=decoder,
136 )
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()
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."
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
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
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
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
192def cflush() -> None:
193 """Flush the console output."""
195 # pylint: disable=protected-access
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()
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 = ("",)
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)
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)
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 )
233 # console().file.flush()
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("")
243 # console().file.flush()
244 # console()._check_buffer()
245 cflush()
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()
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()
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()
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()
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()
296class ConsoleCapture:
297 """A context manager to output into a string."""
299 def __init__(self):
300 self._saved_file: Optional[IO[str]] = None
301 self._buffer: Optional[StringIO] = None
303 def __enter__(self):
304 cflush()
305 self._saved_file = console().file
306 self._buffer = StringIO()
307 console().file = self._buffer
308 return self
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
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 ""
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
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)
338def is_terminal():
339 """Returns True if the console writes to a terminal (vs a pipe)."""
340 return console().is_terminal
343def cwidth():
344 """Return the console width."""
345 return console().width
348def get_theme() -> ApioTheme:
349 """Return the the current theme."""
350 check_apio_console_configured()
352 assert _state is not None and _state.theme is not None
353 return _state.theme