Coverage for apio/common/apio_console.py: 93%
146 statements
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-23 03:53 +0000
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-23 03:53 +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."""
12import sys
13import os
14import traceback
15from dataclasses import dataclass
16from typing import NoReturn, Literal
17from rich.console import Console
18from rich.ansi import AnsiDecoder
19from rich.theme import Theme
20from rich.text import Text
21from rich.table import Table
22from apio.common import rich_lib_windows
23from apio.common.apio_styles import WARNING, ERROR, INFO
24from apio.common.apio_themes import ApioTheme, THEMES_TABLE, DEFAULT_THEME
25from apio.common.proto.apio_scons_pb2 import (
26 TerminalMode,
27 FORCE_PIPE,
28 FORCE_TERMINAL,
29 AUTO_TERMINAL,
30)
32# -- The Rich library colors names are listed at:
33# -- https://rich.readthedocs.io/en/stable/appendix/colors.html
36# -- Redemanded table cell padding. 1 space on the left and 3 on the right.
37PADDING = padding = (0, 3, 0, 1)
39# -- Line width when rendering help and docs.
40DOCS_WIDTH = 70
43# -- This console state is initialized at the end of this file.
44@dataclass
45class ConsoleState:
46 """Contains the state of the apio console."""
48 # None = auto. True and False force to terminal and pipe mode respectively.
49 terminal_mode: TerminalMode
50 # The theme object.
51 theme: ApioTheme
52 # The current console object.
53 console: Console
54 # The latest AnsiDecoder we use for capture printing.
55 decoder: AnsiDecoder
57 def __post_init__(self):
58 assert self.terminal_mode is not None
59 assert self.theme is not None
60 assert self.console is not None
61 assert self.decoder is not None
64# -- Initialized by Configure().
65_state: ConsoleState | None = None
68# NOTE: not declaring terminal_mode and theme_name is optional because it
69# causes the tests to fail with python 3.9.
70def configure(
71 *,
72 terminal_mode: TerminalMode | None = None,
73 theme_name: str | None = None,
74) -> None:
75 """Change the apio console settings."""
77 # pylint: disable=global-statement
79 global _state
81 # -- Force utf-8 output encoding. This is a workaround for rich library
82 # -- defaulting to non graphic ASCII border for tables.
83 # --
84 stdout_fixed = rich_lib_windows.fix_windows_stdout_encoding()
85 _ = stdout_fixed # For pylint, when debugging code below commented out.
87 # -- Determine the theme.
88 if theme_name:
89 # -- Used caller specified theme.
90 assert theme_name in THEMES_TABLE, theme_name
91 theme = THEMES_TABLE[theme_name]
92 assert theme.name == theme_name, theme
93 elif _state:
94 # -- Fall to theme name from state, if available.
95 theme = _state.theme
96 else:
97 # -- Fall to default theme.
98 theme = DEFAULT_THEME
100 # -- Determine terminal mode.
101 if terminal_mode is None:
102 if _state:
103 # -- Fall to terminal mode from the state.
104 terminal_mode = _state.terminal_mode
105 else:
106 # -- Fall to default.
107 terminal_mode = AUTO_TERMINAL
109 # -- Determine console color system parameter.
110 color_system: Literal["auto"] | None = (
111 "auto" if theme.colors_enabled else None
112 )
114 # -- Determine console's force_terminal parameter.
115 if terminal_mode == FORCE_TERMINAL:
116 force_terminal = True
117 elif terminal_mode == FORCE_PIPE:
118 force_terminal = False
119 else:
120 assert terminal_mode == AUTO_TERMINAL, terminal_mode
121 force_terminal = None
123 # -- Construct the new console.
124 console_ = Console(
125 color_system=color_system,
126 force_terminal=force_terminal,
127 theme=Theme(theme.styles, inherit=False),
128 )
130 # -- Construct the helper decoder.
131 decoder = AnsiDecoder()
133 # -- Save the state
134 _state = ConsoleState(
135 terminal_mode=terminal_mode,
136 theme=theme,
137 console=console_,
138 decoder=decoder,
139 )
141 # -- For debugging.
142 # print()
143 # print(f"*** {stdout_fixed=}")
144 # print(f"*** {terminal_mode=}")
145 # print(f"*** {theme_name=}")
146 # print(f"*** {theme.name=}")
147 # print(f"*** {color_system=}")
148 # print(f"*** {terminal_mode=}")
149 # print(f"*** {force_terminal=}")
150 # print(f"*** {_state.console.is_terminal=}")
151 # print(f"*** {_state.console.encoding=}")
152 # print(f"*** {_state.console.is_dumb_terminal=}")
153 # print(f"*** {_state.console.safe_box=}")
154 # print(f"*** state={_state}")
155 # print()
158def check_apio_console_configured():
159 """A common check that the apio console has been configured."""
160 assert _state is not None
161 assert _state.console, "The apio console is not configured."
164def is_colors_enabled() -> bool:
165 """Returns True if colors are enabled."""
166 check_apio_console_configured()
167 assert _state is not None
168 return _state.theme.colors_enabled
171def current_theme_name() -> str:
172 """Return the current theme name."""
173 check_apio_console_configured()
174 assert _state is not None
175 return _state.theme.name
178def console():
179 """Returns the underlying console. This value should not be cached as
180 the console object changes when the configure() or reset() are called."""
181 check_apio_console_configured()
182 assert _state is not None
183 return _state.console
186def cunstyle(text: str) -> str:
187 """A replacement for click unstyle(). This function removes ansi colors
188 from a string."""
189 check_apio_console_configured()
190 assert _state is not None
191 text_obj: Text = _state.decoder.decode_line(text)
192 return text_obj.plain
195def cflush() -> None:
196 """Flush the console output."""
198 # pylint: disable=protected-access
200 # -- Flush the console buffer to the output stream.
201 # -- NOTE: We couldn't find an official API for flushing
202 # -- THE console's buffer.
203 console()._check_buffer()
204 # -- Flush the output stream.
205 console().file.flush()
208def cout(
209 *text_lines: str,
210 style: str = "",
211 nl: bool = True,
212) -> None:
213 """Prints lines of text to the console, using the optional style."""
214 # -- If no args, just do an empty println.
215 if not text_lines:
216 text_lines = ("",)
218 for text_line in text_lines:
219 # -- User is responsible to conversion to strings.
220 assert isinstance(text_line, (str, Table)), type(text_line)
222 # -- If colors are off, strip potential coloring in the text.
223 # -- This may be coloring that we received from the scons process.
224 if not console().color_system:
225 text_line = cunstyle(text_line)
227 # -- Write it out using the given style but without line break.
228 # -- We first convert it to Text as a workaround for
229 # -- https://github.com/Textualize/rich/discussions/3779.
230 console().print(
231 Text.from_ansi(text_line, style=style, end=""),
232 highlight=False,
233 end="",
234 )
236 # console().file.flush()
238 # -- If needed, write the line break. By writing the line break in
239 # -- a separate call, we force the console().out() call above to
240 # -- reset the colors before the line break rather than after. This
241 # -- caused an additional blank lines after a colored fatal error
242 # -- messages from scons.
243 if nl: 243 ↛ 218line 243 didn't jump to line 218 because the condition on line 243 was always true
244 console().print("")
246 # console().file.flush()
247 # console()._check_buffer()
248 cflush()
251def ctable(table: Table) -> None:
252 """Write out a Rich lib Table."""
253 assert isinstance(table, Table), type(table)
254 console().print(table)
255 cflush()
258def cmarkdown(markdown_text: str) -> None:
259 """Write out a Rich markdown text."""
260 assert isinstance(markdown_text, str), type(markdown_text)
261 console().print(markdown_text)
262 cflush()
265def cwrite(s: str) -> None:
266 """A low level output that doesn't do any formatting, style, line
267 terminator and so on. Flushing is important"""
268 # -- Flush the existing console buffer and the output stream.
269 cflush()
270 # -- Write directly to the output stream, bypassing the
271 # -- console's buffer.
272 console().file.write(s)
273 # -- Flush again.
274 cflush()
277def cerror(*text_lines: str) -> None:
278 """Prints one or more text lines, adding to the first one the prefix
279 'Error: ' and applying to all of them the red color."""
280 # -- Output the first line.
281 console().out(f"Error: {text_lines[0]}", style=ERROR, highlight=False)
282 # -- Output the rest of the lines.
283 for text_line in text_lines[1:]:
284 console().out(text_line, highlight=False, style=ERROR)
285 cflush()
288def fatal_error(
289 *error_text_lines: str,
290 info: list[str] | str | None = None,
291 cause: Exception | None = None,
292) -> NoReturn:
293 """Prints one or more error lines, then optional info lines, and then
294 exists the program with an error status."""
296 # -- If info is a string, convert it to a list with a single string.
297 if isinstance(info, str):
298 info = [info]
300 # -- Construct a list of error lines.
301 error_lines = list(error_text_lines)
302 if cause:
303 error_lines.append(f"{cause}")
305 # -- If there are no error lines, create a generic one and drop
306 # -- any info text.
307 if not error_lines:
308 error_lines.append("Unspecified error detected.")
309 info = [] # Drop any info text
311 # -- We use an independent ad-hoc is_debug flag to make this function
312 # -- as stand alone as possible.
313 var = os.environ.get("APIO_DEBUG", "0")
314 if var.startswith('"') and var.endswith('"'): 314 ↛ 315line 314 didn't jump to line 315 because the condition on line 314 was never true
315 var = var[1:-1] # For windows
316 is_debug = var.isdigit() and int(var) > 0
318 # -- Print the error line(s)
319 cerror(*error_lines)
321 # -- Print the optional info lines.
322 if info:
323 cout(*info, style=INFO)
325 # -- If debug, print current stack information and underlying cause
326 # -- exception if available.
327 if is_debug:
328 print("Stack (this thread):")
329 traceback.print_stack()
330 if cause is not None: 330 ↛ 340line 330 didn't jump to line 340 because the condition on line 330 was always true
331 print("\nException")
332 traceback.print_exception(cause)
333 else:
334 cout(
335 "Hint: For debugging information, set env var APIO_DEBUG=1.",
336 style=INFO,
337 )
339 # -- Exit with error.
340 sys.exit(1)
343def cwarning(*text_lines: str) -> None:
344 """Prints one or more text lines, adding to the first one the prefix
345 'Warning: ' and applying to all of them the yellow color."""
346 # -- Emit first line.
347 console().out(f"Warning: {text_lines[0]}", style=WARNING, highlight=False)
348 # -- Emit the rest of the lines
349 for text_line in text_lines[1:]:
350 console().out(text_line, highlight=False, style=WARNING)
351 cflush()
354def cstyle(text: str, style: str | None = None) -> str:
355 """Render the text to a string using an optional style."""
357 # -- Render into a string buffer.
358 with console().capture() as capture:
359 console().out(text, style=style, highlight=False, end="")
361 return capture.get()
364def docs_text(
365 rich_text: str, width: int = DOCS_WIDTH, end: str = "\n"
366) -> None:
367 """A wrapper around Console.print that is specialized for rendering
368 help and docs."""
369 console().print(rich_text, highlight=True, width=width, end=end)
372def docs_text_to_str(
373 rich_text: str, width: int = DOCS_WIDTH, end: str = "\n"
374) -> str:
375 """Same as docs_text() but renders to a string instead of
376 stdout."""
377 # -- Render into a string.
378 with console().capture() as capture:
379 docs_text(rich_text=rich_text, width=width, end=end)
381 return capture.get()
384def is_terminal():
385 """Returns True if the console writes to a terminal (vs a pipe)."""
386 return console().is_terminal
389def cwidth():
390 """Return the console width."""
391 return console().width
394def get_theme() -> ApioTheme:
395 """Return the the current theme."""
396 check_apio_console_configured()
398 assert _state is not None and _state.theme is not None
399 return _state.theme