Repository: python-cmd2/cmd2
Pull request #877 (2020-02-07 06:06:19)

Changelog fix for removed ansi.FG_COLORS and ansi.BG_COLORS
Updated changelog to address removal of `ansi.FG_COLORS` and `ansi.BG_COLORS` and mention their replacement by `ansi.fg` and `ansi.bg` enums

Also:
- Use `ansi.fg` in **async_printing.py** and **README.md**
- The fact that the set command now supports tab-completion of values is an enhancement, not a breaking change

This addresses a deficiency called out in a review of  #876 after it merged.

If we absolutely didn't want to remove these, they could be easily generated from the enums like so:
```Python
FG_COLORS = {c.name: c.value for c in fg}
```



Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 07225efc..11d34ed7 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,11 +1,15 @@
 ## 1.0.0-rc1 (TBD, 2020)
 * Enhancements
     * Changed the default help text to make `help -v` more discoverable
+    * **set** command now supports tab-completion of values
     * Added `add_settable()` and `remove_settable()` convenience methods to update `self.settable` dictionary
     * Added convenience `ansi.fg` and `ansi.bg` enums of foreground and background colors
         * `ansi.style()` `fg` argument can now either be of type `str` or `ansi.fg`
         * `ansi.style()` `bg` argument can now either be of type `str` or `ansi.bg`
         * This supports IDE auto-completion of color names
+        * The enums also support
+            * `f-strings` and `format()` calls (e.g. `"{}hello{}".format(fg.blue, fg.reset)`)
+            * string concatenation (e.g. `fg.blue + "hello" + fg.reset`)
 * Breaking changes
     * Renamed `locals_in_py` attribute of `cmd2.Cmd` to `self_in_py`
     * The following public attributes of `cmd2.Cmd` are no longer settable at runtime by default:
@@ -16,8 +20,9 @@
         * It is now a Dict[str, Settable] instead of Dict[str, str]
         * setting onchange callbacks have a new method signature and must be added to the
           Settable instance in order to be called
-        * **set** command now supports tab-completion of values
     * Removed `cast()` utility function
+    * Removed `ansi.FG_COLORS` and `ansi.BG_COLORS` dictionaries
+        * Replaced with `ansi.fg` and `ansi.bg` enums providing similar but improved functionality
 
 ## 0.9.25 (January 26, 2020)
 * Enhancements
diff --git a/README.md b/README.md
index 19bd259b..72053ef5 100755
--- a/README.md
+++ b/README.md
@@ -91,7 +91,7 @@ Instructions for implementing each feature follow.
     class MyApp(cmd2.Cmd):
         def do_foo(self, args):
             """This docstring is the built-in help for the foo command."""
-            self.poutput(cmd2.style('foo bar baz', fg='red'))
+            self.poutput(cmd2.style('foo bar baz', fg=cmd2.fg.red))
     ```
     - By default the docstring for your **do_foo** method is the help for the **foo** command
         - NOTE: This doesn't apply if you use one of the `argparse` decorators mentioned below
diff --git a/cmd2/ansi.py b/cmd2/ansi.py
index abe7b3dc..2813d62f 100644
--- a/cmd2/ansi.py
+++ b/cmd2/ansi.py
@@ -3,9 +3,9 @@
 Support for ANSI escape sequences which are used for things like applying style to text,
 setting the window title, and asynchronous alerts.
  """
-from enum import Enum, unique
 import functools
 import re
+from enum import Enum
 from typing import Any, IO, List, Union
 
 import colorama
@@ -27,11 +27,47 @@ allow_style = STYLE_TERMINAL
 ANSI_STYLE_RE = re.compile(r'\x1b\[[^m]*m')
 
 
+class ColorBase(Enum):
+    """
+    Base class used for defining color enums. See fg and bg classes for examples.
+
+    Child classes should define enums in the follow structure:
+        key: color name (e.g. black)
+        value: anything that when cast to a string returns an ANSI sequence
+    """
+    def __str__(self) -> str:
+        """
+        Return ANSI color sequence instead of enum name
+        This is helpful when using a ColorBase in an f-string or format() call
+        e.g. my_str = "{}hello{}".format(fg.blue, fg.reset)
+        """
+        return str(self.value)
+
+    def __add__(self, other: Any) -> str:
+        """
+        Support building a color string when self is the left operand
+        e.g. fg.blue + "hello"
+        """
+        return str(self) + other
+
+    def __radd__(self, other: Any) -> str:
+        """
+        Support building a color string when self is the right operand
+        e.g. "hello" + fg.reset
+        """
+        return other + str(self)
+
+    @classmethod
+    def colors(cls) -> List[str]:
+        """Return a list of color names."""
+        # Use __members__ to ensure we get all key names, including those which are aliased
+        return [color for color in cls.__members__]
+
+
 # Foreground colors
-# noinspection PyPep8Naming,DuplicatedCode
-@unique
-class fg(Enum):
-    """Enum class for foreground colors (to support IDE autocompletion)."""
+# noinspection PyPep8Naming
+class fg(ColorBase):
+    """Enum class for foreground colors"""
     black = Fore.BLACK
     red = Fore.RED
     green = Fore.GREEN
@@ -50,26 +86,11 @@ class fg(Enum):
     bright_white = Fore.LIGHTWHITE_EX
     reset = Fore.RESET
 
-    def __str__(self) -> str:
-        """Make the value the string representation instead of the enum name."""
-        return self.value
-
-    @staticmethod
-    def colors() -> List[str]:
-        """Return a list of color names."""
-        return [color.name for color in fg]
-
-    @staticmethod
-    def get_value(name: str) -> str:
-        """Retrieve color code by name string."""
-        return fg.__members__[name].value
-
 
 # Background colors
-# noinspection PyPep8Naming,DuplicatedCode
-@unique
-class bg(Enum):
-    """Enum class for background colors (to support IDE autocompletion)."""
+# noinspection PyPep8Naming
+class bg(ColorBase):
+    """Enum class for background colors"""
     black = Back.BLACK
     red = Back.RED
     green = Back.GREEN
@@ -88,20 +109,6 @@ class bg(Enum):
     bright_white = Back.LIGHTWHITE_EX
     reset = Back.RESET
 
-    def __str__(self) -> str:
-        """Make the value the string representation instead of the enum name."""
-        return self.value
-
-    @staticmethod
-    def colors() -> List[str]:
-        """Return a list of color names."""
-        return [color.name for color in bg]
-
-    @staticmethod
-    def get_value(name: str) -> str:
-        """Retrieve color code by name string."""
-        return bg.__members__[name].value
-
 
 FG_RESET = fg.reset.value
 BG_RESET = bg.reset.value
@@ -162,7 +169,7 @@ def fg_lookup(fg_name: Union[str, fg]) -> str:
         return fg_name.value
 
     try:
-        ansi_escape = fg.get_value(fg_name.lower())
+        ansi_escape = fg[fg_name.lower()].value
     except KeyError:
         raise ValueError('Foreground color {!r} does not exist; must be one of: {}'.format(fg_name, fg.colors()))
     return ansi_escape
@@ -180,7 +187,7 @@ def bg_lookup(bg_name: Union[str, bg]) -> str:
         return bg_name.value
 
     try:
-        ansi_escape = bg.get_value(bg_name.lower())
+        ansi_escape = bg[bg_name.lower()].value
     except KeyError:
         raise ValueError('Background color {!r} does not exist; must be one of: {}'.format(bg_name, bg.colors()))
     return ansi_escape
@@ -195,8 +202,10 @@ def style(text: Any, *, fg: Union[str, fg] = '', bg: Union[str, bg] = '', bold:
     to undo whatever styling was done at the beginning.
 
     :param text: Any object compatible with str.format()
-    :param fg: foreground color. Relies on `fg_lookup()` to retrieve ANSI escape based on name or enum. Defaults to no color.
-    :param bg: background color. Relies on `bg_lookup()` to retrieve ANSI escape based on name or enum. Defaults to no color.
+    :param fg: foreground color. Relies on `fg_lookup()` to retrieve ANSI escape based on name or enum.
+               Defaults to no color.
+    :param bg: background color. Relies on `bg_lookup()` to retrieve ANSI escape based on name or enum.
+               Defaults to no color.
     :param bold: apply the bold style if True. Can be combined with dim. Defaults to False.
     :param dim: apply the dim style if True. Can be combined with bold. Defaults to False.
     :param underline: apply the underline style if True. Defaults to False.
diff --git a/cmd2/argparse_custom.py b/cmd2/argparse_custom.py
index f735498d..a0e05ae9 100644
--- a/cmd2/argparse_custom.py
+++ b/cmd2/argparse_custom.py
@@ -529,7 +529,7 @@ argparse.ArgumentParser._match_argument = _match_argument_wrapper
 ############################################################################################################
 
 
-# noinspection PyCompatibility,PyShadowingBuiltins,PyShadowingBuiltins
+# noinspection PyCompatibility,PyShadowingBuiltins
 class Cmd2HelpFormatter(argparse.RawTextHelpFormatter):
     """Custom help formatter to configure ordering of help text"""
 
diff --git a/examples/async_printing.py b/examples/async_printing.py
index fdba14a0..692ce769 100755
--- a/examples/async_printing.py
+++ b/examples/async_printing.py
@@ -10,7 +10,7 @@ import time
 from typing import List
 
 import cmd2
-from cmd2 import ansi
+from cmd2 import style, fg
 
 ALERTS = ["Watch as this application prints alerts and updates the prompt",
           "This will only happen when the prompt is present",
@@ -145,20 +145,20 @@ class AlerterApp(cmd2.Cmd):
         """
         rand_num = random.randint(1, 20)
 
-        status_color = 'reset'
+        status_color = fg.reset
 
         if rand_num == 1:
-            status_color = 'bright_red'
+            status_color = fg.bright_red
         elif rand_num == 2:
-            status_color = 'bright_yellow'
+            status_color = fg.bright_yellow
         elif rand_num == 3:
-            status_color = 'cyan'
+            status_color = fg.cyan
         elif rand_num == 4:
-            status_color = 'bright_green'
+            status_color = fg.bright_green
         elif rand_num == 5:
-            status_color = 'bright_blue'
+            status_color = fg.bright_blue
 
-        return ansi.style(self.visible_prompt, fg=status_color)
+        return style(self.visible_prompt, fg=status_color)
 
     def _alerter_thread_func(self) -> None:
         """ Prints alerts and updates the prompt any time the prompt is showing """
diff --git a/examples/plumbum_colors.py b/examples/plumbum_colors.py
index 94815f50..2887ef1f 100755
--- a/examples/plumbum_colors.py
+++ b/examples/plumbum_colors.py
@@ -31,36 +31,37 @@ import cmd2
 from cmd2 import ansi
 from plumbum.colors import fg, bg
 
-FG_COLORS = {
-    'black': fg.Black,
-    'red': fg.DarkRedA,
-    'green': fg.MediumSpringGreen,
-    'yellow': fg.LightYellow,
-    'blue': fg.RoyalBlue1,
-    'magenta': fg.Purple,
-    'cyan': fg.SkyBlue1,
-    'white': fg.White,
-    'purple': fg.Purple,
-}
-
-BG_COLORS = {
-    'black': bg.BLACK,
-    'red': bg.DarkRedA,
-    'green': bg.MediumSpringGreen,
-    'yellow': bg.LightYellow,
-    'blue': bg.RoyalBlue1,
-    'magenta': bg.Purple,
-    'cyan': bg.SkyBlue1,
-    'white': bg.White,
-}
-
-
-def get_fg(fg):
-    return str(FG_COLORS[fg])
-
-
-def get_bg(bg):
-    return str(BG_COLORS[bg])
+
+class FgColors(ansi.ColorBase):
+    black = fg.Black
+    red = fg.DarkRedA
+    green = fg.MediumSpringGreen
+    yellow = fg.LightYellow
+    blue = fg.RoyalBlue1
+    magenta = fg.Purple
+    cyan = fg.SkyBlue1
+    white = fg.White
+    purple = fg.Purple
+
+
+class BgColors(ansi.ColorBase):
+    black = bg.BLACK
+    red = bg.DarkRedA
+    green = bg.MediumSpringGreen
+    yellow = bg.LightYellow
+    blue = bg.RoyalBlue1
+    magenta = bg.Purple
+    cyan = bg.SkyBlue1
+    white = bg.White
+    purple = bg.Purple
+
+
+def get_fg(name: str):
+    return str(FgColors[name])
+
+
+def get_bg(name: str):
+    return str(BgColors[name])
 
 
 ansi.fg_lookup = get_fg
@@ -84,8 +85,8 @@ class CmdLineApp(cmd2.Cmd):
     speak_parser.add_argument('-p', '--piglatin', action='store_true', help='atinLay')
     speak_parser.add_argument('-s', '--shout', action='store_true', help='N00B EMULATION MODE')
     speak_parser.add_argument('-r', '--repeat', type=int, help='output [n] times')
-    speak_parser.add_argument('-f', '--fg', choices=FG_COLORS, help='foreground color to apply to output')
-    speak_parser.add_argument('-b', '--bg', choices=BG_COLORS, help='background color to apply to output')
+    speak_parser.add_argument('-f', '--fg', choices=FgColors.colors(), help='foreground color to apply to output')
+    speak_parser.add_argument('-b', '--bg', choices=BgColors.colors(), help='background color to apply to output')
     speak_parser.add_argument('-l', '--bold', action='store_true', help='bold the output')
     speak_parser.add_argument('-u', '--underline', action='store_true', help='underline the output')
     speak_parser.add_argument('words', nargs='+', help='words to say')


Test patch:
diff --git a/tests/test_ansi.py b/tests/test_ansi.py
index 51c3b50f..4a28b1a0 100644
--- a/tests/test_ansi.py
+++ b/tests/test_ansi.py
@@ -32,14 +32,14 @@ def test_style_none():
 def test_style_fg():
     base_str = HELLO_WORLD
     fg_color = 'blue'
-    ansi_str = ansi.fg.get_value(fg_color) + base_str + ansi.FG_RESET
+    ansi_str = ansi.fg[fg_color].value + base_str + ansi.FG_RESET
     assert ansi.style(base_str, fg=fg_color) == ansi_str
 
 
 def test_style_bg():
     base_str = HELLO_WORLD
     bg_color = 'green'
-    ansi_str = ansi.bg.get_value(bg_color) + base_str + ansi.BG_RESET
+    ansi_str = ansi.bg[bg_color].value + base_str + ansi.BG_RESET
     assert ansi.style(base_str, bg=bg_color) == ansi_str
 
 
@@ -65,7 +65,7 @@ def test_style_multi():
     base_str = HELLO_WORLD
     fg_color = 'blue'
     bg_color = 'green'
-    ansi_str = (ansi.fg.get_value(fg_color) + ansi.bg.get_value(bg_color) +
+    ansi_str = (ansi.fg[fg_color].value + ansi.bg[bg_color].value +
                 ansi.INTENSITY_BRIGHT + ansi.INTENSITY_DIM + ansi.UNDERLINE_ENABLE +
                 base_str +
                 ansi.FG_RESET + ansi.BG_RESET +
@@ -85,7 +85,7 @@ def test_style_color_not_exist():
 
 def test_fg_lookup_exist():
     fg_color = 'green'
-    assert ansi.fg_lookup(fg_color) == ansi.fg.get_value(fg_color)
+    assert ansi.fg_lookup(fg_color) == ansi.fg_lookup(ansi.fg.green)
 
 
 def test_fg_lookup_nonexist():
@@ -94,8 +94,8 @@ def test_fg_lookup_nonexist():
 
 
 def test_bg_lookup_exist():
-    bg_color = 'green'
-    assert ansi.bg_lookup(bg_color) == ansi.bg.get_value(bg_color)
+    bg_color = 'red'
+    assert ansi.bg_lookup(bg_color) == ansi.bg_lookup(ansi.bg.red)
 
 
 def test_bg_lookup_nonexist():
@@ -121,20 +121,33 @@ def test_async_alert_str(cols, prompt, line, cursor, msg, expected):
     assert alert_str == expected
 
 
-def test_fg_enum():
-    assert ansi.fg_lookup('bright_red') == ansi.fg_lookup(ansi.fg.bright_red)
+def test_cast_color_as_str():
+    assert str(ansi.fg.blue) == ansi.fg.blue.value
+    assert str(ansi.bg.blue) == ansi.bg.blue.value
+
+
+def test_color_str_building():
+    from cmd2.ansi import fg, bg
+    assert fg.blue + "hello" == fg.blue.value + "hello"
+    assert bg.blue + "hello" == bg.blue.value + "hello"
+    assert fg.blue + "hello" + fg.reset == fg.blue.value + "hello" + fg.reset.value
+    assert bg.blue + "hello" + bg.reset == bg.blue.value + "hello" + bg.reset.value
+    assert fg.blue + bg.white + "hello" + fg.reset + bg.reset == \
+           fg.blue.value + bg.white.value + "hello" + fg.reset.value + bg.reset.value
 
-def test_fg_enum_to_str():
-    assert str(ansi.fg.black) == ansi.fg_lookup('black')
 
-def test_bg_enum():
+def test_color_nonunique_values():
+    class Matching(ansi.ColorBase):
+        magenta = ansi.fg_lookup('magenta')
+        purple = ansi.fg_lookup('magenta')
+    assert sorted(Matching.colors()) == ['magenta', 'purple']
+
+
+def test_color_enum():
+    assert ansi.fg_lookup('bright_red') == ansi.fg_lookup(ansi.fg.bright_red)
     assert ansi.bg_lookup('green') == ansi.bg_lookup(ansi.bg.green)
 
-def test_bg_enum_to_str():
-    assert str(ansi.bg.blue) == ansi.bg_lookup('blue')
 
-def test_fg_colors():
+def test_colors_list():
     assert list(ansi.fg.__members__.keys()) == ansi.fg.colors()
-
-def test_bg_colors():
     assert list(ansi.bg.__members__.keys()) == ansi.bg.colors()