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
« 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
9"""A module with functions to manages the apio console output."""
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)
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:
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: Literal["auto"] | None = (
110 "auto" if theme.colors_enabled else None
111 )
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
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 )
129 # -- Construct the helper decoder.
130 decoder = AnsiDecoder()
132 # -- Save the state
133 _state = ConsoleState(
134 terminal_mode=terminal_mode,
135 theme=theme,
136 console=console_,
137 decoder=decoder,
138 )
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()
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."
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
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
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
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
194def cflush() -> None:
195 """Flush the console output."""
197 # pylint: disable=protected-access
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()
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 = ("",)
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)
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)
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 )
235 # console().file.flush()
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("")
245 # console().file.flush()
246 # console()._check_buffer()
247 cflush()
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()
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()
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()
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()
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."""
295 # -- If info is a string, convert it to a list with a single string.
296 if isinstance(info, str):
297 info = [info]
299 # -- Construct a list of error lines.
300 error_lines = list(error_text_lines)
301 if cause:
302 error_lines.append(f"{cause}")
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
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
317 # -- Print the error line(s)
318 cerror(*error_lines)
320 # -- Print the optional info lines.
321 if info:
322 cout(*info, style=INFO)
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 )
338 # -- Exit with error.
339 sys.exit(1)
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()
353def cstyle(text: str, style: str | None = None) -> str:
354 """Render the text to a string using an optional style."""
356 # -- Render into a string buffer.
357 with console().capture() as capture:
358 console().out(text, style=style, highlight=False, end="")
360 return capture.get()
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)
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)
380 return capture.get()
383def is_terminal():
384 """Returns True if the console writes to a terminal (vs a pipe)."""
385 return console().is_terminal
388def cwidth():
389 """Return the console width."""
390 return console().width
393def get_theme() -> ApioTheme:
394 """Return the the current theme."""
395 check_apio_console_configured()
397 assert _state is not None and _state.theme is not None
398 return _state.theme