• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

jupyter / qtconsole / 17161120849

22 Aug 2025 04:52PM UTC coverage: 61.587% (-0.2%) from 61.739%
17161120849

push

github

web-flow
Merge pull request #640 from dalthviz/bright_ansi_codes

PR: Support IPython 9 theme/colors handling

2 of 20 new or added lines in 2 files covered. (10.0%)

2 existing lines in 2 files now uncovered.

2934 of 4764 relevant lines covered (61.59%)

1.23 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

86.61
/qtconsole/ansi_code_processor.py
1
""" Utilities for processing ANSI escape codes and special ASCII characters.
2
"""
3
#-----------------------------------------------------------------------------
4
# Imports
5
#-----------------------------------------------------------------------------
6

7
# Standard library imports
8
from collections import namedtuple
2✔
9
import re
2✔
10

11
# System library imports
12
from qtpy import QtGui
2✔
13

14
# Local imports
15
from qtconsole.styles import dark_style
2✔
16

17
#-----------------------------------------------------------------------------
18
# Constants and datatypes
19
#-----------------------------------------------------------------------------
20

21
# An action for erase requests (ED and EL commands).
22
EraseAction = namedtuple('EraseAction', ['action', 'area', 'erase_to'])
2✔
23

24
# An action for cursor move requests (CUU, CUD, CUF, CUB, CNL, CPL, CHA, CUP,
25
# and HVP commands).
26
# FIXME: Not implemented in AnsiCodeProcessor.
27
MoveAction = namedtuple('MoveAction', ['action', 'dir', 'unit', 'count'])
2✔
28

29
# An action for scroll requests (SU and ST) and form feeds.
30
ScrollAction = namedtuple('ScrollAction', ['action', 'dir', 'unit', 'count'])
2✔
31

32
# An action for the carriage return character
33
CarriageReturnAction = namedtuple('CarriageReturnAction', ['action'])
2✔
34

35
# An action for the \n character
36
NewLineAction = namedtuple('NewLineAction', ['action'])
2✔
37

38
# An action for the beep character
39
BeepAction = namedtuple('BeepAction', ['action'])
2✔
40

41
# An action for backspace
42
BackSpaceAction = namedtuple('BackSpaceAction', ['action'])
2✔
43

44
# Regular expressions.
45
CSI_COMMANDS = 'ABCDEFGHJKSTfmnsu'
2✔
46
CSI_SUBPATTERN = '\\[(.*?)([%s])' % CSI_COMMANDS
2✔
47
OSC_SUBPATTERN = '\\](.*?)[\x07\x1b]'
2✔
48
ANSI_PATTERN = ('\x01?\x1b(%s|%s)\x02?' % \
2✔
49
                (CSI_SUBPATTERN, OSC_SUBPATTERN))
50
ANSI_OR_SPECIAL_PATTERN = re.compile('(\a|\b|\r(?!\n)|\r?\n)|(?:%s)' % ANSI_PATTERN)
2✔
51
SPECIAL_PATTERN = re.compile('([\f])')
2✔
52

53
#-----------------------------------------------------------------------------
54
# Classes
55
#-----------------------------------------------------------------------------
56

57
class AnsiCodeProcessor(object):
2✔
58
    """ Translates special ASCII characters and ANSI escape codes into readable
59
        attributes. It also supports a few non-standard, xterm-specific codes.
60
    """
61

62
    # Whether to increase intensity or set boldness for SGR code 1.
63
    # (Different terminals handle this in different ways.)
64
    bold_text_enabled = True
2✔
65

66
    # We provide an empty default color map because subclasses will likely want
67
    # to use a custom color format.
68
    default_color_map = {}
2✔
69

70
    #---------------------------------------------------------------------------
71
    # AnsiCodeProcessor interface
72
    #---------------------------------------------------------------------------
73

74
    def __init__(self):
2✔
75
        self.actions = []
2✔
76
        self.color_map = self.default_color_map.copy()
2✔
77
        self.reset_sgr()
2✔
78

79
    def reset_sgr(self):
2✔
80
        """ Reset graphics attributs to their default values.
81
        """
82
        self.intensity = 0
2✔
83
        self.italic = False
2✔
84
        self.bold = False
2✔
85
        self.underline = False
2✔
86
        self.foreground_color = None
2✔
87
        self.background_color = None
2✔
88

89
    def split_string(self, string):
2✔
90
        """ Yields substrings for which the same escape code applies.
91
        """
92
        self.actions = []
2✔
93
        start = 0
2✔
94

95
        last_char = None
2✔
96
        string = string[:-1] if last_char is not None else string
2✔
97

98
        for match in ANSI_OR_SPECIAL_PATTERN.finditer(string):
2✔
99
            raw = string[start:match.start()]
2✔
100
            substring = SPECIAL_PATTERN.sub(self._replace_special, raw)
2✔
101
            if substring or self.actions:
2✔
102
                yield substring
2✔
103
                self.actions = []
2✔
104
            start = match.end()
2✔
105

106
            groups = [g for g in match.groups() if (g is not None)]
2✔
107
            g0 = groups[0]
2✔
108
            if g0 == '\a':
2✔
109
                self.actions.append(BeepAction('beep'))
2✔
110
                yield None
2✔
111
                self.actions = []
2✔
112
            elif g0 == '\r':
2✔
113
                self.actions.append(CarriageReturnAction('carriage-return'))
2✔
114
                yield None
2✔
115
                self.actions = []
2✔
116
            elif g0 == '\b':
2✔
117
                self.actions.append(BackSpaceAction('backspace'))
2✔
118
                yield None
2✔
119
                self.actions = []
2✔
120
            elif g0 == '\n' or g0 == '\r\n':
2✔
121
                self.actions.append(NewLineAction('newline'))
2✔
122
                yield None
2✔
123
                self.actions = []
2✔
124
            else:
125
                params = [ param for param in groups[1].split(';') if param ]
2✔
126
                if g0.startswith('['):
2✔
127
                    # Case 1: CSI code.
128
                    try:
2✔
129
                        params = list(map(int, params))
2✔
130
                    except ValueError:
×
131
                        # Silently discard badly formed codes.
132
                        pass
×
133
                    else:
134
                        self.set_csi_code(groups[2], params)
2✔
135

136
                elif g0.startswith(']'):
2✔
137
                    # Case 2: OSC code.
138
                    self.set_osc_code(params)
2✔
139

140
        raw = string[start:]
2✔
141
        substring = SPECIAL_PATTERN.sub(self._replace_special, raw)
2✔
142
        if substring or self.actions:
2✔
143
            yield substring
2✔
144

145
        if last_char is not None:
2✔
146
            self.actions.append(NewLineAction('newline'))
×
147
            yield None
×
148

149
    def set_csi_code(self, command, params=[]):
2✔
150
        """ Set attributes based on CSI (Control Sequence Introducer) code.
151

152
        Parameters
153
        ----------
154
        command : str
155
            The code identifier, i.e. the final character in the sequence.
156

157
        params : sequence of integers, optional
158
            The parameter codes for the command.
159
        """
160
        if command == 'm':   # SGR - Select Graphic Rendition
2✔
161
            if params:
2✔
162
                self.set_sgr_code(params)
2✔
163
            else:
164
                self.set_sgr_code([0])
×
165

166
        elif (command == 'J' or # ED - Erase Data
2✔
167
              command == 'K'):  # EL - Erase in Line
168
            code = params[0] if params else 0
2✔
169
            if 0 <= code <= 2:
2✔
170
                area = 'screen' if command == 'J' else 'line'
2✔
171
                if code == 0:
2✔
172
                    erase_to = 'end'
2✔
173
                elif code == 1:
2✔
174
                    erase_to = 'start'
2✔
175
                elif code == 2:
2✔
176
                    erase_to = 'all'
2✔
177
                self.actions.append(EraseAction('erase', area, erase_to))
2✔
178

179
        elif (command == 'S' or # SU - Scroll Up
2✔
180
              command == 'T'):  # SD - Scroll Down
181
            dir = 'up' if command == 'S' else 'down'
2✔
182
            count = params[0] if params else 1
2✔
183
            self.actions.append(ScrollAction('scroll', dir, 'line', count))
2✔
184

185
        elif command == 'A':  # Move N lines Up
2✔
186
            dir = 'up'
2✔
187
            count = params[0] if params else 1
2✔
188
            self.actions.append(MoveAction('move', dir, 'line', count))
2✔
189

190
        elif command == 'B':  # Move N lines Down
2✔
191
            dir = 'down'
×
192
            count = params[0] if params else 1
×
193
            self.actions.append(MoveAction('move', dir, 'line', count))
×
194

195
        elif command == 'F':  # Goes back to the begining of the n-th previous line
2✔
196
            dir = 'leftup'
2✔
197
            count = params[0] if params else 1
2✔
198
            self.actions.append(MoveAction('move', dir, 'line', count))
2✔
199
        
200

201
    def set_osc_code(self, params):
2✔
202
        """ Set attributes based on OSC (Operating System Command) parameters.
203

204
        Parameters
205
        ----------
206
        params : sequence of str
207
            The parameters for the command.
208
        """
209
        try:
2✔
210
            command = int(params.pop(0))
2✔
211
        except (IndexError, ValueError):
×
212
            return
×
213

214
        if command == 4:
2✔
215
            # xterm-specific: set color number to color spec.
216
            try:
2✔
217
                color = int(params.pop(0))
2✔
218
                spec = params.pop(0)
2✔
219
                self.color_map[color] = self._parse_xterm_color_spec(spec)
2✔
220
            except (IndexError, ValueError):
×
221
                pass
×
222

223
    def set_sgr_code(self, params):
2✔
224
        """ Set attributes based on SGR (Select Graphic Rendition) codes.
225

226
        Parameters
227
        ----------
228
        params : sequence of ints
229
            A list of SGR codes for one or more SGR commands. Usually this
230
            sequence will have one element per command, although certain
231
            xterm-specific commands requires multiple elements.
232
        """
233
        # Always consume the first parameter.
234
        if not params:
2✔
235
            return
2✔
236
        code = params.pop(0)
2✔
237

238
        if code == 0:
2✔
239
            self.reset_sgr()
2✔
240
        elif code == 1:
2✔
241
            if self.bold_text_enabled:
2✔
242
                self.bold = True
2✔
243
            else:
244
                self.intensity = 1
×
245
        elif code == 2:
2✔
246
            self.intensity = 0
×
247
        elif code == 3:
2✔
248
            self.italic = True
×
249
        elif code == 4:
2✔
250
            self.underline = True
×
251
        elif code == 22:
2✔
252
            self.intensity = 0
×
253
            self.bold = False
×
254
        elif code == 23:
2✔
255
            self.italic = False
×
256
        elif code == 24:
2✔
257
            self.underline = False
×
258
        elif code >= 30 and code <= 37:
2✔
259
            self.foreground_color = code - 30
2✔
260
        elif code == 38 and params:
2✔
261
            _color_type = params.pop(0)
2✔
262
            if _color_type == 5 and params:
2✔
263
                # xterm-specific: 256 color support.
264
                self.foreground_color = params.pop(0)
2✔
265
            elif _color_type == 2:
2✔
266
                # 24bit true colour support.
267
                self.foreground_color = params[:3]
2✔
268
                params[:3] = []
2✔
269
        elif code == 39:
2✔
270
            self.foreground_color = None
2✔
271
        elif code >= 40 and code <= 47:
2✔
272
            self.background_color = code - 40
2✔
273
        elif code == 48 and params:
2✔
274
            _color_type = params.pop(0)
2✔
275
            if _color_type == 5 and params:
2✔
276
                # xterm-specific: 256 color support.
277
                self.background_color = params.pop(0)
2✔
278
            elif _color_type == 2:
2✔
279
                # 24bit true colour support.
280
                self.background_color = params[:3]
2✔
281
                params[:3] = []
2✔
282
        elif code == 49:
2✔
283
            self.background_color = None
2✔
NEW
284
        elif code >= 90 and code <= 97:
×
285
            # Bright foreground color
NEW
286
            self.foreground_color = code - 90
×
NEW
287
            self.intensity = 1
×
NEW
288
        elif code >=100 and code <= 107:
×
289
            # Bright background color
NEW
290
            self.background_color = code - 100
×
NEW
291
            self.intensity = 1
×
292

293
        # Recurse with unconsumed parameters.
294
        self.set_sgr_code(params)
2✔
295

296
    #---------------------------------------------------------------------------
297
    # Protected interface
298
    #---------------------------------------------------------------------------
299

300
    def _parse_xterm_color_spec(self, spec):
2✔
301
        if spec.startswith('rgb:'):
2✔
302
            return tuple(map(lambda x: int(x, 16), spec[4:].split('/')))
2✔
303
        elif spec.startswith('rgbi:'):
2✔
304
            return tuple(map(lambda x: int(float(x) * 255),
2✔
305
                             spec[5:].split('/')))
306
        elif spec == '?':
×
307
            raise ValueError('Unsupported xterm color spec')
×
308
        return spec
×
309

310
    def _replace_special(self, match):
2✔
311
        special = match.group(1)
2✔
312
        if special == '\f':
2✔
313
            self.actions.append(ScrollAction('scroll', 'down', 'page', 1))
2✔
314
        return ''
2✔
315

316
    def _parse_ansi_color(self, color, intensity):
2✔
317
        """
318
        Map an ANSI color code to color name or a RGB tuple.
319
        Based on: https://gist.github.com/MightyPork/1d9bd3a3fd4eb1a661011560f6921b5b
320
        """
321
        parsed_color = None
2✔
322
        if color < 16:
2✔
323
            # Adjust for intensity, if possible.
324
            if intensity > 0 and color < 8:
2✔
325
                color += 8
×
326
            parsed_color = self.color_map.get(color, None)
2✔
327
        elif (color > 231):
2✔
328
                s = int((color - 232) * 10 + 8)
2✔
329
                parsed_color = (s, s, s)
2✔
330
        else:
331
            n = color - 16
2✔
332
            b = n % 6
2✔
333
            g = (n - b) / 6 % 6
2✔
334
            r = (n - b - g * 6) / 36 % 6
2✔
335
            r = int(r * 40 + 55) if r else 0
2✔
336
            g = int(g * 40 + 55) if g else 0
2✔
337
            b = int(b * 40 + 55) if b else 0
2✔
338
            parsed_color = (r, g, b)
2✔
339
        return parsed_color
2✔
340

341

342
class QtAnsiCodeProcessor(AnsiCodeProcessor):
2✔
343
    """ Translates ANSI escape codes into QTextCharFormats.
344
    """
345

346
    # A map from ANSI color codes to SVG color names or RGB(A) tuples.
347
    darkbg_color_map = {
2✔
348
        0  : 'black',       # black
349
        1  : 'darkred',     # red
350
        2  : 'darkgreen',   # green
351
        3  : 'gold',       # yellow
352
        4  : 'darkblue',    # blue
353
        5  : 'darkviolet',  # magenta
354
        6  : 'steelblue',   # cyan
355
        7  : 'grey',        # white
356
        8  : 'grey',        # black (bright)
357
        9  : 'red',         # red (bright)
358
        10 : 'lime',        # green (bright)
359
        11 : 'yellow',      # yellow (bright)
360
        12 : 'deepskyblue', # blue (bright)
361
        13 : 'magenta',     # magenta (bright)
362
        14 : 'cyan',        # cyan (bright)
363
        15 : 'white' }      # white (bright)
364

365
    # Set the default color map for super class.
366
    default_color_map = darkbg_color_map.copy()
2✔
367

368
    def get_color(self, color, intensity=0):
2✔
369
        """ Returns a QColor for a given color code or rgb list, or None if one
370
            cannot be constructed.
371
        """
372
        if isinstance(color, int):
2✔
373
            constructor = self._parse_ansi_color(color, intensity)
2✔
374
        elif isinstance(color, (tuple, list)):
2✔
375
            constructor = color
×
376
        else:
377
            return None
2✔
378

379
        if isinstance(constructor, str):
2✔
380
            # If this is an X11 color name, we just hope there is a close SVG
381
            # color name. We could use QColor's static method
382
            # 'setAllowX11ColorNames()', but this is global and only available
383
            # on X11. It seems cleaner to aim for uniformity of behavior.
384
            return QtGui.QColor(constructor)
2✔
385

386
        elif isinstance(constructor, (tuple, list)):
2✔
387
            return QtGui.QColor(*constructor)
2✔
388

389
        return None
×
390

391
    def get_format(self):
2✔
392
        """ Returns a QTextCharFormat that encodes the current style attributes.
393
        """
394
        format = QtGui.QTextCharFormat()
2✔
395

396
        # Set foreground color
397
        qcolor = self.get_color(self.foreground_color, self.intensity)
2✔
398
        if qcolor is not None:
2✔
399
            format.setForeground(qcolor)
2✔
400

401
        # Set background color
402
        qcolor = self.get_color(self.background_color, self.intensity)
2✔
403
        if qcolor is not None:
2✔
404
            format.setBackground(qcolor)
2✔
405

406
        # Set font weight/style options
407
        if self.bold:
2✔
408
            format.setFontWeight(QtGui.QFont.Bold)
2✔
409
        else:
410
            format.setFontWeight(QtGui.QFont.Normal)
2✔
411
        format.setFontItalic(self.italic)
2✔
412
        format.setFontUnderline(self.underline)
2✔
413

414
        return format
2✔
415

416
    def set_background_color(self, style):
2✔
417
        """
418
        Given a syntax style, attempt to set a color map that will be
419
        aesthetically pleasing.
420
        """
421
        # Set a new default color map.
422
        self.default_color_map = self.darkbg_color_map.copy()
2✔
423

424
        if not dark_style(style):
2✔
425
            # Colors appropriate for a terminal with a light background. For
426
            # now, only use non-bright colors...
427
            for i in range(8):
2✔
428
                self.default_color_map[i + 8] = self.default_color_map[i]
2✔
429

430
            # ...and replace white with black.
431
            self.default_color_map[7] = self.default_color_map[15] = 'black'
2✔
432

433
        # Update the current color map with the new defaults.
434
        self.color_map.update(self.default_color_map)
2✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc