From 0cac74d39ab31a0685d183c0656eac73d936f7ec Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Mon, 27 Nov 2017 14:50:06 +0530 Subject: [PATCH] Get rid of the horrible argparse It's slow, bloated and has no support for decent output formatting --- kitty/boss.py | 5 +- kitty/cli.py | 485 ++++++++++++++++++++++++++++++++++++-------------- kitty/main.py | 5 +- 3 files changed, 361 insertions(+), 134 deletions(-) diff --git a/kitty/boss.py b/kitty/boss.py index 75e4413fc..130807e17 100644 --- a/kitty/boss.py +++ b/kitty/boss.py @@ -5,7 +5,7 @@ from gettext import gettext as _ from weakref import WeakValueDictionary -from .cli import create_opts, option_parser +from .cli import create_opts, parse_args from .config import MINIMUM_FONT_SIZE, cached_values, initial_window_size from .constants import set_boss, wakeup from .fast_data_types import ( @@ -102,7 +102,8 @@ class Boss: msg = json.loads(msg.decode('utf-8')) if isinstance(msg, dict) and msg.get('cmd') == 'new_instance': startup_id = msg.get('startup_id') - args = option_parser().parse_args(msg['args'][1:]) + args, rest = parse_args(msg['args'][1:]) + args.args = rest opts = create_opts(args) session = create_session(opts, args) os_window_id = self.add_os_window(session, wclass=args.cls, wname=args.name, size=initial_window_size(opts), visible=False) diff --git a/kitty/cli.py b/kitty/cli.py index fed069e9d..670fe6c50 100644 --- a/kitty/cli.py +++ b/kitty/cli.py @@ -2,142 +2,367 @@ # vim:fileencoding=utf-8 # License: GPL v3 Copyright: 2017, Kovid Goyal -import argparse -from gettext import gettext as _ +import re +import sys +from collections import deque from .config import load_config -from .constants import appname, str_version, is_macos, defconf +from .constants import appname, defconf, is_macos, str_version from .layout import all_layouts -def option_parser(): - parser = argparse.ArgumentParser( - prog=appname, - description=_('The {} terminal emulator').format(appname) - ) - a = parser.add_argument - a( - '--class', - default=appname, - dest='cls', - help=_('Set the class part of the WM_CLASS property') - ) - a( - '--name', - default=None, - dest='name', - help=_('Set the name part of the WM_CLASS property (defaults to using the value from {})').format('--class') - ) - a( - '--config', - action='append', - help=_( - 'Specify a path to the config file(s) to use.' - ' Can be specified multiple times to read multiple' - ' config files in sequence, which are merged. Default: {}' - ).format(defconf) - ) - a( - '--override', - '-o', - action='append', - help=_( - 'Override individual configuration options, can be specified' - ' multiple times. Syntax: name=value. For example: {}' - ).format('-o font_size=20') - ) - a( - '--cmd', - '-c', - default=None, - help=_('Run python code in the kitty context') - ) - a( - '-d', - '--directory', - default='.', - help=_('Change to the specified directory when launching') - ) - a( - '--version', - '-v', - action='version', - version='{} {} by Kovid Goyal'.format(appname, str_version) - ) - a( - '--dump-commands', - action='store_true', - default=False, - help=_('Output commands received from child process to stdout') - ) - if not is_macos: - a( - '--detach', - action='store_true', - default=False, - help=_('Detach from the controlling terminal, if any') +is_macos +OPTIONS = ''' +--class +dest=cls +default={appname} +condition=not is_macos +Set the class part of the |_ WM_CLASS| window property + + +--name +condition=not is_macos +Set the name part of the |_ WM_CLASS| property (defaults to using the value from |_ --class|) + + +--config +type=list +default={config_path} +Specify a path to the configuration file(s) to use. +Can be specified multiple times to read multiple configuration files in sequence, which are merged. +Default: |_ %default| + + +--override -o +type=list +Override individual configuration options, can be specified multiple times. +Syntax: |_ name=value|. For example: |_ -o font_size=20| + + +--cmd -c +Run python code in the kitty context + + +--directory -d +default=. +Change to the specified directory when launching + + +--detach +type=bool-set +condition=not is_macos +Detach from the controlling terminal, if any + + +--window-layout +type=choices +choices={window_layout_choices} +The window layout to use on startup + + +--session +Path to a file containing the startup |_ session| (tabs, windows, layout, programs) + + +--single-instance -1 +type=bool-set +If specified only a single instance of {appname} will run. New invocations will +instead create a new top-level window in the existing {appname} instance. This +allows {appname} to share a single sprite cache on the GPU and also reduces +startup time. You can also have separate groups of {appname} instances by using the +|_ --instance-group| option + + +--instance-group +Used in combination with the |_ --single-instance| option. All {appname} invocations +with the same |_ --instance-group| will result in new windows being created +in the first {appname} instance with that group + + +# Debugging options + +--version -v +The current {appname} version + + +--dump-commands +type=bool-set +Output commands received from child process to stdout + + +--replay-commands +type=bool-set +Replay previously dumped commands + + +--dump-bytes +Path to file in which to store the raw bytes received from the child process + + +--debug-gl +type=bool-set +Debug OpenGL commands. This will cause all OpenGL calls to check for errors instead of ignoring them. Useful when debugging rendering problems +''' + + +def surround(x, start, end): + if sys.stdout.isatty(): + x = '\033[{}m{}\033[{}m'.format(start, x, end) + return x + + +def emph(x): + return surround(x, 91, 39) + + +def cyan(x): + return surround(x, 96, 39) + + +def green(x): + return surround(x, 32, 39) + + +def blue(x): + return surround(x, 34, 39) + + +def yellow(x): + return surround(x, 93, 39) + + +def italic(x): + return surround(x, 3, 23) + + +def bold(x): + return surround(x, 1, 22) + + +def title(x): + return blue(bold(x)) + + +def parse_option_spec(spec=OPTIONS): + NORMAL, METADATA, HELP = 'NORMAL', 'METADATA', 'HELP' + state = NORMAL + lines = spec.splitlines() + prev_line = '' + seq = [] + disabled = [] + mpat = re.compile('([a-z]+)=(.+)') + + for line in lines: + line = line.strip() + if state is NORMAL: + if not line: + continue + if line.startswith('# '): + seq.append(line[2:]) + continue + if line.startswith('--'): + parts = line.split(' ') + current_cmd = {'dest': parts[0][2:].replace('-', '_'), 'aliases': frozenset(parts), 'help': ''} + state = METADATA + continue + raise ValueError('Invalid option spec, unexpected line: {}'.format(line)) + elif state is METADATA: + m = mpat.match(line) + if m is None: + state = HELP + current_cmd['help'] += line + else: + k, v = m.group(1), m.group(2) + if k == 'condition': + v = eval(v) + current_cmd[k] = v + if k == 'choices': + current_cmd['choices'] = {x.strip() for x in current_cmd['choices'].split(',')} + elif state is HELP: + if line: + current_cmd['help'] += ' ' + line + else: + if prev_line: + current_cmd['help'] += '\n' + else: + state = NORMAL + (seq if current_cmd.get('condition', True) else disabled).append(current_cmd) + current_cmd = None + prev_line = line + if current_cmd is not None: + (seq if current_cmd.get('condition', True) else disabled).append(current_cmd) + + return seq, disabled + + +def prettify(text): + + def sub(m): + t = m.group(2) + for ch in m.group(1): + t = {'C': cyan, '_': italic, '*': bold}[ch](t) + return t + + text = re.sub(r'[|]([a-zA-Z_*]+?) (.+?)[|]', sub, text) + return text + + +def version(): + return '{} {} created by {}'.format(italic(appname), green(str_version), title('Kovid Goyal')) + + +def print_help_for_seq(seq): + blocks = [] + a = blocks.append + a('{}: {} [options] [program-to-run ...]'.format(title('Usage'), bold(yellow(appname)))) + a('') + a('Run the {appname} terminal emulator. You can also specify the {program} to run inside {appname} as normal' + ' arguments following the {options}. For example: {appname} /bin/sh'.format( + appname=italic(appname), options=italic('options'), program=italic('program'))) + a('') + a('{}:'.format(title('Options'))) + for opt in seq: + if isinstance(opt, str): + a('{}:'.format(title(opt))) + continue + a(' ' + ', '.join(map(green, sorted(opt['aliases'])))) + if opt.get('help'): + t = opt['help'].replace('%default', str(opt.get('default'))) + a(' ' * 4 + prettify(t)) + + print('\n'.join(blocks)) + print('\n' + version()) + + +def defval_for_opt(opt): + dv = opt.get('default') + typ = opt.get('type', '') + if typ.startswith('bool-'): + if dv is None: + dv = False if typ == 'bool-set' else True + else: + dv = dv.lower() in ('true', 'yes', 'y') + elif typ == 'list': + dv = [] + return dv + + +class Options: + + def __init__(self, seq): + self.alias_map = {} + self.seq = seq + self.names_map = {} + self.values_map = {} + for opt in seq: + if isinstance(opt, str): + continue + for alias in opt['aliases']: + self.alias_map[alias] = opt + name = opt['dest'] + self.names_map[name] = opt + self.values_map[name] = defval_for_opt(opt) + + def opt_for_alias(self, alias): + opt = self.alias_map.get(alias) + if opt is None: + raise SystemExit('Unknown option: {}'.format(emph(alias))) + return opt + + def needs_arg(self, alias): + if alias in ('-h', '--help'): + print_help_for_seq(self.seq) + raise SystemExit(0) + opt = self.opt_for_alias(alias) + if opt['dest'] == 'version': + print(version()) + raise SystemExit(0) + typ = opt.get('type', '') + return not typ.startswith('bool-') + + def process_arg(self, alias, val=None): + opt = self.opt_for_alias(alias) + typ = opt.get('type', '') + name = opt['dest'] + if typ == 'bool-set': + self.values_map[name] = True + elif typ == 'bool-reset': + self.values_map[name] = False + elif typ == 'list': + self.values_map.setdefault(name, []) + self.values_map[name].append(val) + elif typ == 'choices': + choices = opt['choices'] + if val not in choices: + raise SystemExit('{} is not a valid value for the {} option. Valid values are: {}'.format( + val, emph(alias), ', '.join(choices))) + self.values_map[name] = val + else: + self.values_map[name] = val + + +class Namespace: + + def __init__(self, kwargs): + for name in kwargs: + setattr(self, name, kwargs[name]) + + +def parse_cmdline(options, args=None): + NORMAL, EXPECTING_ARG = 'NORMAL', 'EXPECTING_ARG' + state = NORMAL + if args is None: + args = sys.argv[1:] + args = deque(args) + seq, disabled = options + oc = Options(seq) + current_option = None + + while args: + arg = args.popleft() + if state is NORMAL: + if arg.startswith('-'): + if arg == '--': + break + parts = arg.split('=', 1) + needs_arg = oc.needs_arg(parts[0]) + if not needs_arg: + if len(parts) != 1: + raise SystemExit('The {} option does not accept arguments'.format(emph(parts[0]))) + oc.process_arg(parts[0]) + continue + if len(parts) == 1: + current_option = parts[0] + state = EXPECTING_ARG + continue + oc.process_arg(parts[0], parts[1]) + else: + args = [arg] + list(args) + break + else: + oc.process_arg(current_option, arg) + current_option, state = None, NORMAL + if state is EXPECTING_ARG: + raise SystemExit('An argument is required for the option: {}'.format(emph(arg))) + + ans = Namespace(oc.values_map) + for opt in disabled: + setattr(ans, opt['dest'], defval_for_opt(opt)) + return ans, list(args) + + +def options_spec(): + if not hasattr(options_spec, 'ans'): + options_spec.ans = OPTIONS.format( + appname=appname, config_path=defconf, + window_layout_choices=','.join(all_layouts) ) - a( - '--replay-commands', - default=None, - help=_('Replay previously dumped commands') - ) - a( - '--dump-bytes', - help=_('Path to file in which to store the raw bytes received from the' - ' child process. Useful for debugging.') - ) - a( - '--debug-gl', - action='store_true', - default=False, - help=_('Debug OpenGL commands. This will cause all OpenGL calls' - ' to check for errors instead of ignoring them. Useful' - ' when debugging rendering problems.') - ) - a( - '--window-layout', - default=None, - choices=frozenset(all_layouts.keys()), - help=_('The window layout to use on startup') - ) - a( - '--session', - default=None, - help=_( - 'Path to a file containing the startup session (tabs, windows, layout, programs)' - ) - ) - a( - '-1', '--single-instance', - default=False, - action='store_true', - help=_( - 'If specified only a single instance of {0} will run. New invocations will' - ' instead create a new top-level window in the existing {0} instance. This' - ' allows {0} to share a single sprite cache on the GPU and also reduces' - ' startup time. You can also have groups of {0} instances by using the' - ' {1} option.' - ).format(appname, '--instance-group') - ) - a( - '--instance-group', - default=None, - help=_( - 'Used in combination with the --single-instance option. All {0} invocations' - ' with the same --instance-group will result in new windows being created' - ' in the first {0} instance with that group.' - ).format(appname) - ) - a( - 'args', - nargs=argparse.REMAINDER, - help=_( - 'The remaining arguments are used to launch a program other than the default shell. Any further options are passed' - ' directly to the program being invoked.' - ) - ) - return parser + return options_spec.ans + + +def parse_args(args=None): + options = parse_option_spec(options_spec()) + return parse_cmdline(options, args=args) def create_opts(args): diff --git a/kitty/main.py b/kitty/main.py index 28d9504f2..9dc2741f9 100644 --- a/kitty/main.py +++ b/kitty/main.py @@ -10,7 +10,7 @@ from contextlib import contextmanager from .borders import load_borders_program from .boss import Boss -from .cli import create_opts, option_parser +from .cli import create_opts, parse_args from .config import initial_window_size, load_cached_values, save_cached_values from .constants import glfw_path, is_macos, is_wayland, logo_data_file from .fast_data_types import ( @@ -116,7 +116,8 @@ def main(): os.chdir(os.path.expanduser('~')) if not os.path.isdir(os.getcwd()): os.chdir(os.path.expanduser('~')) - args = option_parser().parse_args() + args, rest = parse_args() + args.args = rest if getattr(args, 'detach', False): detach() if args.cmd: