Get rid of the horrible argparse

It's slow, bloated and has no support for decent output formatting
This commit is contained in:
Kovid Goyal 2017-11-27 14:50:06 +05:30
parent fb41ecb2e5
commit 0cac74d39a
No known key found for this signature in database
GPG Key ID: 06BC317B515ACE7C
3 changed files with 361 additions and 134 deletions

View File

@ -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)

View File

@ -2,142 +2,367 @@
# vim:fileencoding=utf-8
# License: GPL v3 Copyright: 2017, Kovid Goyal <kovid at kovidgoyal.net>
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):

View File

@ -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: