From 2b1f463de5205ddf54c1d32b743ac9c23f1a7fec Mon Sep 17 00:00:00 2001 From: Thaddeus Crews Date: Tue, 25 Mar 2025 12:20:00 -0500 Subject: [PATCH] SCons: Refactor `color.py` --- SConstruct | 6 +- doc/tools/doc_status.py | 9 +- doc/tools/make_rst.py | 5 +- methods.py | 4 +- misc/scripts/install_d3d12_sdk_windows.py | 12 +- misc/utility/color.py | 165 ++++++++++++---------- 6 files changed, 111 insertions(+), 90 deletions(-) diff --git a/SConstruct b/SConstruct index 24491ccbaea..88fdb6b9fcc 100644 --- a/SConstruct +++ b/SConstruct @@ -58,7 +58,7 @@ import gles3_builders import glsl_builders import methods import scu_builders -from misc.utility.color import STDERR_COLOR, print_error, print_info, print_warning +from misc.utility.color import is_stderr_color, print_error, print_info, print_warning from platform_methods import architecture_aliases, architectures, compatibility_platform_aliases if ARGUMENTS.get("target", "editor") == "editor": @@ -704,9 +704,9 @@ if env["arch"] == "x86_32": # Explicitly specify colored output. if methods.using_gcc(env): - env.AppendUnique(CCFLAGS=["-fdiagnostics-color" if STDERR_COLOR else "-fno-diagnostics-color"]) + env.AppendUnique(CCFLAGS=["-fdiagnostics-color" if is_stderr_color() else "-fno-diagnostics-color"]) elif methods.using_clang(env) or methods.using_emcc(env): - env.AppendUnique(CCFLAGS=["-fcolor-diagnostics" if STDERR_COLOR else "-fno-color-diagnostics"]) + env.AppendUnique(CCFLAGS=["-fcolor-diagnostics" if is_stderr_color() else "-fno-color-diagnostics"]) if sys.platform == "win32": env.AppendUnique(CCFLAGS=["-fansi-escape-codes"]) diff --git a/doc/tools/doc_status.py b/doc/tools/doc_status.py index bbc89a33452..960778dec98 100755 --- a/doc/tools/doc_status.py +++ b/doc/tools/doc_status.py @@ -10,14 +10,14 @@ from typing import Dict, List, Set sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "../../")) -from misc.utility.color import NO_COLOR, STDOUT_COLOR, Ansi, toggle_color +from misc.utility.color import Ansi, force_stdout_color, is_stdout_color ################################################################################ # Config # ################################################################################ flags = { - "c": STDOUT_COLOR, + "c": is_stdout_color(), "b": False, "g": False, "s": False, @@ -114,7 +114,7 @@ def validate_tag(elem: ET.Element, tag: str) -> None: def color(color: str, string: str) -> str: - if NO_COLOR: + if not is_stdout_color(): return string color_format = "".join([str(x) for x in colors[color]]) return f"{color_format}{string}{Ansi.RESET}" @@ -332,8 +332,7 @@ if flags["u"]: table_column_names.append("Docs URL") table_columns.append("url") -if flags["c"]: - toggle_color(True) +force_stdout_color(flags["c"]) ################################################################################ # Help # diff --git a/doc/tools/make_rst.py b/doc/tools/make_rst.py index 0b4896d25e3..243c08ba1c8 100755 --- a/doc/tools/make_rst.py +++ b/doc/tools/make_rst.py @@ -13,7 +13,7 @@ from typing import Any, Dict, List, Optional, TextIO, Tuple, Union sys.path.insert(0, root_directory := os.path.join(os.path.dirname(os.path.abspath(__file__)), "../../")) import version -from misc.utility.color import Ansi, toggle_color +from misc.utility.color import Ansi, force_stderr_color, force_stdout_color # $DOCS_URL/path/to/page.html(#fragment-tag) GODOT_DOCS_PATTERN = re.compile(r"^\$DOCS_URL/(.*)\.html(#.*)?$") @@ -698,7 +698,8 @@ def main() -> None: args = parser.parse_args() if args.color: - toggle_color(True) + force_stdout_color(True) + force_stderr_color(True) # Retrieve heading translations for the given language. if not args.dry_run and args.lang != "en": diff --git a/methods.py b/methods.py index 29afe58949e..0bde3aa5457 100644 --- a/methods.py +++ b/methods.py @@ -428,9 +428,9 @@ def use_windows_spawn_fix(self, platform=None): def no_verbose(env): - from misc.utility.color import Ansi + from misc.utility.color import Ansi, is_stdout_color - colors = [Ansi.BLUE, Ansi.BOLD, Ansi.REGULAR, Ansi.RESET] + colors = [Ansi.BLUE, Ansi.BOLD, Ansi.REGULAR, Ansi.RESET] if is_stdout_color() else ["", "", "", ""] # There is a space before "..." to ensure that source file names can be # Ctrl + clicked in the VS Code terminal. diff --git a/misc/scripts/install_d3d12_sdk_windows.py b/misc/scripts/install_d3d12_sdk_windows.py index 26e5181d9a0..7425000e229 100644 --- a/misc/scripts/install_d3d12_sdk_windows.py +++ b/misc/scripts/install_d3d12_sdk_windows.py @@ -8,7 +8,7 @@ import urllib.request sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "../../")) -from misc.utility.color import Ansi +from misc.utility.color import Ansi, color_print # Base Godot dependencies path # If cross-compiling (no LOCALAPPDATA), we install in `bin` @@ -42,7 +42,7 @@ if not os.path.exists(deps_folder): os.makedirs(deps_folder) # Mesa NIR -print(f"{Ansi.BOLD}[1/3] Mesa NIR{Ansi.RESET}") +color_print(f"{Ansi.BOLD}[1/3] Mesa NIR") if os.path.isfile(mesa_archive): os.remove(mesa_archive) print(f"Downloading Mesa NIR {mesa_filename} ...") @@ -69,7 +69,7 @@ if dlltool == "": dlltool = shutil.which("x86_64-w64-mingw32-dlltool") or "" has_mingw = gendef != "" and dlltool != "" -print(f"{Ansi.BOLD}[2/3] WinPixEventRuntime{Ansi.RESET}") +color_print(f"{Ansi.BOLD}[2/3] WinPixEventRuntime") if os.path.isfile(pix_archive): os.remove(pix_archive) print(f"Downloading WinPixEventRuntime {pix_version} ...") @@ -100,7 +100,7 @@ else: print(f"WinPixEventRuntime {pix_version} installed successfully.\n") # DirectX 12 Agility SDK -print(f"{Ansi.BOLD}[3/3] DirectX 12 Agility SDK{Ansi.RESET}") +color_print(f"{Ansi.BOLD}[3/3] DirectX 12 Agility SDK") if os.path.isfile(agility_sdk_archive): os.remove(agility_sdk_archive) print(f"Downloading DirectX 12 Agility SDK {agility_sdk_version} ...") @@ -116,5 +116,5 @@ os.remove(agility_sdk_archive) print(f"DirectX 12 Agility SDK {agility_sdk_version} installed successfully.\n") # Complete message -print(f'{Ansi.GREEN}All Direct3D 12 SDK components were installed to "{deps_folder}" successfully!{Ansi.RESET}') -print(f'{Ansi.GREEN}You can now build Godot with Direct3D 12 support enabled by running "scons d3d12=yes".{Ansi.RESET}') +color_print(f'{Ansi.GREEN}All Direct3D 12 SDK components were installed to "{deps_folder}" successfully!') +color_print(f'{Ansi.GREEN}You can now build Godot with Direct3D 12 support enabled by running "scons d3d12=yes".') diff --git a/misc/utility/color.py b/misc/utility/color.py index 97d3e7856d1..e482b6a776e 100644 --- a/misc/utility/color.py +++ b/misc/utility/color.py @@ -1,86 +1,58 @@ from __future__ import annotations import os +import re import sys from enum import Enum from typing import Final # Colors are disabled in non-TTY environments such as pipes. This means if output is redirected -# to a file, it won't contain color codes. Colors are always enabled on continuous integration. +# to a file, it won't contain color codes. Colors are enabled by default on continuous integration. IS_CI: Final[bool] = bool(os.environ.get("CI")) NO_COLOR: Final[bool] = bool(os.environ.get("NO_COLOR")) +CLICOLOR_FORCE: Final[bool] = bool(os.environ.get("CLICOLOR_FORCE")) STDOUT_TTY: Final[bool] = bool(sys.stdout.isatty()) STDERR_TTY: Final[bool] = bool(sys.stderr.isatty()) -def _color_supported(stdout: bool) -> bool: +_STDOUT_ORIGINAL: Final[bool] = False if NO_COLOR else CLICOLOR_FORCE or IS_CI or STDOUT_TTY +_STDERR_ORIGINAL: Final[bool] = False if NO_COLOR else CLICOLOR_FORCE or IS_CI or STDERR_TTY +_stdout_override: bool = _STDOUT_ORIGINAL +_stderr_override: bool = _STDERR_ORIGINAL + + +def is_stdout_color() -> bool: + return _stdout_override + + +def is_stderr_color() -> bool: + return _stderr_override + + +def force_stdout_color(value: bool) -> None: """ - Validates if the current environment supports colored output. Attempts to enable ANSI escape - code support on Windows 10 and later. + Explicitly set `stdout` support for ANSI escape codes. + If environment overrides exist, does nothing. """ - if IS_CI: - return True - - if sys.platform != "win32": - return STDOUT_TTY if stdout else STDERR_TTY - else: - from ctypes import POINTER, WINFUNCTYPE, WinError, windll - from ctypes.wintypes import BOOL, DWORD, HANDLE - - STD_HANDLE = -11 if stdout else -12 - ENABLE_VIRTUAL_TERMINAL_PROCESSING = 4 - - def err_handler(result, func, args): - if not result: - raise WinError() - return args - - GetStdHandle = WINFUNCTYPE(HANDLE, DWORD)(("GetStdHandle", windll.kernel32), ((1, "nStdHandle"),)) - GetConsoleMode = WINFUNCTYPE(BOOL, HANDLE, POINTER(DWORD))( - ("GetConsoleMode", windll.kernel32), - ((1, "hConsoleHandle"), (2, "lpMode")), - ) - GetConsoleMode.errcheck = err_handler - SetConsoleMode = WINFUNCTYPE(BOOL, HANDLE, DWORD)( - ("SetConsoleMode", windll.kernel32), - ((1, "hConsoleHandle"), (1, "dwMode")), - ) - SetConsoleMode.errcheck = err_handler - - try: - handle = GetStdHandle(STD_HANDLE) - flags = GetConsoleMode(handle) - SetConsoleMode(handle, flags | ENABLE_VIRTUAL_TERMINAL_PROCESSING) - return True - except OSError: - return False - - -STDOUT_COLOR: Final[bool] = _color_supported(True) -STDERR_COLOR: Final[bool] = _color_supported(False) -_stdout_override: bool = STDOUT_COLOR -_stderr_override: bool = STDERR_COLOR - - -def toggle_color(stdout: bool, value: bool | None = None) -> None: - """ - Explicitly toggle color codes, regardless of support. - - - `stdout`: A boolean to choose the output stream. `True` for stdout, `False` for stderr. - - `value`: An optional boolean to explicitly set the color state instead of toggling. - """ - if stdout: + if not NO_COLOR or not CLICOLOR_FORCE: global _stdout_override - _stdout_override = value if value is not None else not _stdout_override - else: + _stdout_override = value + + +def force_stderr_color(value: bool) -> None: + """ + Explicitly set `stderr` support for ANSI escape codes. + If environment overrides exist, does nothing. + """ + if not NO_COLOR or not CLICOLOR_FORCE: global _stderr_override - _stderr_override = value if value is not None else not _stderr_override + _stderr_override = value class Ansi(Enum): """ - Enum class for adding ansi codepoints directly into strings. Automatically converts values to + Enum class for adding ANSI codepoints directly into strings. Automatically converts values to strings representing their internal value. """ @@ -107,25 +79,74 @@ class Ansi(Enum): return self.value +RE_ANSI = re.compile(r"\x1b\[[=\?]?[;\d]+[a-zA-Z]") + + +def color_print(*values: object, sep: str | None = " ", end: str | None = "\n", flush: bool = False) -> None: + """Prints a colored message to `stdout`. If disabled, ANSI codes are automatically stripped.""" + if is_stdout_color(): + print(*values, sep=sep, end=f"{Ansi.RESET}{end}", flush=flush) + else: + print(RE_ANSI.sub("", (sep or " ").join(map(str, values))), sep="", end=end, flush=flush) + + +def color_printerr(*values: object, sep: str | None = " ", end: str | None = "\n", flush: bool = False) -> None: + """Prints a colored message to `stderr`. If disabled, ANSI codes are automatically stripped.""" + if is_stderr_color(): + print(*values, sep=sep, end=f"{Ansi.RESET}{end}", flush=flush, file=sys.stderr) + else: + print(RE_ANSI.sub("", (sep or " ").join(map(str, values))), sep="", end=end, flush=flush, file=sys.stderr) + + def print_info(*values: object) -> None: """Prints a informational message with formatting.""" - if _stdout_override: - print(f"{Ansi.GRAY}{Ansi.BOLD}INFO:{Ansi.REGULAR}", *values, Ansi.RESET) - else: - print("INFO:", *values) + color_print(f"{Ansi.GRAY}{Ansi.BOLD}INFO:{Ansi.REGULAR}", *values) def print_warning(*values: object) -> None: """Prints a warning message with formatting.""" - if _stderr_override: - print(f"{Ansi.YELLOW}{Ansi.BOLD}WARNING:{Ansi.REGULAR}", *values, Ansi.RESET, file=sys.stderr) - else: - print("WARNING:", *values, file=sys.stderr) + color_printerr(f"{Ansi.YELLOW}{Ansi.BOLD}WARNING:{Ansi.REGULAR}", *values) def print_error(*values: object) -> None: """Prints an error message with formatting.""" - if _stderr_override: - print(f"{Ansi.RED}{Ansi.BOLD}ERROR:{Ansi.REGULAR}", *values, Ansi.RESET, file=sys.stderr) - else: - print("ERROR:", *values, file=sys.stderr) + color_printerr(f"{Ansi.RED}{Ansi.BOLD}ERROR:{Ansi.REGULAR}", *values) + + +if sys.platform == "win32": + + def _win_color_fix(): + """Attempts to enable ANSI escape code support on Windows 10 and later.""" + from ctypes import POINTER, WINFUNCTYPE, WinError, windll + from ctypes.wintypes import BOOL, DWORD, HANDLE + + STDOUT_HANDLE = -11 + STDERR_HANDLE = -12 + ENABLE_VIRTUAL_TERMINAL_PROCESSING = 4 + + def err_handler(result, func, args): + if not result: + raise WinError() + return args + + GetStdHandle = WINFUNCTYPE(HANDLE, DWORD)(("GetStdHandle", windll.kernel32), ((1, "nStdHandle"),)) + GetConsoleMode = WINFUNCTYPE(BOOL, HANDLE, POINTER(DWORD))( + ("GetConsoleMode", windll.kernel32), + ((1, "hConsoleHandle"), (2, "lpMode")), + ) + GetConsoleMode.errcheck = err_handler + SetConsoleMode = WINFUNCTYPE(BOOL, HANDLE, DWORD)( + ("SetConsoleMode", windll.kernel32), + ((1, "hConsoleHandle"), (1, "dwMode")), + ) + SetConsoleMode.errcheck = err_handler + + for handle_id in [STDOUT_HANDLE, STDERR_HANDLE]: + try: + handle = GetStdHandle(handle_id) + flags = GetConsoleMode(handle) + SetConsoleMode(handle, flags | ENABLE_VIRTUAL_TERMINAL_PROCESSING) + except OSError: + pass + + _win_color_fix()