Source code for console.core

# -*- coding: future_fstrings -*-
'''
    .. console - Comprehensive utility library for ANSI terminals.
    .. © 2018, Mike Miller - Released under the LGPL, version 3+.

    Complicated gobbyldegook supporting the simple color/style interface
    located here.
    Classes below are not meant to be instantiated by client code;
    see style.py.
'''
import sys
import logging
import re

from . import term_level as _term_level
from .constants import (CSI, ANSI_BG_LO_BASE, ANSI_FG_LO_BASE, ANSI_RESET,
                        TermLevel)
from .disabled import empty_bin, empty
from .detection import is_fbterm, color_sep
from .meta import defaults
from .proximity import (color_table4, find_nearest_color_hexstr,
                        find_nearest_color_index)

try:
    import webcolors
except ImportError:
    webcolors = None


log = logging.getLogger(__name__)
MAX_NL_SEARCH = defaults.MAX_NL_SEARCH
_string_plus_call_warning_template = '''

    Ambiguous and/or inefficient addition operation used:
        Form:  pal.style1 + pal.style2(msg)
          or:  adding a style to a previously ANSI escaped string.
        Given: %r + %r

    Suggested alternatives:
        (pal.style1 + pal.style2)(msg)  # new anonymous style
    Or:
        pal.style2(msg, pal.style1)     # via "mixins"
'''

# Palette attribute name finders.  Now we've got two problems.
# Not a huge fan of regex but they nicely enforce the attribute naming rules:
_hd = '[0-9A-Fa-f]'  # hex digits
_index_finder = re.compile(r'^i_?\d{1,3}$', re.A)                   # i_DDD
_nearest_finder = re.compile(f'^n_?{_hd}{{3}}$', re.A)              # n_HHH
_true_finder = re.compile(f'^t_?({_hd}{{3}}|{_hd}{{6}})$', re.A)    # t_HHH+
_x11_finder = re.compile(r'^x_\w{4,64}$', re.A)                     # x_NAME
_web_finder = re.compile(r'^w_\w{4,64}$', re.A)                     # w_NAME


class _BasicPaletteBuilder:
    ''' Code container for ANSI colors and effects.

        A container base-class that creates child attributes on initialization.
        Attributes are integer ANSI codes that are wrapped in a _PaletteEntry
        object to provide functionality.

        Used for the basic 8/16 color and fx palettes.
    '''
    def __new__(cls, color_sep=None, level=Ellipsis):
        ''' Override new() to replace the class entirely on deactivation.

            Arguments:
                level       - Term level to support.
                              - Ellipsis - Detect from environment.
        '''
        self = super().__new__(cls)
        self._level = TermLevel.DUMB

        if level is Ellipsis:                   # autodetecten-Sie
            if _term_level:
                self._level = _term_level
            else:  # None
                self = empty_bin                # deactivate self
        elif isinstance(level, TermLevel):      # continue on fine sir…
            self._level = level
        elif level is None:                     # Ah, Shaddap-a ya face
            self = empty_bin
        else:
            raise TypeError(f'level: {level!r} was unrecognized.')

        return self

    def __init__(self, color_sep=color_sep, **kwargs):
        # look for attributes to wrap as a basic palette:
        attributes = ['default'] + dir(self)  # default needs to go first
        color_available = self._level >= TermLevel.ANSI_BASIC  # look again
        mono_available = color_available or (
            isinstance(self, _MonochromePaletteBuilder) and
            self._level >= TermLevel.ANSI_MONOCHROME
        )
        for name in attributes:
            if not name.startswith('_'):
                value = getattr(self, name, None)  # fx has no default
                if type(value) in (int, str, tuple):  # skip methods, default²
                    if color_available or mono_available:
                        attr = _PaletteEntry(self, name.upper(), value)
                    else:
                        attr = empty
                    setattr(self, name, attr)

        self._color_sep = color_sep  # for ease of testing

    def __repr__(self):
        return f'{self.__class__.__name__}(level={self._level.name})'


class _MonochromePaletteBuilder(_BasicPaletteBuilder):
    ''' A type of PaletteBuilder that let's us classify and enable effects
        objects.
    '''
    pass


class _HighColorPaletteBuilder(_BasicPaletteBuilder):
    ''' Container/Router for ANSI Extended & Truecolor palettes.

        Unlike the Basic palette builder, this one computes attributes on the
        fly.
    '''
    def __init__(self, downgrade_method='euclid', **kwargs):
        super().__init__(**kwargs)
        self._dg_method = downgrade_method

    def __getattr__(self, name):
        ''' Traffic cop - called only when an attribute is missing,
            i.e. once per palette entry attribute.  The "basic" palette
            attributes will never get here, as they are already defined.

            Data flow: look up the name by its prefix (or not), then convert to
            a RGB three-tuple, for optional calculation and output.

            Attribute prefixes:                     Examples:

                - t_ hex-string:                    .t_bb00bb
                - x_ name:                          .x_lime
                - w_ name to tuple of int8:         .w_bisque
                - Bare names                        .cornflowerblue

            Convert to three-tuples:
                (1, 2, 3) or ('1', '2', '3')

            On downgrade, find nearest:

                 - Prefer tuple of ints:            (1, 2, 3)
                 - Also handles 3 digit hex-string: 'b0b'

                - returns: integer index
                    * 0-15 or
                    * 0-255

            Output, one of:

                - String index from integer:        '30'
                - Tuple of dec-int strings:         ('1', '2', '3')
                    - joined with ';' to string:    Prefix + '1;2;3'

            Final Output:
                - wrap in _PaletteEntry(output)
        '''
        key = name[1:].lstrip('_')  # rm prefix from key
        result = None

        # follow the yellow brick road…
        if _index_finder.match(name):       # Indexed aka Extended
            result = self._get_extended_palette_entry(name, key)

        elif _nearest_finder.match(name):   # Nearest index
            result = self._get_extended_palette_entry(name, key, is_hex=True)

        elif _true_finder.match(name):      # Direct color
            result = self._get_direct_palette_entry(name, key)

        elif _x11_finder.match(name):       # X11, forced via prefix
            result = self._get_X11_palette_entry(key)

        elif _web_finder.match(name):       # Webcolors, forced via prefix
            result = self._get_web_palette_entry(key)

        else:  # look for bare names (without prefix)
            if webcolors:
                try:
                    return self._get_web_palette_entry(name)
                except AttributeError:
                    pass  # nope, didn't find…

            try:  # try X11
                result = self._get_X11_palette_entry(name)
                if result:
                    return result
            except AttributeError:
                pass  # nada

            # Emerald city
            cname = self.__class__.__name__
            raise AttributeError(f'{cname} - {name!r} is not a recognized '
                                 'color name or format.')
        return result

    def _get_extended_palette_entry(self, name, index, is_hex=False):
        ''' Compute extended entry. '''
        values = []

        if self._level >= TermLevel.ANSI_EXTENDED:  # build entry
            if is_hex:
                index = str(find_nearest_color_hexstr(index,
                                                      method=self._dg_method))
            start_codes = self._start_codes_extended
            if is_fbterm:
                start_codes = self._start_codes_extended_fbterm
            values.extend(start_codes)  # no colorspace param needed
            values.append(index)

        # downgrade section
        elif self._level is TermLevel.ANSI_BASIC:
            if is_hex:
                nearest_idx = find_nearest_color_hexstr(index, color_table4,
                                                        method=self._dg_method)
            else:
                from .color_tables import index_to_rgb8  # find rgb for idx
                nearest_idx = find_nearest_color_index(*index_to_rgb8[index],
                                                       color_table=color_table4,
                                                       method=self._dg_method)
            values.extend(self._index_to_ansi_values(nearest_idx))

        return (self._create_entry(name, values) if values else empty)

    def _get_direct_palette_entry(self, name, digits):
        ''' Compute direct color entry.

            Values become sequence of decimal int strings: ('1', '2', '3')
        '''
        values = []
        digits_type = type(digits)

        if self._level >= TermLevel.ANSI_DIRECT:  # build entry
            values.extend(self._start_codes_direct)
            if self._color_sep == ':':
                values.append('')  # needs a colorspace param, ok if empty
            if digits_type is str:  # convert from hex string
                if len(digits) == 3:
                    values.extend(str(int(ch + ch, 16)) for ch in digits)
                else:  # chunk 'BB00BB', to ints to 'R', 'G', 'B':
                    values.extend(str(int(digits[i:i+2], 16)) for i in (0, 2 ,4))
            else:  # tuple of str-digit or int from webcolors
                values.extend(str(digit) for digit in digits)

        # downgrade section
        elif self._level is TermLevel.ANSI_EXTENDED:  # build entry
            start_codes = self._start_codes_extended
            if is_fbterm:
                start_codes = self._start_codes_extended_fbterm

            if digits_type is str:
                nearest_idx = find_nearest_color_hexstr(digits,
                                                        method=self._dg_method)
            else:  # tuple
                if type(digits[0]) is str:  # convert to ints
                    digits = tuple(int(digit) for digit in digits)
                nearest_idx = find_nearest_color_index(*digits,
                                                       method=self._dg_method)
            values.extend(start_codes)
            values.append(str(nearest_idx))

        elif self._level is TermLevel.ANSI_BASIC:
            if digits_type is str:
                nearest_idx = find_nearest_color_hexstr(digits, color_table4,
                                                       method=self._dg_method)
            else:  # tuple
                if type(digits[0]) is str:  # convert to ints
                    digits = tuple(int(digit) for digit in digits)
                nearest_idx = find_nearest_color_index(*digits,
                                                       color_table=color_table4,
                                                       method=self._dg_method)
            values.extend(self._index_to_ansi_values(nearest_idx))

        return (self._create_entry(name, values) if values else empty)

    def _get_X11_palette_entry(self, name):
        ''' Look up colors from bundled X11 palette. '''
        from .color_tables_x11 import x11_color_map
        result = empty
        try:            # to decimal int strings, e.g.: ('1', '2', '3')
            color = x11_color_map[name.lower()]
        except KeyError:  # convert to AttributeError
            raise AttributeError(f'{name.lower()!r} not found in X11 palette.')
        result = self._get_direct_palette_entry(name, color)
        return result

    def _get_web_palette_entry(self, name):
        ''' Look up colors from webcolors module. '''
        result = None
        try:  # wc: returns tuple of "decimal" int: (1, 2, 3)
            color = webcolors.name_to_rgb(name)
            result = self._get_direct_palette_entry(name, color)
        except (ValueError, AttributeError):  # convert to AttributeError
            raise AttributeError(
                f'{name!r} not found in webcolors palette.')
        return result

    def _create_entry(self, name, values):
        ''' Render first values as string and place as first code,
            save, and return attr.
        '''
        if is_fbterm:
            str_values = ';'.join(values)  # always semi-colon
            attr = _PaletteEntryFBTerm(self, name.upper(), str_values)
        else:
            str_values = self._color_sep.join(values)
            attr = _PaletteEntry(self, name.upper(), str_values)
        setattr(self, name, attr)  # now cached
        return attr

    def _index_to_ansi_values(self, index):
        ''' Converts a palette index to the corresponding ANSI color.

            Arguments:
                index   - an int (from 0-15)
            Returns:
                index as str in a list for compatibility with values.
        '''
        if self.__class__.__name__[0] == 'F':   # Foreground
            if index < 8:
                index += ANSI_FG_LO_BASE
            else:
                index += 82                     # (ANSI_FG_HI_BASE - 8)
        else:                                   # Background
            if index < 8:
                index += ANSI_BG_LO_BASE
            else:
                index += 92                     # (ANSI_BG_HI_BASE - 8)
        return [str(index)]

    def _clear(self):
        ''' "Cleanse the palette" to free memory.
            Useful for direct color, perhaps.
        '''
        self.__dict__.clear()


class _LineWriter(object):
    ''' Writes each line with escape sequences terminated so paging works
        correctly, a la Pygments.
    '''
    def __init__(self, start, stream, default):
        self.start = start
        self.stream = stream
        self.default = default

    def write(self, data):
        ''' This could be a bit less clumsy. '''
        if data == '\n':  # print does this
            return self.stream.write(data)
        else:
            bytes_written = 0
            for line in data.splitlines(True):  # keep ends: True
                end = ''
                if line.endswith('\n'):  # mv nl to end:
                    line = line[:-1]
                    end = '\n'
                bytes_written += self.stream.write(
                                    f'{self.start}{line}{self.default}{end}'
                                 ) or 0  # in case None returned (on Windows)
            return bytes_written

    def __getattr__(self, attr):
         return getattr(self.stream, attr)


class _PaletteEntry:
    ''' Palette Entry Attribute, a.k.a. a "color"

        Enables:

        - Rendering to an escape sequence string.
        - Addition of attributes, to create a combined, single sequence.
        - Provides a call interface, for use as a text wrapper.
        - Provides a Context Manager for use via the "with" statement.

        Arguments:
            parent  - Parent palette
            name    - Display name, used in demos.
            code    - Associated ANSI code number.
            stream  - Stream to print to, when using a context manager.
    '''
    _end_code = 'm'

    def __init__(self, parent, name, code, stream=sys.stdout):
        self._parent = parent
        self.name = name
        self._stream = stream               # for redirection

        # find initial code and default
        default = None
        if type(code) in (int, str):
            self._codes = [str(code)]
        elif type(code) is tuple:
            self._codes = [str(code[0])]
            default = f'{CSI}{code[1]}m'    # pre-render
        else:
            TypeError('code not valid: %r' % code)

        self.default = default or (parent.default if hasattr(parent, 'default')
                                                  else parent.end)  # style

    def __add__(self, other):
        ''' Add: self + other '''
        if isinstance(other, str):
            if other.startswith(CSI) and other.endswith(self._end_code):
                return self._handle_ambiguous_op(other)
            else:
                return str(self) + other

        elif type(other) is _PaletteEntryFBTerm:  # not! isinstance
            return _CallableFBString(str(self) + str(other))

        elif isinstance(other, _PaletteEntry):
            # Make a copy, so codes don't pile up after each addition
            # Render initial values once as string and place as first code:
            newcodes = self._codes + other._codes
            new_entry = _PaletteEntry(self._parent, self.name,
                                      ';'.join(newcodes))   # _not_ color_sep
                                                            # different type
            if not self.default == other.default:   # not in same class,
                new_entry.default = ANSI_RESET      # switch to full reset

            return new_entry
        else:
            raise TypeError(f'Addition to type {type(other)} not supported.')

    def __radd__(self, other):
        ''' Reverse add: other + self '''
        return other + str(self)

    def __bool__(self):
        return bool(self._codes)

    def __enter__(self):
        ''' Wrap output streams. '''
        log.debug(repr(str(self)))
        # wrap originals
        self._orig_stdout = sys.stdout
        sys.stdout = _LineWriter(self, self._stream, self.default)
        return sys.stdout

    def __exit__(self, type, value, traceback):
        sys.stdout = sys.stdout.stream
        log.debug(repr(str(self.default)))
        self._stream.write(str(self.default))  # just in case

    def __call__(self, text, *styles, save_length=False):
        ''' Formats text.  Not appropriate for *huge* input strings.

            Arguments:
                text                Original text.
                *styles             Add "mix-in" styles, per invocation.
                save_length         bool - Save original string length for
                                    later use.

            Note:
                Color sequences are terminated at newlines,
                so that paging of output works correctly.
        '''
        if (self._parent.__class__.__name__ == 'EffectsTerminator' or
            self.name in ('DEFAULT', 'END')):
            raise NotImplementedError("call form undefined for "
                                      "EffectsTerminator or 'default'.")
        if not text:  # when an empty string/None is passed, don't emit codes.
            return ''

        # if the defaults of mixins are different,
        # uses fx.end instead of palette.default, see addition:
        for attr in styles:
            self += attr

        # add and end styles per line, to facilitate paging:
        pos = text.find('\n', 0, MAX_NL_SEARCH)  # if '\n' in text, w/limit
        if pos == -1:  # not found
            result = f'{self}{text}{self.default}'
        else:
            lines = text.splitlines()
            for i, line in enumerate(lines):
                lines[i] = f'{self}{line}{self.default}'  # add styles, see tip
            result = '\n'.join(lines)

        if save_length:
            return _LengthyString(len(text), result)
        else:
            return result

    def __str__(self):
        return f'{CSI}{";".join(self._codes)}m'  # not color_sep, styles also

    def __repr__(self):
        return repr(self.__str__())

    def _handle_ambiguous_op(self, other):
        ''' This operation is ambiguous and difficult to handle fully, e.g.::

                pal.style1 + pal.style2('hello')

            Attempts to break up the other ansi string by lines,
            insert codes into each opening sequence, conditionally fix end.
        '''
        import warnings

        msg = _string_plus_call_warning_template % (self, other)
        warnings.warn(msg, SyntaxWarning)  # warn first
        log.debug(msg)
        try:
            # do line by line to avoid doubling mem reqs
            lines = other.splitlines()
            for i, line in enumerate(lines):
                tokens = [CSI]
                tokens.append(';'.join(self._codes))            # if multiple
                tokens.append(';')
                tokens.append(CSI.join(line.split(CSI)[1:-1]))  # if multiple

                # figure end code:
                try:
                    end_code = self.default._codes[0]
                except AttributeError:  # already rendered
                    end_code = self.default.strip('\x1b[m')

                if end_code == other[-3:-1]:    # same category
                    tokens.append(str(self.default))
                else:                           # different
                    tokens.append(ANSI_RESET)
                # put humpty (pronounced with an -umpty) back together again:
                lines[i] = ''.join(tokens)
            result = '\n'.join(lines)

        except IndexError as err:
            log.warn('Could not perform enhanced addition with operands'
                     ': %r %r.  Falling back to str concatenation. %s',
                      self, other, err)
            result = str(self) + other

        return result

    def template(self, placeholder='{}'):
        ''' Returns a template string from this Entry with its attributes.

            Placeholder can be '%s', '{}', '${}' or other depending on your
            needs.
        '''
        return f'{self}{placeholder}{self.default}'

    def set_output(self, outfile):
        ''' Set's the output file, currently only useful with context-managers.

            Note:
                This function is experimental and may not survive.
        '''
        if self._orig_stdout:  # restore Usted
            sys.stdout = self._orig_stdout

        self._stream = outfile
        sys.stdout = _LineWriter(self, self._stream, self.default)


class _PaletteEntryFBTerm(_PaletteEntry):
    ''' Help fbterm show 256 colors. '''
    _end_code = '}'

    def __add__(self, other):
        ''' Add: self + other '''
        # these are not able to mix unfortunately, convert to callable string:
        if type(other) is _PaletteEntry:  # not! isinstance
            return _CallableFBString(str(self) + str(other))
        else:
            return super().__add__(other)

    def __str__(self):  # outer sep, not color_sep
        return f'{CSI}{";".join(self._codes)}}}'  # note '}' at end not std 'm'


class _CallableFBString(str):
    ''' String that is callable, only needed in the very specific instance of
        running under fbterm and combining extended color Palettes with other
        Palettes of a different category.  :-/
    '''
    def __call__(self, text, *styles, original_length=False):
        if not text:  # when an empty string/None is passed, don't emit codes.
            return ''

        # add and end styles per line, to facilitate paging:
        pos = text.find('\n', 0, MAX_NL_SEARCH)  # if '\n' in text, w/limit
        if pos == -1:  # not found
            result = f'{self}{text}{ANSI_RESET}'
        else:
            lines = text.splitlines()
            for i, line in enumerate(lines):
                lines[i] = f'{self}{line}{ANSI_RESET}'  # add styles, see tip
            result = '\n'.join(lines)
        return result


class _LengthyString(str):
    ''' String that saves and returns the length of its bare string, before
        escape sequences were added.
    '''
    def __new__(cls, original_length, content):
        self = str.__new__(cls, content)
        self.original_length = original_length
        return self