Repository: softlayer/softlayer-python
Pull request #1775 (2022-10-21 15:57:14)

New feature to change theme in slcli like dark, light o maintain in default
Issue link: #1652
New feature to change theme in slcli like dark, light o maintain in default

With this new feature you can choose the theme for slcli like "dark, light" or if you not set theme, will use default theme
You can see the theme with the command `slcli config show`
![image](https://user-images.githubusercontent.com/43519769/197235624-e2c4d58e-2b79-4a5d-851f-99ebb2ac2aaf.png)

I'ts possible change to theme with the follow command `slcli config setup`
![image](https://user-images.githubusercontent.com/43519769/197236089-2214b7db-7715-48e7-ae8c-12e9e2cca074.png)

Also, it's possible to set the env variable with `export SL_THEME`


Example
![image](https://user-images.githubusercontent.com/43519769/197236798-7f8f9b9b-74cc-4014-8226-11c45d4f94d9.png)
![image](https://user-images.githubusercontent.com/43519769/197236904-f44c642b-add1-4b55-a22a-3ea1a3cbc424.png)


For now the color changes are small because they only adapt to see better according to the background, but now you can add new themes, for more drastic color changes


Patch:
diff --git a/SoftLayer/CLI/command.py b/SoftLayer/CLI/command.py
index 95013769..c6f98300 100644
--- a/SoftLayer/CLI/command.py
+++ b/SoftLayer/CLI/command.py
@@ -11,11 +11,9 @@
 import click
 
 from rich import box
-from rich.console import Console
 from rich.highlighter import RegexHighlighter
 from rich.table import Table
 from rich.text import Text
-from rich.theme import Theme
 
 from SoftLayer.CLI import environment
 
@@ -26,53 +24,42 @@ class OptionHighlighter(RegexHighlighter):
         r"(?P<switch>^\-\w)",  # single options like -v
         r"(?P<option>\-\-[\w\-]+)",  # long options like --verbose
         r"(?P<default_option>\[[^\]]+\])",  # anything between [], usually default options
-
     ]
 
 
-#  Colors defined in https://rich.readthedocs.io/en/latest/_modules/rich/color.html#ColorType
-SLCLI_THEME = Theme(
-    {
-        "option": "bold cyan",
-        "switch": "bold green",
-        "default_option": "light_pink1",
-        "option_keyword": "bold cyan",
-        "args_keyword": "bold green"
-    }
-)
-
-
 class CommandLoader(click.MultiCommand):
     """Loads module for click."""
 
     def __init__(self, *path, **attrs):
         click.MultiCommand.__init__(self, **attrs)
         self.path = path
-
         self.highlighter = OptionHighlighter()
-        self.console = Console(
-            theme=SLCLI_THEME
-        )
+        self.env = None
+        self.console = None
+
+    def ensure_env(self, ctx):
+        """ensures self.env is set"""
+        if self.env is None:
+            self.env = ctx.ensure_object(environment.Environment)
+            self.env.load()
+        if self.console is None:
+            self.console = self.env.console
 
     def list_commands(self, ctx):
         """List all sub-commands."""
-        env = ctx.ensure_object(environment.Environment)
-        env.load()
-
-        return sorted(env.list_commands(*self.path))
+        self.ensure_env(ctx)
+        return sorted(self.env.list_commands(*self.path))
 
     def get_command(self, ctx, cmd_name):
         """Get command for click."""
-        env = ctx.ensure_object(environment.Environment)
-        env.load()
-
+        self.ensure_env(ctx)
         # Do alias lookup (only available for root commands)
         if len(self.path) == 0:
-            cmd_name = env.resolve_alias(cmd_name)
+            cmd_name = self.env.resolve_alias(cmd_name)
 
         new_path = list(self.path)
         new_path.append(cmd_name)
-        module = env.get_command(*new_path)
+        module = self.env.get_command(*new_path)
         if isinstance(module, types.ModuleType):
             return CommandLoader(*new_path, help=module.__doc__ or '')
         else:
@@ -80,14 +67,15 @@ def get_command(self, ctx, cmd_name):
 
     def format_usage(self, ctx: click.Context, formatter: click.formatting.HelpFormatter) -> None:
         """Formats and colorizes the usage information."""
+        self.ensure_env(ctx)
         pieces = self.collect_usage_pieces(ctx)
         for index, piece in enumerate(pieces):
             if piece == "[OPTIONS]":
-                pieces[index] = "[bold cyan][OPTIONS][/bold cyan]"
+                pieces[index] = "[options][OPTIONS][/]"
             elif piece == "COMMAND [ARGS]...":
-                pieces[index] = "[orange1]COMMAND[/orange1] [bold cyan][ARGS][/bold cyan] ..."
+                pieces[index] = "[command]COMMAND[/] [args][ARGS][/] ..."
 
-        self.console.print(f"Usage: [bold red]{ctx.command_path}[/bold red] {' '.join(pieces)}")
+        self.console.print(f"Usage: [path]{ctx.command_path}[/] {' '.join(pieces)}")
 
     def format_help_text(self, ctx: click.Context, formatter: click.formatting.HelpFormatter) -> None:
         """Writes the help text"""
@@ -153,9 +141,9 @@ def format_commands(self, ctx, formatter):
         if len(commands):
             for subcommand, cmd in commands:
                 help_text = cmd.get_short_help_str(120)
-                command_style = Text(f" {subcommand}", style="orange1")
+                command_style = Text(f" {subcommand}", style="sub_command")
                 command_table.add_row(command_style, help_text)
-        self.console.print("\n[bold orange1]Commands:[/]")
+        self.console.print("\n[name_sub_command]Commands:[/]")
         self.console.print(command_table)
 
 
@@ -165,20 +153,28 @@ class SLCommand(click.Command):
     def __init__(self, **attrs):
         click.Command.__init__(self, **attrs)
         self.highlighter = OptionHighlighter()
-        self.console = Console(
-            theme=SLCLI_THEME
-        )
+        self.env = None
+        self.console = None
+
+    def ensure_env(self, ctx):
+        """ensures self.env is set"""
+        if self.env is None:
+            self.env = ctx.ensure_object(environment.Environment)
+            self.env.load()
+        if self.console is None:
+            self.console = self.env.console
 
     def format_usage(self, ctx: click.Context, formatter: click.formatting.HelpFormatter) -> None:
         """Formats and colorizes the usage information."""
+        self.ensure_env(ctx)
         pieces = self.collect_usage_pieces(ctx)
         for index, piece in enumerate(pieces):
             if piece == "[OPTIONS]":
-                pieces[index] = "[bold cyan][OPTIONS][/bold cyan]"
+                pieces[index] = "[options][OPTIONS][/]"
             elif piece == "COMMAND [ARGS]...":
-                pieces[index] = "[orange1]COMMAND[/orange1] [bold cyan][ARGS][/bold cyan] ..."
+                pieces[index] = "[command]COMMAND[/] [args][ARGS][/] ..."
 
-        self.console.print(f"Usage: [bold red]{ctx.command_path}[/bold red] {' '.join(pieces)}")
+        self.console.print(f"Usage: [path]{ctx.command_path}[/] {' '.join(pieces)}")
 
     def format_help_text(self, ctx: click.Context, formatter: click.formatting.HelpFormatter) -> None:
         """Writes the help text"""
diff --git a/SoftLayer/CLI/config/__init__.py b/SoftLayer/CLI/config/__init__.py
index fc1cca2b..a4b5c578 100644
--- a/SoftLayer/CLI/config/__init__.py
+++ b/SoftLayer/CLI/config/__init__.py
@@ -13,7 +13,7 @@ def _resolve_transport(transport):
     return _resolve_transport(nested_transport)
 
 
-def get_settings_from_client(client):
+def get_settings_from_client(client, theme):
     """Pull out settings from a SoftLayer.BaseClient instance.
 
     :param client: SoftLayer.BaseClient instance
@@ -23,6 +23,7 @@ def get_settings_from_client(client):
         'api_key': '',
         'timeout': '',
         'endpoint_url': '',
+        'theme': theme
     }
     try:
         settings['username'] = client.auth.username
@@ -49,4 +50,5 @@ def config_table(settings):
     table.add_row(['API Key', settings['api_key'] or 'not set'])
     table.add_row(['Endpoint URL', settings['endpoint_url'] or 'not set'])
     table.add_row(['Timeout', settings['timeout'] or 'not set'])
+    table.add_row(['Theme', settings['theme'] or 'not set'])
     return table
diff --git a/SoftLayer/CLI/config/setup.py b/SoftLayer/CLI/config/setup.py
index 2e2c87b0..f06825ab 100644
--- a/SoftLayer/CLI/config/setup.py
+++ b/SoftLayer/CLI/config/setup.py
@@ -68,7 +68,7 @@ def cli(env, auth):
     api_key = None
 
     timeout = 0
-    defaults = config.get_settings_from_client(env.client)
+    defaults = config.get_settings_from_client(env.client, env.theme)
     endpoint_url = get_endpoint_url(env, defaults.get('endpoint_url', 'public'))
     # Get ths username and API key
     if auth == 'ibmid':
@@ -92,6 +92,9 @@ def cli(env, auth):
     # Ask for timeout, convert to float, then to int
     timeout = int(float(env.input('Timeout', default=defaults['timeout'] or 0)))
 
+    # Ask theme for console
+    theme = env.input('Theme (dark/light)', default=defaults['theme'] or 'dark')
+
     path = '~/.softlayer'
     if env.config_file:
         path = env.config_file
@@ -100,7 +103,8 @@ def cli(env, auth):
     env.out(env.fmt(config.config_table({'username': username,
                                          'api_key': api_key,
                                          'endpoint_url': endpoint_url,
-                                         'timeout': timeout})))
+                                         'timeout': timeout,
+                                         'theme': theme})))
 
     if not formatting.confirm('Are you sure you want to write settings to "%s"?' % config_path, default=True):
         raise exceptions.CLIAbort('Aborted.')
@@ -118,6 +122,7 @@ def cli(env, auth):
     parsed_config.set('softlayer', 'api_key', api_key)
     parsed_config.set('softlayer', 'endpoint_url', endpoint_url)
     parsed_config.set('softlayer', 'timeout', timeout)
+    parsed_config.set('softlayer', 'theme', theme)
 
     config_fd = os.fdopen(os.open(config_path, (os.O_WRONLY | os.O_CREAT | os.O_TRUNC), 0o600), 'w')
     try:
diff --git a/SoftLayer/CLI/config/show.py b/SoftLayer/CLI/config/show.py
index a5a933fe..ece6f9e4 100644
--- a/SoftLayer/CLI/config/show.py
+++ b/SoftLayer/CLI/config/show.py
@@ -13,5 +13,5 @@
 def cli(env):
     """Show current configuration."""
 
-    settings = config.get_settings_from_client(env.client)
+    settings = config.get_settings_from_client(client=env.client, theme=env.theme)
     env.fout(config.config_table(settings))
diff --git a/SoftLayer/CLI/core.py b/SoftLayer/CLI/core.py
index b3a1fead..2f55a0db 100644
--- a/SoftLayer/CLI/core.py
+++ b/SoftLayer/CLI/core.py
@@ -118,6 +118,7 @@ def cli(env,
     env.skip_confirmations = really
     env.config_file = config
     env.format = format
+    env.set_env_theme(config_file=config)
     env.ensure_client(config_file=config, is_demo=demo, proxy=proxy)
     env.vars['_start'] = time.time()
     logger = logging.getLogger()
diff --git a/SoftLayer/CLI/environment.py b/SoftLayer/CLI/environment.py
index d7b98501..79964efb 100644
--- a/SoftLayer/CLI/environment.py
+++ b/SoftLayer/CLI/environment.py
@@ -5,6 +5,9 @@
 
     :license: MIT, see LICENSE for more details.
 """
+import configparser
+import os
+
 import importlib
 from json.decoder import JSONDecodeError
 
@@ -16,6 +19,7 @@
 import SoftLayer
 from SoftLayer.CLI import formatting
 from SoftLayer.CLI import routes
+from SoftLayer import utils
 
 # pylint: disable=too-many-instance-attributes, invalid-name
 
@@ -35,7 +39,8 @@ def __init__(self):
         self.vars = {}
 
         self.client = None
-        self.console = Console()
+        self.theme = self.set_env_theme()
+        self.console = utils.console_color_themes(self.theme)
         self.err_console = Console(stderr=True)
         self.format = 'table'
         self.skip_confirmations = False
@@ -76,7 +81,7 @@ def fmt(self, output, fmt=None):
         """Format output based on current the environment format."""
         if fmt is None:
             fmt = self.format
-        return formatting.format_output(output, fmt)
+        return formatting.format_output(output, fmt, self.theme)
 
     def format_output_is_json(self):
         """Return True if format output is json or jsonraw"""
@@ -208,6 +213,25 @@ def ensure_client(self, config_file=None, is_demo=False, proxy=None):
             )
         self.client = client
 
+    def set_env_theme(self, config_file=None):
+        """Get theme to color console and set in env"""
+        theme = os.environ.get('SL_THEME')
+        if theme:
+            return theme
+        else:
+            config_files = ['/etc/softlayer.conf', '~/.softlayer']
+            path_os = os.getenv('HOME')
+            if path_os:
+                config_files.append(path_os + '\\AppData\\Roaming\\softlayer')
+            if config_file:
+                config_files.append(config_file)
+            config = configparser.RawConfigParser({'theme': 'dark'})
+            config.read(config_files)
+            if config.has_section('softlayer'):
+                self.theme = config.get('softlayer', 'theme')
+                return config.get('softlayer', 'theme')
+        return 'dark'
+
 
 class ModuleLoader(object):
     """Module loader that acts a little like an EntryPoint object."""
diff --git a/SoftLayer/CLI/formatting.py b/SoftLayer/CLI/formatting.py
index b4df8417..bf8ce831 100644
--- a/SoftLayer/CLI/formatting.py
+++ b/SoftLayer/CLI/formatting.py
@@ -22,7 +22,7 @@
 FALSE_VALUES = ['0', 'false', 'FALSE', 'no', 'False']
 
 
-def format_output(data, fmt='table'):  # pylint: disable=R0911,R0912
+def format_output(data, fmt='table', theme=None):  # pylint: disable=R0911,R0912
     """Given some data, will format it for console output.
 
     :param data: One of: String, Table, FormattedItem, List, Tuple, SequentialOutput
@@ -40,7 +40,7 @@ def format_output(data, fmt='table'):  # pylint: disable=R0911,R0912
 
     # responds to .prettytable()
     if hasattr(data, 'prettytable') and fmt in ('table', 'raw'):
-        return format_prettytable(data, fmt)
+        return format_prettytable(data, fmt, theme)
 
     # responds to .to_python()
     if hasattr(data, 'to_python'):
@@ -73,7 +73,7 @@ def format_output(data, fmt='table'):  # pylint: disable=R0911,R0912
     return str(data)
 
 
-def format_prettytable(table, fmt='table'):
+def format_prettytable(table, fmt='table', theme=None):
     """Converts SoftLayer.CLI.formatting.Table instance to a prettytable."""
     for i, row in enumerate(table.rows):
         for j, item in enumerate(row):
@@ -83,7 +83,7 @@ def format_prettytable(table, fmt='table'):
                 table.rows[i][j] = format_output(item)
             else:
                 table.rows[i][j] = str(item)
-    ptable = table.prettytable(fmt)
+    ptable = table.prettytable(fmt, theme)
     return ptable
 
 
@@ -267,12 +267,13 @@ def to_python(self):
             items.append(dict(zip(self.columns, formatted_row)))
         return items
 
-    def prettytable(self, fmt='table'):
+    def prettytable(self, fmt='table', theme=None):
         """Returns a RICH table instance."""
         box_style = box.SQUARE
         if fmt == 'raw':
             box_style = None
-        table = rTable(title=self.title, box=box_style, header_style="bright_cyan")
+        color_table = utils.table_color_theme(theme)
+        table = rTable(title=self.title, box=box_style, header_style=color_table['header'])
         if self.sortby:
             try:
                 # https://docs.python.org/3/howto/sorting.html#key-functions
@@ -300,7 +301,7 @@ def prettytable(self, fmt='table'):
                 justify = 'left'
             # Special coloring for some columns
             if col in ('id', 'Id', 'ID'):
-                style = "pale_violet_red1"
+                style = color_table['id_columns']
             table.add_column(col, justify=justify, style=style)
 
         for row in self.rows:
diff --git a/SoftLayer/utils.py b/SoftLayer/utils.py
index 54502d41..df6d3438 100644
--- a/SoftLayer/utils.py
+++ b/SoftLayer/utils.py
@@ -11,6 +11,8 @@
 import re
 import time
 
+from rich.console import Console
+from rich.theme import Theme
 
 # pylint: disable=no-member, invalid-name
 
@@ -459,3 +461,50 @@ def decode_stacked(document, pos=0, decoder=JSONDecoder()):
         obj, pos = decoder.raw_decode(document, pos)
 
         yield obj
+
+
+def console_color_themes(theme):
+    """Colors in https://rich.readthedocs.io/en/stable/appendix/colors.html?highlight=light_pink1#standard-colors"""
+
+    if theme == 'light':
+        return Console(theme=Theme(
+            {
+                "options": "bold dark_cyan",  # OPTIONS
+                "command": "orange3",  # COMMAND
+                "args": "bold dark_cyan",  # ARGS
+                "path": "bold red",  # command path
+                "name_sub_command": "orange3",  # sub command name
+                "sub_command": "orange3",  # sub command list
+                # Help table colors options
+                "option": "bold dark_cyan",
+                "switch": "bold green4",
+                "default_option": "light_coral",
+                "option_keyword": "bold dark_cyan",
+                "args_keyword": "bold green4",
+            })
+        )
+    return Console(theme=Theme(
+        {
+            "options": "bold cyan",  # OPTIONS
+            "command": "orange3",  # COMMAND
+            "args": "bold cyan",  # ARGS
+            "path": "bold red",  # command path
+            "name_sub_command": "orange3",  # sub command name
+            "sub_command": "orange3",  # sub command list
+            # Help table colors options
+            "option": "bold cyan",
+            "switch": "bold green",
+            "default_option": "light_pink1",
+            "option_keyword": "bold cyan",
+            "args_keyword": "bold green",
+        })
+    )
+
+
+def table_color_theme(theme):
+    """Define result table colors"""
+    if theme == 'light':
+        return {'header': 'dark_cyan',
+                'id_columns': 'light_coral'}
+    return {'header': 'bright_cyan',
+            'id_columns': 'pale_violet_red1'}


Test patch:
diff --git a/tests/CLI/modules/config_tests.py b/tests/CLI/modules/config_tests.py
index ef16edf3..5ee79406 100644
--- a/tests/CLI/modules/config_tests.py
+++ b/tests/CLI/modules/config_tests.py
@@ -30,13 +30,13 @@ def set_up(self):
 
     def test_show(self):
         result = self.run_command(['config', 'show'])
-
         self.assert_no_fail(result)
         self.assertEqual(json.loads(result.output),
                          {'Username': 'username',
                           'API Key': 'api-key',
                           'Endpoint URL': 'http://endpoint-url',
-                          'Timeout': 'not set'})
+                          'Timeout': 'not set',
+                          'Theme': 'dark'})
 
 
 class TestHelpSetup(testing.TestCase):
@@ -63,12 +63,12 @@ def tearDown(self):
     @mock.patch('SoftLayer.CLI.environment.Environment.input')
     def test_setup(self, mocked_input, getpass, confirm_mock, client):
         client.return_value = self.env.client
-        if(sys.platform.startswith("win")):
+        if (sys.platform.startswith("win")):
             self.skipTest("Test doesn't work in Windows")
         with tempfile.NamedTemporaryFile() as config_file:
             confirm_mock.return_value = True
             getpass.return_value = 'A' * 64
-            mocked_input.side_effect = ['public', 'user', 0]
+            mocked_input.side_effect = ['public', 'user', 0, 'dark']
 
             result = self.run_command(['--config=%s' % config_file.name, 'config', 'setup'])
 
@@ -80,6 +80,7 @@ def test_setup(self, mocked_input, getpass, confirm_mock, client):
             self.assertIn('username = user', contents)
             self.assertIn('api_key = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', contents)
             self.assertIn('endpoint_url = %s' % consts.API_PUBLIC_ENDPOINT, contents)
+            self.assertIn('theme = dark', contents)
 
     @mock.patch('SoftLayer.Client')
     @mock.patch('SoftLayer.CLI.formatting.confirm')
@@ -89,7 +90,7 @@ def test_setup_float_timeout(self, mocked_input, getpass, confirm_mock, client):
         client.return_value = self.env.client
         confirm_mock.return_value = True
         getpass.return_value = 'A' * 64
-        mocked_input.side_effect = ['public', 'user', 10.0]
+        mocked_input.side_effect = ['public', 'user', 10.0, 'None']
 
         result = self.run_command(['--config=%s' % self.config_file, 'config', 'setup'])
 
@@ -114,8 +115,7 @@ def test_setup_cancel(self, mocked_input, getpass, confirm_mock, client):
         with tempfile.NamedTemporaryFile() as config_file:
             confirm_mock.return_value = False
             getpass.return_value = 'A' * 64
-            mocked_input.side_effect = ['public', 'user', 0]
-
+            mocked_input.side_effect = ['public', 'user', 0, 'None']
             result = self.run_command(['--config=%s' % config_file.name, 'config', 'setup'])
             self.assertEqual(result.exit_code, 2)
             self.assertIsInstance(result.exception, exceptions.CLIAbort)