1
2# -*- coding: utf-8 -*-
3# Copyright 2017 - 2020 Avram Lubkin, All Rights Reserved
4
5# This Source Code Form is subject to the terms of the Mozilla Public
6# License, v. 2.0. If a copy of the MPL was not distributed with this
7# file, You can obtain one at http://mozilla.org/MPL/2.0/.
8
9"""
10**Enlighten utility submodule**
11
12Provides utility functions and objects
13"""
14
15from collections import OrderedDict
16import inspect
17import os
18import re
19import sys
20import warnings
21
22from blessed.colorspace import RGB_256TABLE
23from blessed.sequences import iter_parse
24
25
26try:
27    BASESTRING = basestring
28except NameError:
29    BASESTRING = str
30
31BASE_DIR = os.path.basename(os.path.dirname(__file__))
32FORMAT_MAP_SUPPORT = sys.version_info[:2] >= (3, 2)
33RE_COLOR_RGB = re.compile(r'\x1b\[38;2;(\d+);(\d+);(\d+)m')
34RE_ON_COLOR_RGB = re.compile(r'\x1b\[48;2;(\d+);(\d+);(\d+)m')
35RE_COLOR_256 = re.compile(r'\x1b\[38;5;(\d+)m')
36RE_ON_COLOR_256 = re.compile(r'\x1b\[48;5;(\d+)m')
37RE_SET_A = re.compile(r'\x1b\[(\d+)m')
38RE_LINK = re.compile(r'\x1b]8;.*;(.*)\x1b\\')
39
40CGA_COLORS = ('black', 'red', 'green', 'yellow', 'blue', 'magenta', 'cyan', 'white')
41HTML_ESCAPE = {'&': '&amp;', '<': '&lt;', '>': '&gt;', '?': '&#63;'}
42
43
44class EnlightenWarning(Warning):
45    """
46    Generic warning class for Enlighten
47    """
48
49
50def warn_best_level(message, category):
51    """
52    Helper function to warn at first frame stack outside of library
53    """
54
55    level = 5  # Unused default
56    for level, frame in enumerate(inspect.stack(), 1):  # pragma: no cover
57        if os.path.basename(os.path.dirname(frame[1])) != BASE_DIR:
58            break
59
60    warnings.warn(message, category=category, stacklevel=level)
61
62
63def format_time(seconds):
64    """
65    Args:
66        seconds (float): amount of time
67
68    Format time string for eta and elapsed
69    """
70
71    # Always do minutes and seconds in mm:ss format
72    minutes = seconds // 60
73    hours = minutes // 60
74    rtn = u'%02.0f:%02.0f' % (minutes % 60, seconds % 60)
75
76    #  Add hours if there are any
77    if hours:
78
79        rtn = u'%dh %s' % (int(hours % 24), rtn)
80
81        #  Add days if there are any
82        days = int(hours // 24)
83        if days:
84            rtn = u'%dd %s' % (days, rtn)
85
86    return rtn
87
88
89def raise_from_none(exc):  # pragma: no cover
90    """
91    Convenience function to raise from None in a Python 2/3 compatible manner
92    """
93    raise exc
94
95
96if sys.version_info[0] >= 3:  # pragma: no branch
97    exec('def raise_from_none(exc):\n    raise exc from None')  # pylint: disable=exec-used
98
99
100class Justify(object):
101    """
102    Enumerated type for justification options
103
104    .. py:attribute:: CENTER
105
106        Justify center
107
108    .. py:attribute:: LEFT
109
110        Justify left
111
112    .. py:attribute:: RIGHT
113
114        Justify right
115
116    """
117
118    CENTER = 'center'
119    LEFT = 'ljust'
120    RIGHT = 'rjust'
121
122
123class Lookahead:
124    """
125    Args:
126        iterator(:py:term:`iterator`): Instance of an iterator
127
128    Wrapper for an iterator supporting look ahead
129    """
130
131    def __init__(self, iterator):
132        self.iterator = iterator
133        self.buffer = []
134
135    def __iter__(self):
136        return self
137
138    def __next__(self):
139        if self.buffer:
140            return self.buffer.pop(0)
141        return next(self.iterator)
142
143    # Python 2
144    next = __next__
145
146    def lookahead(self, start, stop=None):
147        """
148        Args:
149            start(int): Positive integer index of first value
150            stop(int): Positive integer index to end before (not returned)
151
152        Retrieve next value(s) in iterator.
153
154        start and stop roughly behave like slice notation, but must be positive
155        """
156
157        last = max(start, (stop or 0) - 1)
158
159        while last >= len(self.buffer):
160            try:
161                self.buffer.append(next(self.iterator))
162            except StopIteration:
163                break
164
165        if stop is None:
166            return self.buffer[start]
167
168        return self.buffer[start:stop]
169
170
171class Span(list):
172    """
173    Container for span classes
174
175    A list is used to preserve order
176    """
177    def __str__(self):
178        return '<span class="%s">' % ' '.join(self)
179
180    def append_unique(self, item):
181        """
182        Append only if value is unique
183        """
184
185        if item not in self:
186            self.append(item)
187
188
189class HTMLConverter(object):
190    """
191    Args:
192        term(:py:class:`blessed.Terminal`): Blessed terminal instance
193
194    Blessed-based ANSI terminal code to HTML converter
195    """
196
197    def __init__(self, term):
198
199        self.term = term
200        self.caps = self.term.caps
201        self.normal = [elem[0] for elem in iter_parse(term, term.normal)]
202        self.normal_rem = len(self.normal) - 1
203        self._styles = OrderedDict()
204        self._additional_styles = set()
205
206    @property
207    def style(self):
208        """
209        Formatted style section for an HTML document
210
211        Styles are cumulative for the life of the instance
212        """
213
214        out = '<style>\n'
215
216        for style, props in self._styles.items():
217            out += '.%s {\n%s}\n' % (style, ''.join('  %s: %s;\n' % item for item in props.items()))
218
219        if self._additional_styles:
220            out += '%s\n' % '\n'.join(self._additional_styles)
221        out += '</style>\n'
222
223        return out
224
225    def to_html(self, text):
226        """
227        Args:
228            text(str): String formatted with ANSI escape codes
229
230        Convert text to HTML
231
232        Formatted text is enclosed in an HTML span and classes are available in HTMLConverter.style
233
234        Supported formatting:
235            - Blink
236            - Bold
237            - Color (8, 16, 256, and RGB)
238            - Italic
239            - Links
240            - Underline
241        """
242
243        out = '<pre>'
244        open_spans = 0
245        to_out = []
246        parsed = Lookahead(iter_parse(self.term, text))
247        normal = self.normal
248
249        # Iterate through parsed text
250        for value, cap in parsed:
251
252            # If there's no capability, it's just regular text
253            if cap is None:
254
255                # Add in any previous spans
256                out += ''.join(str(item) for item in to_out)
257                del to_out[:]  # Python 2 compatible .clear()
258
259                # Append character and continue
260                out += HTML_ESCAPE.get(value, value)
261                continue
262
263            # Parse links
264            if cap is self.caps['link']:
265                url = RE_LINK.match(value).group(1).strip()
266                out += '<a href="%s">' % url if url else '<a>'
267                continue
268
269            last_added = to_out[-1] if to_out else None
270
271            # Look for normal to close span
272            if value == normal[0] and \
273               normal[1:] == [val[0] for val in parsed.lookahead(0, self.normal_rem or None)]:
274
275                # Clear rest of normal
276                for _ in range(self.normal_rem):
277                    next(parsed)
278
279                # Ignore empty spans
280                if isinstance(last_added, Span):
281                    to_out.pop()
282                    open_spans -= 1
283
284                # Only add if there are open spans
285                elif open_spans:
286                    to_out.append('</span>')
287                    open_spans -= 1
288
289                continue  # pragma: no cover  # To be fixed in PEP 626 (3.10)
290
291            # Parse styles
292            key, value = self._parse_style(value, cap)
293
294            # If not parsed, ignore
295            if not key:
296                continue
297
298            # Update style sheet
299            self._styles[key] = value
300
301            # Update span classes
302            if isinstance(last_added, Span):
303                last_added.append_unique(key)
304            else:
305                to_out.append(Span([key]))
306                open_spans += 1
307
308        # Process any remaining caps
309        out += ''.join(str(item) for item in to_out)
310
311        # Close any spans that didn't get closed
312        out += '</span>' * open_spans
313
314        out += '</pre>'
315
316        return out
317
318    set_a_codes = {
319            1: ('enlighten-bold', {'font-weight': 'bold'}),
320            3: ('enlighten-italic', {'font-style': 'italic'}),
321            5: ('enlighten-blink',
322                {'animation': 'enlighten-blink-animation 1s steps(5, start) infinite'}),
323            4: ('enlighten-underline', {'text-decoration': 'underline'}),
324        }
325
326    def _parse_style(self, value, cap):  # pylint: disable=too-many-return-statements
327        r"""
328        Args:
329            value (str): VT100 terminal code
330            cap(term(:py:class:`~blessed.sequences.Termcap`): Blessed terminal capability
331
332        Parse text attributes of the form '\x1b\[\d+m' into CSS styles
333        """
334
335        caps = self.caps
336
337        # Parse RGB color foreground
338        if cap is caps['color_rgb']:
339            rgb = '#%02x%02x%02x' % tuple(int(num) for num in RE_COLOR_RGB.match(value).groups())
340            return 'enlighten-fg-%s' % rgb[1:], {'color': rgb}
341
342        # Parse RGB color background
343        if cap is caps['on_color_rgb']:
344            rgb = '#%02x%02x%02x' % tuple(int(num) for num in RE_ON_COLOR_RGB.match(value).groups())
345            return 'enlighten-bg-%s' % rgb[1:], {'background-color': rgb}
346
347        # Weird and inconsistent bug that seems to affect Python <= 3.5
348        # Matches set_a_attributes3 instead of more specific color 256 patterns
349        if cap is caps['set_a_attributes3']:  # pragma: no cover
350            if caps['color256'].re_compiled.match(value):
351                cap = caps['color256']
352            elif caps['on_color256'].re_compiled.match(value):
353                cap = caps['on_color256']
354
355        # Parse 256 color foreground
356        if cap is caps['color256']:
357            rgb = str(RGB_256TABLE[int(RE_COLOR_256.match(value).group(1))])
358            return 'enlighten-fg-%s' % rgb[1:], {'color': rgb}
359
360        # Parse 256 color background
361        if cap is caps['on_color256']:
362            rgb = str(RGB_256TABLE[int(RE_ON_COLOR_256.match(value).group(1))])
363            return 'enlighten-bg-%s' % rgb[1:], {'background-color': rgb}
364
365        # Parse text attributes
366        if cap is caps['set_a_attributes1']:
367            code = int(RE_SET_A.match(value).group(1))
368        else:
369            return None, None
370
371        # Blink needs additional styling
372        if code == 5:
373            self._additional_styles.add(
374                '@keyframes enlighten-blink-animation {\n  to {\n    visibility: hidden;\n  }\n}'
375            )
376
377        if code in self.set_a_codes:
378            return self.set_a_codes[code]
379
380        if 30 <= code <= 37:
381            idx = code - 30
382            return 'enlighten-fg-%s' % CGA_COLORS[idx], {'color': str(RGB_256TABLE[idx])}
383
384        if 40 <= code <= 47:
385            idx = code - 40
386            return 'enlighten-bg-%s' % CGA_COLORS[idx], {'background-color': str(RGB_256TABLE[idx])}
387
388        if 90 <= code <= 97:
389            idx = code - 90
390            return 'enlighten-fg-bright-%s' % CGA_COLORS[idx], {'color': str(RGB_256TABLE[idx + 8])}
391
392        if 100 <= code <= 107:
393            idx = code - 100
394            return (
395                'enlighten-bg-bright-%s' % CGA_COLORS[idx],
396                {'background-color': str(RGB_256TABLE[idx + 8])}
397            )
398
399        return None, None
400