Repository: synchronizing/toolbox (language: python)

Issue:
Color - ANSI Color Interface
Interface for using [ANSI](https://en.wikipedia.org/wiki/ANSI_escape_code) terminal color and formatting.


Discussion / hints:

Fix patch:
diff --git a/docs/source/conf.py b/docs/source/conf.py
index 1d0aeec..5ff3f4c 100644
--- a/docs/source/conf.py
+++ b/docs/source/conf.py
@@ -37,7 +37,7 @@
 add_module_names = False
 
 # Show only class docs.
-autoclass_content = "class"
+autoclass_content = "both"
 
 # Add any Sphinx extension module names here, as strings. They can be
 # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
@@ -72,41 +72,3 @@
 # relative to this directory. They are copied after the builtin static files,
 # so a file named "default.css" will overwrite the builtin "default.css".
 html_static_path = ["_static"]
-
-
-# https://michaelgoerz.net/notes/extending-sphinx-napoleon-docstring-sections.html
-# -- Extensions to the  Napoleon GoogleDocstring class ---------------------
-
-from sphinx.ext.napoleon.docstring import GoogleDocstring
-
-# first, we define new methods for any new sections and add them to the class
-def parse_keys_section(self, section):
-    return self._format_fields("Keys", self._consume_fields())
-
-
-GoogleDocstring._parse_keys_section = parse_keys_section
-
-
-def parse_attributes_section(self, section):
-    return self._format_fields("Attributes", self._consume_fields())
-
-
-GoogleDocstring._parse_attributes_section = parse_attributes_section
-
-
-def parse_class_attributes_section(self, section):
-    return self._format_fields("Class Attributes", self._consume_fields())
-
-
-GoogleDocstring._parse_class_attributes_section = parse_class_attributes_section
-
-# we now patch the parse method to guarantee that the the above methods are
-# assigned to the _section dict
-def patched_parse(self):
-    self._sections["keys"] = self._parse_keys_section
-    self._sections["class attributes"] = self._parse_class_attributes_section
-    self._unpatched_parse()
-
-
-GoogleDocstring._unpatched_parse = GoogleDocstring._parse
-GoogleDocstring._parse = patched_parse
diff --git a/docs/source/module/string.rst b/docs/source/module/string.rst
new file mode 100644
index 0000000..cc87303
--- /dev/null
+++ b/docs/source/module/string.rst
@@ -0,0 +1,120 @@
+######
+string
+######
+
+*****
+color
+*****
+
+.. code-block:: python
+
+    from toolbox.string import color
+
+Out-of-the-box this module includes the following :class:`toolbox.string.color.Format` definitions:
+
+.. list-table:: 
+    :header-rows: 1
+
+    *   - Foreground Color
+        - Background Color
+        - Styles
+    *   - :py:class:`black`
+        - :py:class:`bblack`
+        - :py:class:`reset`
+    *   - :py:class:`red`
+        - :py:class:`bred`
+        - :py:class:`bold`
+    *   - :py:class:`green`
+        - :py:class:`bgreen`
+        - :py:class:`underline`
+    *   - :py:class:`yellow`
+        - :py:class:`byellow`
+        - :py:class:`blink`
+    *   - :py:class:`blue`
+        - :py:class:`bblue`
+        - :py:class:`reverse`
+    *   - :py:class:`magenta`
+        - :py:class:`bmagenta`
+        - :py:class:`conceal`
+    *   - :py:class:`cyan`
+        - :py:class:`bcyan`
+        - 
+    *   - :py:class:`white`
+        - :py:class:`bwhite`
+        - 
+
+
+These can be utilized like so:
+
+.. code-block:: python
+
+    from toolbox.string.color import bold
+
+    print(bold("Hello world!"))
+
+.. autoclass:: toolbox.string.color.Format
+
+.. autoclass:: toolbox.string.color.Style
+
+.. autofunction:: toolbox.string.color.supports_color
+
+.. autofunction:: toolbox.string.color.strip_ansi
+
+Module Data
+===========
+
+:py:data:`ANSI`
+^^^^^^^^^^^^^^^
+
+Dictionary with 16-bit ANSI codes.
+
+.. code-block:: python
+
+    ANSI = {
+        # Foreground Colors
+        "black": "30",
+        "red": "31",
+        "green": "32",
+        "yellow": "33",
+        "blue": "34",
+        "magenta": "35",
+        "cyan": "36",
+        "white": "37",
+        # Background Colors
+        "bblack": "40",
+        "bred": "41",
+        "bgreen": "42",
+        "byellow": "43",
+        "bblue": "44",
+        "bmagenta": "45",
+        "bcyan": "46",
+        "bwhite": "47",
+        # Styles
+        "reset": "0",
+        "bold": "1",
+        "underline": "4",
+        "blink": "5",
+        "reverse": "7",
+        "conceal": "8",
+    }
+
+:py:data:`EC`
+^^^^^^^^^^^^^
+
+ANSI escape character that toolbox.color uses.
+
+To modify the escape character, one may do the following:
+
+.. code-block:: python
+
+    EC = "\x1B"
+
+.. code-block:: python
+
+    from toolbox.string.color import red
+    import toolbox
+
+    toolbox.string.color.EC = "\t"
+
+    print(red("hello world").encode("utf-8"))
+    # b'\t[31mhello world\t[0m'
diff --git a/toolbox/__init__.py b/toolbox/__init__.py
index f4387d2..127deb6 100644
--- a/toolbox/__init__.py
+++ b/toolbox/__init__.py
@@ -3,4 +3,5 @@
 from .collections import *
 from .config import *
 from .functools import *
+from .string import *
 from .textwrap import *
diff --git a/toolbox/string/__init__.py b/toolbox/string/__init__.py
new file mode 100644
index 0000000..5ee9b8a
--- /dev/null
+++ b/toolbox/string/__init__.py
@@ -0,0 +1,1 @@
+from .color import *
diff --git a/toolbox/string/color.py b/toolbox/string/color.py
new file mode 100644
index 0000000..9699430
--- /dev/null
+++ b/toolbox/string/color.py
@@ -0,0 +1,236 @@
+from typing import Union, Tuple, Optional
+import os
+import sys
+import re
+
+# Dictionary with 16-bit ANSI codes.
+ANSI = {
+    # Foreground Colors
+    "black": "30",
+    "red": "31",
+    "green": "32",
+    "yellow": "33",
+    "blue": "34",
+    "magenta": "35",
+    "cyan": "36",
+    "white": "37",
+    # Background Colors
+    "bblack": "40",
+    "bred": "41",
+    "bgreen": "42",
+    "byellow": "43",
+    "bblue": "44",
+    "bmagenta": "45",
+    "bcyan": "46",
+    "bwhite": "47",
+    # Styles
+    "reset": "0",
+    "bold": "1",
+    "underline": "4",
+    "blink": "5",
+    "reverse": "7",
+    "conceal": "8",
+}
+
+# ANSI escape character that toolbox.string.color uses.
+EC = "\x1B"
+
+
+class Format:
+    """Persistent ANSI format object.
+
+    Example:
+
+    .. code-block:: python
+
+        from toolbox.string.color import Format
+
+        bold = Format(code="1")
+        print(bold("hello world"))
+    """
+
+    def __init__(self, code: int):
+        r"""Creates a new ANSI format that can be called to retrieve a styled string.
+
+        Args:
+            code: ANSI code.
+        """
+        self.code = code
+
+    def __call__(self, string: str, reset: bool = True):
+        """Returns a string with ANSI code.
+
+        Args:
+            string: String to stylize.
+            reset: Optional flag to append reset ANSI code on specific call.
+        """
+
+        fin = "{}[{}m{}".format(EC, self.code, string)
+        if reset:
+            fin += "{}[{}m".format(EC, ANSI["reset"])
+
+        return fin
+
+    def __repr__(self):
+        return "Format(code={})".format(self.code)
+
+
+class Style:
+    """Persistent ANSI style object.
+
+    Example:
+
+    .. code-block:: python
+
+        from toolbox.string.color import Style, red
+
+        # ANSI code 1 is bold.
+        error = Style(red, 1, "underline")
+        print(error("This is an error"))
+    """
+
+    def __init__(self, *args: Tuple[Union[str, int, "Format"]], reset: bool = True):
+        """Initializes a complex ANSI style that can be called to retrieve a styled string.
+
+        Args:
+            *args: Arguments that can be either a string, integer, or Format type to create a
+                new ANSI style.
+            reset: Boolean flag that when set to True will append the ANSI reset code to ensure
+                no style spill over.
+
+        Note:
+            :py:class:`Style` can take arguments of type string, int, and Format. When
+            using string, it will look up the ANSI code in the 16-bit ANSI dictionary.
+            To use a non-standard code use either a custom Format, or an integer representation
+            of the ANSI code.
+        """
+
+        if not all(isinstance(x, (int, str, Format)) for x in args):
+            err = "Arguments must either be of format 'Format', 'int', or 'str'."
+            raise TypeError(err)
+
+        self.args = args
+        self.reset = reset
+
+    def __call__(self, string, reset: Optional[bool] = None):
+        """Returns a string with ANSI codes set.
+
+        Args:
+            string: String to stylize.
+            reset: Optional flag to append reset ANSI code on specific call.
+        """
+
+        codes = self._args_codes()
+        reset = reset if reset else self.reset
+        return Format(code=";".join(codes))(string, reset=reset)
+
+    def _args_codes(self):
+        codes = []
+        for arg in self.args:
+            if isinstance(arg, int):
+                codes.append(str(arg))
+            elif isinstance(arg, str):
+                codes.append(ANSI[arg])
+            elif isinstance(arg, Format):
+                codes.append(str(arg.code))
+
+        return codes
+
+    def __repr__(self):
+        return "Style(args={})".format(";".join(self._args_codes()))
+
+
+black = Format(code=30)
+red = Format(code=31)
+green = Format(code=32)
+yellow = Format(code=33)
+blue = Format(code=34)
+magenta = Format(code=35)
+cyan = Format(code=36)
+white = Format(code=37)
+bblack = Format(code=40)
+bred = Format(code=41)
+bgreen = Format(code=42)
+byellow = Format(code=43)
+bblue = Format(code=44)
+bmagenta = Format(code=45)
+bcyan = Format(code=46)
+bwhite = Format(code=47)
+reset = Format(code=0)
+bold = Format(code=1)
+underline = Format(code=4)
+blink = Format(code=5)
+reverse = Format(code=7)
+conceal = Format(code=8)
+
+
+def supports_color():  # pragma: no cover
+    """Checks if system's terminal has color support.
+
+    Note:
+        This piece of code is from Django's source code
+        `here <https://github.com/django/django/blob/b41d38ae26b1da9519a6cd765bc2f2ce7d355007/django/core/management/color.py#L20-L56>`_.
+
+        Copyright (c) Django Software Foundation and individual contributors.
+    """
+
+    try:
+        import colorama
+    except ImportError:
+        HAS_COLORAMA = False
+    else:
+        colorama.init()
+        HAS_COLORAMA = True
+
+    def vt_codes_enabled_in_windows_registry():
+        """ Check the Windows Registry to see if VT code handling has been enabled by default, see https://superuser.com/a/1300251/447564."""
+
+        try:
+            # winreg is only available on Windows.
+            import winreg
+        except ImportError:
+            return False
+        else:
+            reg_key = winreg.OpenKey(winreg.HKEY_CURRENT_USER, "Console")
+            try:
+                reg_key_value, _ = winreg.QueryValueEx(reg_key, "VirtualTerminalLevel")
+            except FileNotFoundError:
+                return False
+            else:
+                return reg_key_value == 1
+
+    # isatty is not always implemented, #6223.
+    is_a_tty = hasattr(sys.stdout, "isatty") and sys.stdout.isatty()
+
+    return is_a_tty and (
+        sys.platform != "win32"
+        or HAS_COLORAMA
+        or "ANSICON" in os.environ
+        or
+        # Windows Terminal supports VT codes.
+        "WT_SESSION" in os.environ
+        or
+        # Microsoft Visual Studio Code's built-in terminal supports colors.
+        os.environ.get("TERM_PROGRAM") == "vscode"
+        or vt_codes_enabled_in_windows_registry()
+    )
+
+
+def strip_ansi(string: str):
+    """Removes ANSI color/style sequence.
+
+    Args:
+        string: String to remove ANSI style from.
+
+    Example:
+
+        .. code-block:: python
+
+            from toolbox import strip_ansi, red
+
+            print(strip_ansi(red("hello world")))
+    """
+
+    return re.sub(
+        r"\x1b\[([0-9,A-Z]{1,2}(;[0-9]{1,2})?(;[0-9]{3})?)?[m|K]?", "", string
+    )


Test patch:
diff --git a/tests/test_string.py b/tests/test_string.py
new file mode 100644
index 0000000..6c4f71f
--- /dev/null
+++ b/tests/test_string.py
@@ -0,0 +1,43 @@
+from toolbox.string.color import Format, Style, red, supports_color, strip_ansi
+import pytest
+import sys
+
+
+class Test_color:
+    class Test_Format:
+        def test_format_init(self):
+            Format(code=1)
+
+        def test_format_call(self):
+            formt = Format(code=1)
+            assert formt("hello world").encode("utf-8") == b"\x1b[1mhello world\x1b[0m"
+
+        def test_format_repr(self):
+            formt = Format(code=1)
+            assert formt.__repr__() == "Format(code=1)"
+
+    class Test_Style:
+        def test_style_init(self):
+            Style(red)
+            Style(1)
+            Style("underline")
+            Style(Format(code=31), 1, "underline")
+
+        def test_style_init_err(self):
+            with pytest.raises(TypeError):
+                Style(None)
+
+        def test_style_call(self):
+            style = Style(red, 1, "underline")
+            assert style("hello world") == "\x1b[31;1;4mhello world\x1b[0m"
+
+        def test_style_repr(self):
+            style = Style(red, 1, "underline")
+            assert style.__repr__() == "Style(args=31;1;4)"
+
+    class Test_supports_color:
+        """ No tests are done here. This function is part of the Django test suite (hopefully)."""
+
+    class Test_strip_ansi:
+        def test_strip_ansi(self):
+            assert strip_ansi(Format(code=1)("hello world")) == "hello world"