Skip to content

Commit

Permalink
Define theme for Cloud Shell
Browse files Browse the repository at this point in the history
  • Loading branch information
jiasli committed Apr 16, 2021
1 parent 4b28b0e commit ddaf2e5
Show file tree
Hide file tree
Showing 10 changed files with 139 additions and 95 deletions.
2 changes: 1 addition & 1 deletion .github/CODEOWNERS
Validating CODEOWNERS rules …
Original file line number Diff line number Diff line change
Expand Up @@ -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
27 changes: 21 additions & 6 deletions src/azure-cli-core/azure/cli/core/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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):

Expand Down
136 changes: 77 additions & 59 deletions src/azure-cli-core/azure/cli/core/style.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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
}


Expand Down Expand Up @@ -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"):
Expand Down Expand Up @@ -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:
Expand All @@ -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)


Expand Down Expand Up @@ -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
27 changes: 12 additions & 15 deletions src/azure-cli-core/azure/cli/core/tests/test_style.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -33,27 +33,27 @@ 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
\x1b[96mBright Cyan: Hyperlink
\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):
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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__':
Expand Down
6 changes: 6 additions & 0 deletions src/azure-cli-core/azure/cli/core/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
3 changes: 1 addition & 2 deletions src/azure-cli-core/setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading

0 comments on commit ddaf2e5

Please sign in to comment.