From ddaf2e50644318889253c32a8859d294e9dadd7d Mon Sep 17 00:00:00 2001 From: jiasli <4003950+jiasli@users.noreply.github.com> Date: Fri, 16 Apr 2021 17:18:31 +0800 Subject: [PATCH] Define theme for Cloud Shell --- .github/CODEOWNERS | 2 +- src/azure-cli-core/azure/cli/core/__init__.py | 27 +++- src/azure-cli-core/azure/cli/core/style.py | 136 ++++++++++-------- .../azure/cli/core/tests/test_style.py | 27 ++-- src/azure-cli-core/azure/cli/core/util.py | 6 + src/azure-cli-core/setup.py | 3 +- .../azure/cli/command_modules/util/custom.py | 27 ++-- src/azure-cli/requirements.py3.Darwin.txt | 2 +- src/azure-cli/requirements.py3.Linux.txt | 2 +- src/azure-cli/requirements.py3.windows.txt | 2 +- 10 files changed, 139 insertions(+), 95 deletions(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index daebf830641..0781a5b366d 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -56,5 +56,5 @@ /src/azure-cli/azure/cli/command_modules/sql/ @jaredmoo @Juliehzl @evelyn-ys /src/azure-cli/azure/cli/command_modules/storage/ @Juliehzl @jsntcy @zhoxing-ms @evelyn-ys /src/azure-cli/azure/cli/command_modules/synapse/ @idear1203 @sunsw1994 @aim-for-better -/src/azure-cli/azure/cli/command_modules/util/ @jiasli @Juliehzl @zhoxing-ms +/src/azure-cli/azure/cli/command_modules/util/ @jiasli @Juliehzl @zhoxing-ms @evelyn-ys /src/azure-cli/azure/cli/command_modules/vm/ @qwordy @houk-ms @yungezz diff --git a/src/azure-cli-core/azure/cli/core/__init__.py b/src/azure-cli-core/azure/cli/core/__init__.py index c4dfc7f8b22..f2dc9042d34 100644 --- a/src/azure-cli-core/azure/cli/core/__init__.py +++ b/src/azure-cli-core/azure/cli/core/__init__.py @@ -64,7 +64,6 @@ def __init__(self, **kwargs): from azure.cli.core.cloud import get_active_cloud from azure.cli.core.commands.transform import register_global_transforms from azure.cli.core._session import ACCOUNT, CONFIG, SESSION, INDEX, VERSIONS - from azure.cli.core.style import format_styled_text from azure.cli.core.util import handle_version_update from azure.cli.core.commands.query_examples import register_global_query_examples_argument @@ -96,11 +95,7 @@ def __init__(self, **kwargs): self.progress_controller = None - if self.enable_color: - theme = self.config.get('core', 'theme', fallback='dark') - else: - theme = 'none' - format_styled_text.theme = theme + self._configure_style() def refresh_request_id(self): """Assign a new random GUID as x-ms-client-request-id @@ -182,6 +177,26 @@ def save_local_context(self, parsed_args, argument_definitions, specified_argume logger.warning('Your preference of %s now saved to local context. To learn more, type in `az ' 'local-context --help`', ', '.join(args_str) + ' is' if len(args_str) == 1 else ' are') + def _configure_style(self): + from azure.cli.core.util import in_cloud_console + from azure.cli.core.style import format_styled_text, get_theme_dict, Style + + # Configure Style + if self.enable_color: + theme = self.config.get('core', 'theme', + fallback="cloud-shell" if in_cloud_console() else "dark") + + theme_dict = get_theme_dict(theme) + + if theme_dict: + # If theme is used, also apply it to knack's logger + from knack.util import color_map + color_map['error'] = theme_dict[Style.ERROR] + color_map['warning'] = theme_dict[Style.WARNING] + else: + theme = 'none' + format_styled_text.theme = theme + class MainCommandsLoader(CLICommandsLoader): diff --git a/src/azure-cli-core/azure/cli/core/style.py b/src/azure-cli-core/azure/cli/core/style.py index e61fa61d410..9826f70ab18 100644 --- a/src/azure-cli-core/azure/cli/core/style.py +++ b/src/azure-cli-core/azure/cli/core/style.py @@ -11,14 +11,20 @@ Design spec: https://devdivdesignguide.azurewebsites.net/command-line-interface/color-guidelines-for-command-line-interface/ +Console Virtual Terminal Sequences: +https://docs.microsoft.com/en-us/windows/console/console-virtual-terminal-sequences#text-formatting + For a complete demo, see `src/azure-cli/azure/cli/command_modules/util/custom.py` and run `az demo style`. """ -import os import sys from enum import Enum -from colorama import Fore +from knack.log import get_logger +from knack.util import is_modern_terminal + + +logger = get_logger(__name__) class Style(str, Enum): @@ -33,53 +39,84 @@ class Style(str, Enum): WARNING = "warning" +def _rgb_hex(rgb_hex: str): + """ + Convert RGB hex value to Control Sequences. + """ + template = '\x1b[38;2;{r};{g};{b}m' + if rgb_hex.startswith("#"): + rgb_hex = rgb_hex[1:] + + rgb = {} + for i, c in enumerate(('r', 'g', 'b')): + value_str = rgb_hex[i * 2: i * 2 + 2] + value_int = int(value_str, 16) + rgb[c] = value_int + + return template.format(**rgb) + + +DEFAULT = '\x1b[0m' # Default + # Theme that doesn't contain any style -THEME_NONE = {} +THEME_NONE = None -# Theme to be used on a dark-themed terminal +# Theme to be used in a dark-themed terminal THEME_DARK = { - # Style to ANSI escape sequence mapping - # https://docs.microsoft.com/en-us/windows/console/console-virtual-terminal-sequences - Style.PRIMARY: Fore.RESET, - Style.SECONDARY: Fore.LIGHTBLACK_EX, # may use WHITE, but will lose contrast to LIGHTWHITE_EX - Style.IMPORTANT: Fore.LIGHTMAGENTA_EX, - Style.ACTION: Fore.LIGHTBLUE_EX, - Style.HYPERLINK: Fore.LIGHTCYAN_EX, - # Message colors - Style.ERROR: Fore.LIGHTRED_EX, - Style.SUCCESS: Fore.LIGHTGREEN_EX, - Style.WARNING: Fore.LIGHTYELLOW_EX, + Style.PRIMARY: DEFAULT, + Style.SECONDARY: '\x1b[90m', # Bright Foreground Black + Style.IMPORTANT: '\x1b[95m', # Bright Foreground Magenta + Style.ACTION: '\x1b[94m', # Bright Foreground Blue + Style.HYPERLINK: '\x1b[96m', # Bright Foreground Cyan + Style.ERROR: '\x1b[91m', # Bright Foreground Red + Style.SUCCESS: '\x1b[92m', # Bright Foreground Green + Style.WARNING: '\x1b[93m' # Bright Foreground Yellow } -# Theme to be used on a light-themed terminal +# Theme to be used in a light-themed terminal THEME_LIGHT = { - Style.PRIMARY: Fore.RESET, - Style.SECONDARY: Fore.LIGHTBLACK_EX, - Style.IMPORTANT: Fore.MAGENTA, - Style.ACTION: Fore.BLUE, - Style.HYPERLINK: Fore.CYAN, - Style.ERROR: Fore.RED, - Style.SUCCESS: Fore.GREEN, - Style.WARNING: Fore.YELLOW, + Style.PRIMARY: DEFAULT, + Style.SECONDARY: '\x1b[90m', # Bright Foreground Black + Style.IMPORTANT: '\x1b[35m', # Foreground Magenta + Style.ACTION: '\x1b[34m', # Foreground Blue + Style.HYPERLINK: '\x1b[36m', # Foreground Cyan + Style.ERROR: '\x1b[31m', # Foreground Red + Style.SUCCESS: '\x1b[32m', # Foreground Green + Style.WARNING: '\x1b[33m' # Foreground Yellow +} + +# Theme to be used in Cloud Shell +# Text and background's Contrast Ratio should be above 4.5:1 +THEME_CLOUD_SHELL = { + Style.PRIMARY: _rgb_hex('#ffffff'), + Style.SECONDARY: _rgb_hex('#bcbcbc'), + Style.IMPORTANT: _rgb_hex('#f887ff'), + Style.ACTION: _rgb_hex('#6cb0ff'), + Style.HYPERLINK: _rgb_hex('#72d7d8'), + Style.ERROR: _rgb_hex('#f55d5c'), + Style.SUCCESS: _rgb_hex('#70d784'), + Style.WARNING: _rgb_hex('#fbd682'), } class Theme(str, Enum): DARK = 'dark' LIGHT = 'light' + CLOUD_SHELL = 'cloud-shell' NONE = 'none' THEME_DEFINITIONS = { - Theme.NONE: THEME_NONE, Theme.DARK: THEME_DARK, - Theme.LIGHT: THEME_LIGHT + Theme.LIGHT: THEME_LIGHT, + Theme.CLOUD_SHELL: THEME_CLOUD_SHELL, + Theme.NONE: THEME_NONE } # Blue and bright blue is not visible under the default theme of powershell.exe POWERSHELL_COLOR_REPLACEMENT = { - Fore.BLUE: Fore.RESET, - Fore.LIGHTBLUE_EX: Fore.RESET + '\x1b[34m': DEFAULT, # Foreground Blue + '\x1b[94m': DEFAULT # Bright Foreground Blue } @@ -113,11 +150,7 @@ def format_styled_text(styled_text, theme=None): # Convert str to the theme dict if isinstance(theme, str): - try: - theme = THEME_DEFINITIONS[theme] - except KeyError: - from azure.cli.core.azclierror import CLIInternalError - raise CLIInternalError("Invalid theme. Supported themes: none, dark, light") + theme = get_theme_dict(theme) # Cache the value of is_legacy_powershell if not hasattr(format_styled_text, "_is_legacy_powershell"): @@ -145,9 +178,7 @@ def format_styled_text(styled_text, theme=None): style, raw_text = text - if theme is THEME_NONE: - formatted_parts.append(raw_text) - else: + if theme: try: escape_seq = theme[style] except KeyError: @@ -157,10 +188,12 @@ def format_styled_text(styled_text, theme=None): if is_legacy_powershell and escape_seq in POWERSHELL_COLOR_REPLACEMENT: escape_seq = POWERSHELL_COLOR_REPLACEMENT[escape_seq] formatted_parts.append(escape_seq + raw_text) + else: + formatted_parts.append(raw_text) # Reset control sequence if theme is not THEME_NONE: - formatted_parts.append(Fore.RESET) + formatted_parts.append(DEFAULT) return ''.join(formatted_parts) @@ -199,25 +232,10 @@ def highlight_command(raw_command): return styled_command -def _is_modern_terminal(): - # Windows Terminal: https://github.com/microsoft/terminal/issues/1040 - if 'WT_SESSION' in os.environ: - return True - # VS Code: https://github.com/microsoft/vscode/pull/30346 - if os.environ.get('TERM_PROGRAM', '').lower() == 'vscode': - return True - return False - - -def is_modern_terminal(): - """Detect whether the current terminal is a modern terminal that supports Unicode and - Console Virtual Terminal Sequences. - - Currently, these terminals can be detected: - - Windows Terminal - - VS Code terminal - """ - # This function wraps _is_modern_terminal and use a function-level cache to save the result. - if not hasattr(is_modern_terminal, "return_value"): - setattr(is_modern_terminal, "return_value", _is_modern_terminal()) - return getattr(is_modern_terminal, "return_value") +def get_theme_dict(theme: str): + try: + return THEME_DEFINITIONS[theme] + except KeyError as ex: + available_themes = ', '.join([m.value for m in Theme.__members__.values()]) # pylint: disable=no-member + logger.warning("Invalid theme %s. Supported themes: %s", ex, available_themes) + return None diff --git a/src/azure-cli-core/azure/cli/core/tests/test_style.py b/src/azure-cli-core/azure/cli/core/tests/test_style.py index 68ac9fe1561..c671c96586a 100644 --- a/src/azure-cli-core/azure/cli/core/tests/test_style.py +++ b/src/azure-cli-core/azure/cli/core/tests/test_style.py @@ -7,7 +7,7 @@ import unittest from unittest import mock -from azure.cli.core.style import Style, Theme, format_styled_text, print_styled_text, _is_modern_terminal +from azure.cli.core.style import Style, Theme, format_styled_text, print_styled_text, _rgb_hex class TestStyle(unittest.TestCase): @@ -33,7 +33,7 @@ def test_format_styled_text(self): (Style.WARNING, "Bright Yellow: Warning message indicator\n"), ] formatted = format_styled_text(styled_text) - excepted = """\x1b[39mWhite: Primary text color + excepted = """\x1b[0mWhite: Primary text color \x1b[90mBright Black: Secondary text color \x1b[95mBright Magenta: Important text color \x1b[94mBright Blue: Commands, parameters, and system inputs @@ -41,19 +41,19 @@ def test_format_styled_text(self): \x1b[91mBright Red: Error message indicator \x1b[92mBright Green: Success message indicator \x1b[93mBright Yellow: Warning message indicator -\x1b[39m""" +\x1b[0m""" self.assertEqual(formatted, excepted) # Test str input styled_text = "Primary text color" formatted = format_styled_text(styled_text) - excepted = "\x1b[39mPrimary text color\x1b[39m" + excepted = "\x1b[0mPrimary text color\x1b[0m" self.assertEqual(formatted, excepted) # Test tuple input styled_text = (Style.PRIMARY, "Primary text color") formatted = format_styled_text(styled_text) - excepted = "\x1b[39mPrimary text color\x1b[39m" + excepted = "\x1b[0mPrimary text color\x1b[0m" self.assertEqual(formatted, excepted) def test_format_styled_text_on_error(self): @@ -111,9 +111,9 @@ def test_format_styled_text_theme(self): (Style.SECONDARY, "Bright Black: Secondary text color\n"), ] formatted = format_styled_text(styled_text) - excepted = """\x1b[39mWhite: Primary text color + excepted = """\x1b[0mWhite: Primary text color \x1b[90mBright Black: Secondary text color -\x1b[39m""" +\x1b[0m""" self.assertEqual(formatted, excepted) # Color is turned off via param @@ -156,16 +156,13 @@ def test_print_styled_text(self, mock_format_styled_text, mock_print): print_styled_text("test text 1", "test text 2") mock_print.assert_called_with("test text 1", "test text 2", file=sys.stderr) + def test_rgb_hex(self): -class TestUtils(unittest.TestCase): + result = _rgb_hex("#13A10E") + self.assertEqual(result, '\x1b[38;2;19;161;14m') - def test_is_modern_terminal(self): - with mock.patch.dict("os.environ", clear=True): - self.assertEqual(_is_modern_terminal(), False) - with mock.patch.dict("os.environ", WT_SESSION='c25cb945-246a-49e5-b37a-1e4b6671b916'): - self.assertEqual(_is_modern_terminal(), True) - with mock.patch.dict("os.environ", TERM_PROGRAM='vscode'): - self.assertEqual(_is_modern_terminal(), True) + result = _rgb_hex("3A96DD") + self.assertEqual(result, '\x1b[38;2;58;150;221m') if __name__ == '__main__': diff --git a/src/azure-cli-core/azure/cli/core/util.py b/src/azure-cli-core/azure/cli/core/util.py index 424574ee83a..090becc5c7f 100644 --- a/src/azure-cli-core/azure/cli/core/util.py +++ b/src/azure-cli-core/azure/cli/core/util.py @@ -1266,3 +1266,9 @@ def get_parent_proc_name(): parent_proc_name = _get_parent_proc_name() setattr(get_parent_proc_name, "return_value", parent_proc_name) return getattr(get_parent_proc_name, "return_value") + + +def is_modern_terminal(): + """In addition to knack.util.is_modern_terminal, detect Cloud Shell.""" + import knack.util + return knack.util.is_modern_terminal() or in_cloud_console() diff --git a/src/azure-cli-core/setup.py b/src/azure-cli-core/setup.py index 761089d8000..c1e2363d963 100644 --- a/src/azure-cli-core/setup.py +++ b/src/azure-cli-core/setup.py @@ -48,11 +48,10 @@ 'azure-common~=1.1', 'azure-core==1.12.0', 'azure-mgmt-core>=1.2.0,<2.0.0', - 'colorama~=0.4.1', 'cryptography>=3.2,<3.4', 'humanfriendly>=4.7,<10.0', 'jmespath', - 'knack~=0.8.0', + 'knack~=0.8.1', 'msal>=1.10.0,<2.0.0', # Dependencies of the vendored subscription SDK # https://github.com/Azure/azure-sdk-for-python/blob/ab12b048ddf676fe0ccec16b2167117f0609700d/sdk/resources/azure-mgmt-resource/setup.py#L82-L86 diff --git a/src/azure-cli/azure/cli/command_modules/util/custom.py b/src/azure-cli/azure/cli/command_modules/util/custom.py index eb7025a2f6c..4526adbc0ba 100644 --- a/src/azure-cli/azure/cli/command_modules/util/custom.py +++ b/src/azure-cli/azure/cli/command_modules/util/custom.py @@ -192,17 +192,17 @@ def demo_style(cmd, theme=None): # pylint: disable=unused-argument print_styled_text() print_styled_text("[Available styles]\n") - placeholder = '{:19s}: {}\n' + placeholder = '████ {:8s}: {}\n' styled_text = [ (Style.PRIMARY, placeholder.format("White", "Primary text color")), - (Style.SECONDARY, placeholder.format("Bright Black", "Secondary text color")), - (Style.IMPORTANT, placeholder.format("Bright/Dark Magent", "Important text color")), + (Style.SECONDARY, placeholder.format("Grey", "Secondary text color")), + (Style.IMPORTANT, placeholder.format("Magenta", "Important text color")), (Style.ACTION, placeholder.format( - "Bright/Dark Blue", "Commands, parameters, and system inputs. (White in legacy powershell terminal.)")), - (Style.HYPERLINK, placeholder.format("Bright/Dark Cyan", "Hyperlink")), - (Style.ERROR, placeholder.format("Bright/Dark Red", "Error message indicator")), - (Style.SUCCESS, placeholder.format("Bright/Dark Green", "Success message indicator")), - (Style.WARNING, placeholder.format("Bright/Dark Yellow", "Warning message indicator")), + "Blue", "Commands, parameters, and system inputs (White in legacy powershell terminal)")), + (Style.HYPERLINK, placeholder.format("Cyan", "Hyperlink")), + (Style.ERROR, placeholder.format("Red", "Error message indicator")), + (Style.SUCCESS, placeholder.format("Green", "Success message indicator")), + (Style.WARNING, placeholder.format("Yellow", "Warning message indicator")), ] print_styled_text(styled_text) @@ -260,6 +260,15 @@ def demo_style(cmd, theme=None): # pylint: disable=unused-argument (Style.PRIMARY, ". To switch to another subscription, run "), (Style.ACTION, "az account set --subscription"), (Style.PRIMARY, " \n"), - (Style.WARNING, "WARNING: The subscription has been disabled!") + (Style.WARNING, "WARNING: The subscription has been disabled!\n") ] print_styled_text(styled_text) + + print_styled_text("[logs]\n") + + # Print logs + logger.debug("This is a debug log entry.") + logger.info("This is a info log entry.") + logger.warning("This is a warning log entry.") + logger.error("This is a error log entry.") + logger.critical("This is a critical log entry.") diff --git a/src/azure-cli/requirements.py3.Darwin.txt b/src/azure-cli/requirements.py3.Darwin.txt index 6e2143f1246..880108c886e 100644 --- a/src/azure-cli/requirements.py3.Darwin.txt +++ b/src/azure-cli/requirements.py3.Darwin.txt @@ -105,7 +105,7 @@ isodate==0.6.0 Jinja2==2.11.3 jmespath==0.9.5 jsmin==2.2.2 -knack==0.8.0 +knack==0.8.1 MarkupSafe==1.1.1 mock==4.0.2 msal==1.10.0 diff --git a/src/azure-cli/requirements.py3.Linux.txt b/src/azure-cli/requirements.py3.Linux.txt index 4d4bfcb83e7..94a32f4adff 100644 --- a/src/azure-cli/requirements.py3.Linux.txt +++ b/src/azure-cli/requirements.py3.Linux.txt @@ -105,7 +105,7 @@ isodate==0.6.0 Jinja2==2.11.3 jmespath==0.9.5 jsmin==2.2.2 -knack==0.8.0 +knack==0.8.1 MarkupSafe==1.1.1 mock==4.0.2 msal==1.10.0 diff --git a/src/azure-cli/requirements.py3.windows.txt b/src/azure-cli/requirements.py3.windows.txt index 68d8bd8aceb..1d8aa273b74 100644 --- a/src/azure-cli/requirements.py3.windows.txt +++ b/src/azure-cli/requirements.py3.windows.txt @@ -104,7 +104,7 @@ isodate==0.6.0 Jinja2==2.11.3 jmespath==0.9.5 jsmin==2.2.2 -knack==0.8.0 +knack==0.8.1 MarkupSafe==1.1.1 mock==4.0.2 msal==1.10.0