Source code for console.detection

'''
    .. console - Comprehensive utility library for ANSI terminals.
    .. © 2018-2025, Mike Miller - Released under the LGPL, version 3+.

    This module contains capability detection routines for use under ANSI
    compatible terminals.  Most functions return None when not able to detect
    requested information.

    TODO:
        get_color is run under redirection... it shouldn't.
'''
import sys, os
import logging

import env

from . import color_tables
from console.color_tables import DEFAULT_BASIC_PALETTE, term_palette_map
from .constants import (BS, BEL, CSI, ESC, ENQ, OSC, RS, ST, TermLevel,
                        _COLOR_CODE_MAP, TERMS_DIRECT_COLON)
from .meta import __version__, defaults


color_sep = ';'  # the above prefer to use colons as the direct color separator
termios = tty = None

is_fbterm = (env.TERM == 'fbterm')
is_xterm = env.XTERM_VERSION.bool  # the real thing
log = logging.getLogger(__name__)
os_name = os.name  # frequent use
_sized_char_support = is_xterm or env.TERM.startswith('konsole')


if os_name == 'posix':  # Tron leotards
    import termios, tty


[docs] class TermStack: ''' Context Manager to save, temporarily modify, then restore terminal attributes. POSIX only. Arguments:: stream - The file object to operate on, defaulting to stdin. exit_mode - Mode to exit with: now, drain, or flush default. Raises: AttributeError: when stream has no attribute 'fileno' Example: A POSIX implementation of get char/key:: import tty with TermStack() as fd: tty.setraw(fd) print(sys.stdin.read(1)) ''' def __init__(self, stream=sys.stdin, exit_mode='flush'): if not termios: raise OSError('The termios module was not loaded, is ' 'this a POSIX-compatible environment?') self.fd = stream.fileno() self._exit_mode = exit_mode.upper() def __enter__(self): # save self._orig_attrs = termios.tcgetattr(self.fd) return self.fd def __exit__(self, *args): # restore mode = getattr(termios, f'TCSA{self._exit_mode}') termios.tcsetattr(self.fd, mode, self._orig_attrs)
[docs] def init(using_terminfo=False, _stream=sys.stdout, _basic_palette=()): ''' Automatically determine whether to enable ANSI sequences, and if so, what level of functionality is available. Takes a number of factors into account, e.g.: - Whether output stream is a TTY. - User preference environment variables: - ``CLICOLOR``, ``CLICOLOR_FORCE``, NO_COLOR`` - Detection results: - The terminfo database, if requested or run remotely via SSH. - Or a further inspection of the environment: - ``TERM``, ``ANSICON``, ``COLORTERM`` configuration variables - Are standard output streams wrapped by colorama on Windows? Arguments: using_terminfo: 2B || !2B # that is the question… _stream: Which output file to check: stdout, stderr _basic_palette: Force the platform-dependent 16 color palette, for testing. Tuple of 16 rgb-int tuples. Returns: level: None or TermLevel member Note: This is the main function of the module—meant to be used unless requirements are more specific. ''' level = pal_name = webcolors = None log.debug('console package, version: %s', __version__) log.debug('os.name/sys.platform: %s/%s', os_name, sys.platform) log.debug('using_terminfo: %s', using_terminfo) # find terminal capability level - given preferences and environment if color_is_forced() or (not color_is_disabled() and is_a_tty(stream=_stream)): global color_sep # makes available if using_terminfo: if (not env.PY_CONSOLE_USE_TERMINFO.truthy # set via ssh, not manually and env.LC_TERMINAL == 'iTerm2'): # a recent iterm log.debug('ssh under iTerm2, skipping terminfo detection.') level, color_sep = TermLevel.ANSI_DIRECT, ':' # upgrayyed else: level, color_sep = detect_terminal_level_terminfo() if level >= TermLevel.ANSI_BASIC: pal_name, _basic_palette = _find_basic_palette_from_term(env.TERM) if level is None: # didn't occur, fall back to platform inspection level, color_sep = detect_terminal_level() if level >= TermLevel.ANSI_DIRECT: # check for webcolors try: import webcolors except ImportError: pass log.debug(f'webcolors: {bool(webcolors)}') # find the platform-dependent 16-color basic palette if level and not using_terminfo: pal_name, _basic_palette = _find_basic_palette_from_os() log.debug('Basic palette: %r %r', pal_name, _basic_palette) if _basic_palette: from .proximity import build_color_tables build_color_tables(_basic_palette) # for color downgrade level = level or TermLevel.DUMB log.debug('%s is available', level.name) return level
[docs] def color_is_disabled(**envars): ''' Look for clues in environment, e.g.: - https://bixense.com/clicolors/ - http://no-color.org/ Arguments: envars: Additional environment variables to check for equality, i.e. ``MYAPP_COLOR_DISABLED='1'`` Returns: disabled: None or bool ''' result = None if 'NO_COLOR' in env: result = True elif env.CLICOLOR == '0': result = True elif env.CLICOLOR: result = False log.debug('color_disabled: %r (NO_COLOR=%s, CLICOLOR=%s)', result, env.NO_COLOR or None, env.CLICOLOR or None, ) # check custom variables for name, value in envars.items(): envar = getattr(env, name) if envar.value == value: result = True log.debug('%s == %r: %r', name, value, result) return result
[docs] def color_is_forced(**envars): ''' Look for clues in environment, e.g.: - https://bixense.com/clicolors/ Arguments: envars: Additional environment variables as keyword arguments to check for equality, i.e. ``MYAPP_COLOR_FORCED='1'`` Returns: forced: bool ''' result = env.CLICOLOR_FORCE and (env.CLICOLOR_FORCE != '0') log.debug('color_forced: %s (CLICOLOR_FORCE=%s)', result or None, env.CLICOLOR_FORCE or None) # check custom variables for name, value in envars.items(): envar = getattr(env, name) if envar.value == value: result = True log.debug('%s == %r: %r', name, value, result) return result
[docs] def detect_terminal_level(): ''' Returns whether we think the terminal is dumb or supports basic, extended, or direct color sequences. posix version. This implementation looks at common environment variables, rather than terminfo. Returns: level: None or TermLevel member color_sep: The extended color sequence separator character, i.e. ":" or ";". ''' level = TermLevel.DUMB TERM = env.TERM.value or '' # shortcut WSL = bool(env.WSLENV) # Linux Subsystem for Winders _color_sep = ';' # ansi sequences delimiter if TERM.startswith(('xterm', 'linux')): level = TermLevel.ANSI_BASIC elif TERM.startswith('vt'): # openbsd, hardware level = TermLevel.ANSI_MONOCHROME # 525 had color # upgrades if TERM.endswith('-256color') or is_fbterm: level = TermLevel.ANSI_EXTENDED # https://bugzilla.redhat.com/show_bug.cgi?id=1173688 - obsolete? if ( env.COLORTERM in ('truecolor', '24bit') or WSL or TERM.endswith('-direct') ): level = TermLevel.ANSI_DIRECT if TERM.endswith('-direct'): # need to check again for prefix in TERMS_DIRECT_COLON: if TERM.startswith(prefix): _color_sep = ':'; break _color_sep = env.PY_CONSOLE_COLOR_SEP or _color_sep # local override log.debug( f'Terminal level: {level.name!r} ({os_name}{"-wsl" if WSL else ""}, ' f'TERM={TERM!r}, COLORTERM={env.COLORTERM.value!r}, ' f'TERM_PROGRAM={env.TERM_PROGRAM.value!r}, ' f'color_sep={_color_sep!r}, source=console) ' ) return level, _color_sep
[docs] def detect_terminal_level_terminfo(): ''' Use curses to query the terminfo database for the terminal support level Returns: level: TermLevel member color_sep The extended color sequence separator character, i.e. ":" or ";". ''' level = TermLevel.DUMB _color_sep = None try: from . import _curses has_underline = _curses.tigetstr('smul') if has_underline: # This first test could be more granular, # but it is so rare today we won't bother: if has_underline.startswith(bytes(CSI, 'ascii')): level = TermLevel.ANSI_MONOCHROME num_colors = _curses.tigetnum('colors') log.debug('tigetnum("colors") = %s', num_colors) # -1 means not set, leaving level unchanged from above. if -1 < num_colors < 50: level = TermLevel.ANSI_BASIC elif 49 < num_colors < 16777216: # 52, 88, 256 level = TermLevel.ANSI_EXTENDED elif 16777216 <= num_colors: level = TermLevel.ANSI_DIRECT if level >= TermLevel.ANSI_BASIC: _color_sep = ';' # finding color_sep is a bit problematic if level >= TermLevel.ANSI_EXTENDED: setaf = (_curses.tigetstr('setaf') or b'').decode('ascii') # log.debug('tigetstr setaf: %r', setaf) suffix = setaf.partition('38')[2] if suffix: _color_sep = suffix[0] # first char after 38 _color_sep = env.PY_CONSOLE_COLOR_SEP or _color_sep # local override log.debug( f'Terminal level: {level.name!r} ({os_name}, ' f'TERM={env.TERM.value!r}, color_sep={_color_sep!r}, source=terminfo) ' ) return level, _color_sep except ModuleNotFoundError: # Fall back early when remoting to Windows w/o curses/jinxed # TERM variable only clue: log.warn('terminfo not available.') return detect_terminal_level()
[docs] def detect_unicode_support(): ''' Try to detect unicode (utf8?) support in the terminal. Checks the ``LANG`` environment variable, falls back to an experimental method utilizing cursor position. Implementation idea is from the link below: https://unix.stackexchange.com/q/184345/ Returns: support: Boolean | None if not a TTY ''' result = None LANG = env.LANG.value if LANG and LANG.upper().endswith('UTF-8'): # first approximation result = True elif is_a_tty(): # kludge stdout = sys.stdout x, _ = get_position() stdout.write('é') stdout.flush() x2, _ = get_position() difference = x2 - x if difference == 1: result = True else: result = False # clean up stdout.write(BS) stdout.flush() log.debug(str(result)) return result
def _find_basic_palette_from_os(): ''' Find the platform-dependent 16-color basic palette—posix version. This is used for "downgrading to the nearest color" support. ''' pal_name = 'default (xterm)' basic_palette = DEFAULT_BASIC_PALETTE if env.WSLENV: # must go first since Windows uses TERM=xterm… pal_name = 'cmd_1709' basic_palette = color_tables.cmd1709_palette4 elif env.TERM.startswith('xterm'): if sys.platform.startswith('freebsd'): # can't differentiate console pal_name = 'vga' basic_palette = color_tables.vga_palette4 else: # Look harder by querying terminal; get_color may timeout try: # TODO: this comparison could be much better: colors = get_color('index', 2) if colors[0][:2] == '85': pal_name = 'solarized' basic_palette = color_tables.solarized_dark_palette4 elif colors[0][:2] == '4e': pal_name = 'tango' basic_palette = color_tables.tango_palette4 else: raise RuntimeError('not a known color scheme.') except (IndexError, RuntimeError, termios.error) as err: log.debug('get_color return value failed: %s', err) elif env.TERM.startswith(('linux', 'fbterm')): pal_name = 'vtrgb' basic_palette = parse_vtrgb() or basic_palette return pal_name, basic_palette def _find_basic_palette_from_term(term): ''' Find the platform-dependent 16-color basic palette—\ remotely via TERM variable. This is used for "downgrading to the nearest color" support. ''' from fnmatch import fnmatchcase # case sensitive pal_name = 'xterm' basic_palette = DEFAULT_BASIC_PALETTE for term_spec in term_palette_map: if fnmatchcase(term, term_spec): # matches basic_palette = term_palette_map[term_spec] pal_name = term_spec.rstrip('*') break return pal_name, basic_palette
[docs] def is_a_tty(stream=sys.stdout): ''' Detect terminal or something else, such as output redirection. Returns: Boolean, None: is tty or None if not found. ''' result = stream.isatty() if hasattr(stream, 'isatty') else None log.debug('tty: %s', result) return result
[docs] def parse_vtrgb(path='/etc/vtrgb'): ''' Parse the color table for the Linux console. Returns: palette or None if not found. ''' palette = None table = [] try: with open(path) as infile: for i, line in enumerate(infile): row = tuple(int(val) for val in line.split(',')) table.append(row) if i == 2: # failsafe break palette = tuple(zip(*table)) # swap rows to columns except OSError: pass return palette
# -- tty, termios ------------------------------------------------------------ def _get_char(): ''' POSIX implementation of get char/key. ''' with TermStack() as fd: tty.setraw(fd) return sys.stdin.read(1) def _read_until_select(infile=sys.stdin, max_bytes=20, end=RS, timeout=None): ''' Read a terminal response of up to a given max characters from stdin, with timeout. POSIX only, files not compat with select on Windows. Arguments: infile: file, stdin max_bytes: int, read no longer than this. end: str, end of data marker, one or two chars. timeout: float secs, how long to wait until giving up. ''' from select import select chars = [] read = infile.read # shortcut last_char = '' if not isinstance(end, tuple): end = (end,) #~ log.debug('read: max_bytes=%s, end=%r, timeout %s …', max_bytes, end, timeout) if select((infile,), (), (), timeout)[0]: # wait until response or timeout #~ log.debug('select output, start reading:') while max_bytes: # response: count down chars, stopping at 0 char = read(1) # print(max_bytes, repr(char)) if char in end: # single break if (last_char + char) in end: # double char, i.e. ST del chars[-1] # rm end[0] break chars.append(char) last_char = char max_bytes -= 1 else: # timeout log.debug('response not received in time, %s secs.', timeout) return ''.join(chars) def _get_color_xterm(name, number=None, timeout=None): ''' Query xterm for color settings. Warning: likely to block on incompatible terminals, use timeout. ''' colors = () if name == 'index' and isinstance(number, int): color_code = '4;' + str(number) else: color_code = _COLOR_CODE_MAP.get(name) if color_code: query_sequence = f'{OSC}{color_code};?{ST}' #~ log.debug('query seq: %r', query_sequence) try: with TermStack() as fd: termios.tcflush(fd, termios.TCIFLUSH) # clear input tty.setcbreak(fd, termios.TCSANOW) # shut off echo sys.stdout.write(query_sequence) sys.stdout.flush() resp = _read_until_select( # max bytes 26 + 2 for 256 digits max_bytes=28, end=(BEL, ST), timeout=timeout ) #~ log.debug('response: %r', resp) except AttributeError: log.debug('warning - no .fileno() attribute was found on the stream.') except OSError: # Winders log.debug('see console.windows.get_color()') except termios.error as err: # some "xterm" compats can't handle, haiku log.debug('get_position return value failed: %s', err) else: # parse response colors = resp.partition(':')[2].split('/') if colors == ['']: # nuttin colors = [] # empty on failure colors = tuple(colors) return colors def _read_clipboard( source='c', encoding=None, max_bytes=defaults.MAX_CLIPBOARD_SIZE, timeout=.2 ): ''' Query xterm for clipboard data. Warning: likely to block on incompatible terminals, use timeout. ''' resp = None query_sequence = f'{OSC}52;{source};?{ST}' try: with TermStack() as fd: termios.tcflush(fd, termios.TCIFLUSH) # clear input tty.setcbreak(fd, termios.TCSANOW) # shut off echo sys.stdout.write(query_sequence) sys.stdout.flush() log.debug('about to read get_color_xterm response…') resp = _read_until_select( # not working on iterm, check for BEL max_bytes=max_bytes, end=ST, timeout=timeout ) except AttributeError: log.debug('warning - no .fileno() attribute was found on the stream.') except OSError: # Winders log.debug('_read_clipboard not yet implemented by Windows.') else: if resp: # parse response from base64 import b64decode resp = b64decode(resp.split(';', 3)[-1]) if encoding: resp = resp.decode(encoding) return resp
[docs] def get_answerback(max_bytes=32, end=(BEL, ST, '\n'), timeout=defaults.READ_TIMEOUT): ''' Returns the "answerback" string which is often empty, None if not available. Warning: Hangs unless max_bytes is a subset of the answer string *or* an explicit end character(s) given, due to inability to find end. https://unix.stackexchange.com/a/312991/159110 ''' try: with TermStack() as fd: termios.tcflush(fd, termios.TCIFLUSH) # clear input tty.setcbreak(fd, termios.TCSANOW) # shut off echo sys.stdout.write(ENQ) sys.stdout.flush() log.debug('about to read answerback response…') return _read_until_select( max_bytes=max_bytes, end=end, timeout=timeout ) except AttributeError: log.debug('warning - no .fileno() attribute was found on the stream.') except OSError: # Winders log.debug('answerback not yet implemented by Windows.')
[docs] def get_color(name, number=None, timeout=defaults.READ_TIMEOUT): ''' Query the default terminal, for colors, etc. Direct queries supported on xterm, iTerm, perhaps others. Arguments: name: str, one of ('foreground', 'fg', 'background', 'bg', or 'index') # index grabs a palette index number: int, if name is index, should be an ANSI color index from 0…255," see links below. Queries terminal using ``OSC # ? BEL`` sequence, call responds with a color in this X Window format syntax: - ``rgb:DEAD/BEEF/CAFE`` - `Control sequences <http://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h2-Operating-System-Commands>`_ - `X11 colors <https://www.x.org/releases/X11R7.7/doc/libX11/libX11/libX11.html#RGB_Device_String_Specification>`_ Returns: tuple[str]:  A tuple of four-digit hex strings after parsing, the last two digits are the least significant and can be chopped when needed: ``('DEAD', 'BEEF', 'CAFE')`` If an error occurs during retrieval or parsing, the tuple will be empty. Examples: >>> get_color('bg') ... ('0000', '0000', '0000') >>> get_color('index', 2) # second color in indexed ... ('4e4d', '9a9a', '0605') # palette, 2 aka 32 in basic Notes: Query blocks until timeout if terminal does not support the function. Many don't. Timeout can be disabled with None or set to a higher number for a slow terminal. On Windows, only able to find palette defaults, which may be different if they were customized. ''' color = () if sys.platform == 'darwin': # check first if env.TERM_PROGRAM == 'iTerm.app': # supports, though returns only two chars per color = _get_color_xterm(name, number, timeout=timeout) elif os_name == 'posix': if env.WSLENV or env.TERM_PROGRAM == 'vscode': pass # LSW, vscode on Linux don't support xterm query elif env.TERM =='xterm' and sys.platform.startswith( ('freebsd', 'haiku') ): pass # defective xterms, TODO: probably should opt-in instead elif env.TERM.startswith('xterm'): color = _get_color_xterm(name, number, timeout=timeout) # Windows impl. uses its API, Terminal has begun support of xterm query log.debug('%s %s color: %r', name, number, color) return color
[docs] def get_position(fallback=defaults.CURSOR_POS_FALLBACK): ''' Return the current column number of the terminal cursor. Used to figure out if we need to print an extra newline. Returns: tuple(int): (x, y) | (0, 0) - fallback, if an error occurred. ''' values, resp = None, '' try: with TermStack() as fd: termios.tcflush(fd, termios.TCIFLUSH) # clear input tty.setcbreak(fd, termios.TCSANOW) # shut off echo sys.stdout.write(CSI + '6n') sys.stdout.flush() log.debug('about to read get_position response…') resp = _read_until_select(max_bytes=10, end='R') except (AttributeError, OSError): # no .fileno(), or ssh into Windows return fallback except termios.error as err: # some "xterm" compats can't handle, haiku log.debug('get_position return value failed: %s', err) return fallback # parse response resp = resp.lstrip(CSI) try: # reverse values = tuple( int(token) for token in resp.partition(';')[::-2] ) except (ValueError, IndexError) as err: log.error('parse error: %s on %r', err, resp) return values
[docs] def get_size(fallback=defaults.TERM_SIZE_FALLBACK): ''' Convenience copy of `shutil.get_terminal_size <https://docs.python.org/3/library/shutil.html#shutil.get_terminal_size>`_ for use here. :: >>> get_terminal_size(fallback=(80, 24)) os.terminal_size(columns=120, lines=24) ''' from shutil import get_terminal_size return get_terminal_size(fallback=fallback)
_query_mode_map = dict(icon=20, title=21)
[docs] def get_title(mode='title'): ''' Return the terminal/console title. Arguments: str: mode, one of ('title', 'icon') or int (20-21): see links below. - `Control sequences <http://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h2-Operating-System-Commands>`_ Returns: title string, or None if not able to be found. Note: Few terms besides xterm support this for security reasons. iTerm returns "". MATE Terminal returns "Terminal". ''' title = None if sys.platform == 'darwin': if env.TERM_PROGRAM != 'iTerm.app': return title # xterm only support mode = _query_mode_map.get(mode, mode) query_sequence = f'{CSI}{mode}t' try: with TermStack() as fd: termios.tcflush(fd, termios.TCIFLUSH) # clear input tty.setcbreak(fd, termios.TCSANOW) # shut off echo sys.stdout.write(query_sequence) sys.stdout.flush() log.debug('about to read get_title response…') resp = _read_until_select(max_bytes=100, end=ST, timeout=.2) except AttributeError: # no .fileno() return title # parse response title = resp.lstrip(OSC)[1:].rstrip(ESC) log.debug('%r', title) return title
[docs] def get_theme(timeout=defaults.READ_TIMEOUT, default=None): ''' Checks terminal for light/dark theme information. First checks for the environment variable COLORFGBG. Next, queries terminal, supported on Windows (not WSL) and xterm, perhaps others. See notes on get_color(). Arguments: timeout: int, time before giving up default: string, fallback value Returns: str, None: 'dark', 'light', or None if no information. ''' theme = default COLORFGBG = env.COLORFGBG.value log.debug('COLORFGBG: %s', COLORFGBG) if COLORFGBG: FG, _, BG = COLORFGBG.partition(';') # TODO: rxvt default;default theme = 'dark' if BG < '8' else 'light' # background wins else: TERM = env.TERM.value if TERM =='xterm' and sys.platform.startswith('freebsd'): # console theme = 'dark' elif TERM.startswith('xterm'): # try xterm query - find average across rgb colors = get_color('background', timeout=timeout) # bg wins if colors: colors = tuple(int(hexclr[:2], 16) for hexclr in colors) avg = sum(colors) / len(colors) theme = 'dark' if avg < 128 else 'light' elif TERM.startswith(('linux', 'fbterm')): # vga console theme = 'dark' elif TERM.startswith('vt'): # openbsd, hardware theme = 'dark' log.debug('%r', theme) return theme
# Override default implementations if os_name == 'nt' and not env.SSH_CLIENT: # I'm a PC # quiet pyflakes, before redefinition: assert detect_unicode_support assert detect_terminal_level assert _find_basic_palette_from_os assert get_color assert get_position assert get_title assert get_theme from .windows import ( detect_unicode_support, detect_terminal_level, _find_basic_palette_from_os, get_color, get_position, get_title, get_theme, ) elif sys.platform == 'darwin': # Think diff'rnt def _find_basic_palette_from_os(): ''' Find the platform-dependent 16-color basic palette—macOS version. This is used for "downgrading to the nearest color" support. ''' pal_name = 'default (xterm)' basic_palette = DEFAULT_BASIC_PALETTE if env.TERM_PROGRAM == 'Apple_Terminal': pal_name = 'termapp' basic_palette = color_tables.termapp_palette4 elif env.TERM_PROGRAM == 'iTerm.app': pal_name = 'iterm' basic_palette = color_tables.iterm_palette4 return pal_name, basic_palette elif os_name == 'posix': # Tron leotards pass else: # Commodore/Amiga/Atari - The Wonder Computer of the 1980s :-D log.warning('Unexpected OS: os.name: %s', os_name) if __name__ == '__main__': # logs the detection information sequence print() # space from warnings :-/ try: #~ raise ImportError() # testing import out out.configure(level='debug') except ImportError: fmt = ' %(levelname)-7.7s %(module)s/%(funcName)s:%(lineno)s %(message)s' logging.basicConfig(level=logging.DEBUG, format=fmt) from . import using_terminfo as _using_terminfo init(using_terminfo=_using_terminfo) # run again so detection gets logged