Source code for console.detection

# -*- coding: future_fstrings -*-
'''
    .. console - Comprehensive utility library for ANSI terminals.
    .. © 2018, 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.
'''
import sys, os
import logging

import env

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


log = logging.getLogger(__name__)
os_name = os.name  # frequent use
is_fbterm = termios = tty = None


if os_name == 'posix':  # Tron leotards
    import termios, tty
    is_fbterm = (env.TERM == 'fbterm')


[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. 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): if not termios: raise EnvironmentError('The termios module was not loaded, is ' 'this a POSIX environment?') self.fd = stream.fileno() def __enter__(self): # save self.orig_attrs = termios.tcgetattr(self.fd) return self.fd def __exit__(self, *args): # restore termios.tcsetattr(self.fd, termios.TCSADRAIN, self.orig_attrs)
[docs]def init(stream=sys.stdout, basic_palette=None): ''' Automatically determine whether to enable ANSI sequences, and if so, what level of functionality is available. Takes the following factors into account: - User preference environment variables: - ``CLICOLOR``, ``CLICOLOR_FORCE``, NO_COLOR`` - Whether output stream is a TTY. - 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: 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 = TermLevel.DUMB webcolors = None log.debug('console package, version: %s', __version__) log.debug('os.name/sys.platform: %s/%s', os_name, sys.platform) # find terminal capability level - given preferences and environment if color_is_forced() or (not color_is_disabled() and is_a_tty(stream=stream)): # detecten Sie, bitte if env.PY_CONSOLE_USE_TERMINFO.truthy or env.SSH_CLIENT: level = detect_terminal_level_terminfo() if not level: # didn't occur or fall back if above failed level = detect_terminal_level() if level is TermLevel.ANSI_DIRECT: try: import webcolors except ImportError: pass log.debug(f'webcolors={bool(webcolors)}') # find the platform-dependent 16-color basic palette if level and not basic_palette: pal_name = 'default (xterm)' basic_palette = color_tables.xterm_palette4 if env.SSH_CLIENT: # fall back to xterm over ssh, info often wrong pal_name = 'ssh' # TODO: needs descriptive name basic_palette = color_tables.term_palette_map.get( (env.TERM, env.TERM_PROGRAM), basic_palette ) else: level, pal_name, basic_palette = _find_basic_palette(level) log.debug('Basic palette: %r %r', pal_name, basic_palette) proximity.build_color_tables(basic_palette) # for color downgrade 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: None, Bool: Disabled ''' result = None if 'NO_COLOR' in env: result = True elif env.CLICOLOR == '0': result = True elif env.CLICOLOR: result = False log.debug('%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: Bool: Forced ''' result = env.CLICOLOR_FORCE and (env.CLICOLOR_FORCE != '0') log.debug('%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 ''' level = TermLevel.DUMB TERM = env.TERM or '' # shortcut if TERM.startswith(('xterm', 'linux')): level = TermLevel.ANSI_BASIC # upgrades if TERM.endswith('-256color') or is_fbterm: level = TermLevel.ANSI_EXTENDED # https://bugzilla.redhat.com/show_bug.cgi?id=1173688 - obsolete? if TERM.endswith('-direct') or env.COLORTERM in ('truecolor', '24bit'): level = TermLevel.ANSI_DIRECT log.debug( f'Term support level: {level.name!r} ({os_name}, TERM={TERM}, ' f'COLORTERM={env.COLORTERM or ""}, TERM_PROGRAM={env.TERM_PROGRAM})' ) return level
[docs]def detect_terminal_level_terminfo(): ''' Use curses to query the terminfo database for the terminal support level Returns: level: TermLevel member ''' level = TermLevel.DUMB try: from curses import setupterm, tigetnum, tigetstr setupterm() has_underline = 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)): level = TermLevel.ANSI_MONOCHROME num_colors = tigetnum('colors') 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 return level except ImportError: log.error('''Curses/terminfo not installed, see: - https://pypi.org/project/windows-curses/ - https://www.lfd.uci.edu/~gohlke/pythonlibs/#curses ''')
[docs]def detect_unicode_support(): ''' Try to detect unicode (utf8?) support in the terminal. Checks the ``LANG`` environment variable or Windows code page, falls back to an experimental method. Implementation idea is from the link below: https://unix.stackexchange.com/q/184345/ Returns: Boolean | None if not a TTY ''' result = None if env.LANG and env.LANG.endswith('UTF-8'): # first approximation result = True elif is_a_tty(): # kludge out = sys.stdout x, _ = get_position() out.write('é') out.flush() x2, _ = get_position() difference = x2 - x if difference == 1: result = True else: result = False # clean up out.write(BS) out.flush() log.debug(str(result)) return result
def _find_basic_palette(level): ''' Find the platform-dependent 16-color basic palette—posix version. This is used for "downgrading to the nearest color" support. Arguments: level This is passed on the possibility that it may need to be overridden, due to WSL oddities. ''' pal_name = 'default (xterm)' basic_palette = color_tables.xterm_palette4 if env.TERM.startswith(('linux', 'fbterm')): pal_name = 'vtrgb' basic_palette = parse_vtrgb() elif env.TERM.startswith('xterm'): # TODO: factor, get_theme # LOW64 - Python on Linux on Windows Console! if 'WSLENV' in env: # or ('Microsoft' in os.uname().release): pal_name = 'cmd_1709' basic_palette = color_tables.cmd1709_palette4 level = TermLevel.ANSI_DIRECT # override elif sys.platform.startswith('freebsd'): pal_name = 'vga' # TODO: is valid? vga console basic_palette = color_tables.vga_palette4 # Look harder by querying terminal; get_color may timeout else: 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) return level, 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(result) return result
[docs]def parse_vtrgb(path='/etc/vtrgb'): ''' Parse the color table for the Linux console. ''' palette = () 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 IOError: palette = color_tables.vga_palette4 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 EnvironmentError: # Winders log.debug('get_color not yet implemented by Windows.') else: # parse response colors = resp.partition(':')[2].split('/') if colors == ['']: # nuttin colors = [] # empty on failure 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. ''' 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 EnvironmentError: # 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) else: resp = None # don't bother 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 EnvironmentError: # 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. ''' colors = () if sys.platform == 'darwin': if env.TERM_PROGRAM == 'iTerm.app': # supports, though returns only two chars per colors = _get_color_xterm(name, number, timeout=timeout) elif os_name == 'posix': if sys.platform.startswith('freebsd'): # TODO, may not be console pass elif env.TERM.startswith('xterm'): if env.TERM_PROGRAM == 'vscode': # vscode on Linux hangs pass else: colors = _get_color_xterm(name, number, timeout=timeout) return tuple(colors)
[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) | (,) - empty, if an error occurred. ''' values = fallback 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') # screen.dsr, avoid import sys.stdout.flush() log.debug('about to read get_position response…') resp = _read_until_select(max_bytes=10, end='R') except AttributeError: # no .fileno() return values # 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): ''' Checks terminal for light/dark theme information. First checks for the environment variable COLORFGBG. Next, queries terminal, supported on Windows and xterm, perhaps others. See notes on get_color(). Returns: str, None: 'dark', 'light', None if no information. ''' theme = None COLORFGBG = env.COLORFGBG log.debug('COLORFGBG: %s', COLORFGBG) if COLORFGBG: FG, _, BG = COLORFGBG.partition(';') theme = 'dark' if BG < '8' else 'light' # background wins else: # TODO: factor, same as _find_basic_pal if env.TERM.startswith(('linux', 'fbterm')): # vga console theme = 'dark' elif sys.platform.startswith('freebsd'): # vga console theme = 'dark' elif env.TERM.startswith('xterm'): # LOW64 - Python on Linux on Windows! if 'WSLENV' in env: # or ('Microsoft' in os.uname().release): pass # don't know for sure. else: # 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' log.debug('%r', theme) return theme
# Override default implementations if os_name == 'nt': # I'm a PC from .windows import ( detect_unicode_support, detect_terminal_level, _find_basic_palette, get_color, get_position, get_title, get_theme, ) elif sys.platform == 'darwin': # Think different def _find_basic_palette(name): ''' Find the platform-dependent 16-color basic palette—macOS version. This is used for "downgrading to the nearest color" support. Arguments: name This is passed on the possibility it may need to be overridden, due to WSL oddities. ''' pal_name = 'default (xterm)' basic_palette = color_tables.xterm_palette4 if env.SSH_CLIENT: # fall back to xterm over ssh, info often wrong pal_name = 'ssh (xterm)' else: 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 name, pal_name, basic_palette elif os_name == 'posix': # Tron leotards pass else: # Amiga/Atari - The Wonder Computer of the 1980s :-D log.warn('Unexpected OS: os.name: %s', os_name) if __name__ == '__main__': # logs the detection information sequence print() # space from warnings :-/ try: 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) init()