Add type checking for the various CLI options objects

This commit is contained in:
Kovid Goyal
2020-03-05 15:47:12 +05:30
parent 0f4e7921ee
commit f05890719d
27 changed files with 487 additions and 157 deletions

View File

@@ -6,9 +6,30 @@ import os
import re
import sys
from collections import deque
from typing import (
Any, Callable, Dict, FrozenSet, Iterator, List, Optional, Tuple, Type,
TypeVar, Union, cast
)
from .conf.utils import resolve_config
from .constants import appname, defconf, is_macos, is_wayland, str_version
from .cli_stub import CLIOptions
from .options_stub import Options as OptionsStub
try:
from typing import TypedDict
class OptionDict(TypedDict):
dest: str
aliases: FrozenSet[str]
help: str
choices: FrozenSet[str]
type: str
default: Optional[str]
condition: bool
except ImportError:
OptionDict = Dict[str, Any] # type: ignore
CONFIG_HELP = '''\
Specify a path to the configuration file(s) to use. All configuration files are
@@ -34,49 +55,49 @@ defaults for all users.
)
def surround(x, start, end):
def surround(x: str, start: int, end: int) -> str:
if sys.stdout.isatty():
x = '\033[{}m{}\033[{}m'.format(start, x, end)
return x
def emph(x):
def emph(x: str) -> str:
return surround(x, 91, 39)
def cyan(x):
def cyan(x: str) -> str:
return surround(x, 96, 39)
def green(x):
def green(x: str) -> str:
return surround(x, 32, 39)
def blue(x):
def blue(x: str) -> str:
return surround(x, 34, 39)
def yellow(x):
def yellow(x: str) -> str:
return surround(x, 93, 39)
def italic(x):
def italic(x: str) -> str:
return surround(x, 3, 23)
def bold(x):
def bold(x: str) -> str:
return surround(x, 1, 22)
def title(x):
def title(x: str) -> str:
return blue(bold(x))
def opt(text):
def opt(text: str) -> str:
return text
def option(x):
def option(x: str) -> str:
idx = x.find('-')
if idx > -1:
x = x[idx:]
@@ -84,33 +105,39 @@ def option(x):
return ' '.join(parts)
def code(x):
def code(x: str) -> str:
return x
def kbd(x):
def kbd(x: str) -> str:
return x
def env(x):
def env(x: str) -> str:
return italic(x)
def file(x):
def file(x: str) -> str:
return italic(x)
def parse_option_spec(spec=None):
OptionSpecSeq = List[Union[str, OptionDict]]
def parse_option_spec(spec: Optional[str] = None) -> Tuple[OptionSpecSeq, OptionSpecSeq]:
if spec is None:
spec = options_spec()
NORMAL, METADATA, HELP = 'NORMAL', 'METADATA', 'HELP'
state = NORMAL
lines = spec.splitlines()
prev_line = ''
seq = []
disabled = []
seq: OptionSpecSeq = []
disabled: OptionSpecSeq = []
mpat = re.compile('([a-z]+)=(.+)')
current_cmd = None
current_cmd: OptionDict = {
'dest': '', 'aliases': frozenset(), 'help': '', 'choices': frozenset(), 'type': '', 'condition': False, 'default': None
}
empty_cmd = current_cmd
for line in lines:
line = line.rstrip()
@@ -122,7 +149,10 @@ def parse_option_spec(spec=None):
continue
if line.startswith('--'):
parts = line.split(' ')
current_cmd = {'dest': parts[0][2:].replace('-', '_'), 'aliases': frozenset(parts), 'help': ''}
current_cmd = {
'dest': parts[0][2:].replace('-', '_'), 'aliases': frozenset(parts), 'help': '', 'choices': frozenset(), 'type': '',
'default': None, 'condition': True
}
state = METADATA
continue
raise ValueError('Invalid option spec, unexpected line: {}'.format(line))
@@ -133,11 +163,17 @@ def parse_option_spec(spec=None):
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(',')}
current_cmd['choices'] = frozenset(x.strip() for x in v.split(','))
else:
if k == 'default':
current_cmd['default'] = v
elif k == 'type':
current_cmd['type'] = v
elif k == 'dest':
current_cmd['dest'] = v
elif k == 'condition':
current_cmd['condition'] = bool(eval(v))
elif state is HELP:
if line:
spc = '' if current_cmd['help'].endswith('\n') else ' '
@@ -148,15 +184,15 @@ def parse_option_spec(spec=None):
else:
state = NORMAL
(seq if current_cmd.get('condition', True) else disabled).append(current_cmd)
current_cmd = None
current_cmd = empty_cmd
prev_line = line
if current_cmd is not None:
if current_cmd is not empty_cmd:
(seq if current_cmd.get('condition', True) else disabled).append(current_cmd)
return seq, disabled
def prettify(text):
def prettify(text: str) -> str:
role_map = globals()
def sub(m):
@@ -167,11 +203,11 @@ def prettify(text):
return text
def prettify_rst(text):
def prettify_rst(text: str) -> str:
return re.sub(r':([a-z]+):`([^`]+)`(=[^\s.]+)', r':\1:`\2`:code:`\3`', text)
def version(add_rev=False):
def version(add_rev: bool = False) -> str:
rev = ''
from . import fast_data_types
if add_rev and hasattr(fast_data_types, 'KITTY_VCS_REV'):
@@ -179,7 +215,7 @@ def version(add_rev=False):
return '{} {}{} created by {}'.format(italic(appname), green(str_version), rev, title('Kovid Goyal'))
def wrap(text, limit=80):
def wrap(text: str, limit: int = 80) -> Iterator[str]:
NORMAL, IN_FORMAT = 'NORMAL', 'IN_FORMAT'
state = NORMAL
last_space_at = None
@@ -203,7 +239,7 @@ def wrap(text, limit=80):
last_space_at = None
chars_in_line = i - breaks[-1]
lines = []
lines: List[str] = []
for b in reversed(breaks):
lines.append(text[b:].lstrip())
text = text[:b]
@@ -212,8 +248,8 @@ def wrap(text, limit=80):
return reversed(lines)
def get_defaults_from_seq(seq):
ans = {}
def get_defaults_from_seq(seq: OptionSpecSeq) -> Dict[str, Any]:
ans: Dict[str, Any] = {}
for opt in seq:
if not isinstance(opt, str):
ans[opt['dest']] = defval_for_opt(opt)
@@ -232,21 +268,21 @@ class PrintHelpForSeq:
allow_pager = True
def __call__(self, seq, usage, message, appname):
def __call__(self, seq: OptionSpecSeq, usage: Optional[str], message: Optional[str], appname: str) -> None:
from kitty.utils import screen_size_function
screen_size = screen_size_function()
try:
linesz = min(screen_size().cols, 76)
except OSError:
linesz = 76
blocks = []
blocks: List[str] = []
a = blocks.append
def wa(text, indent=0, leading_indent=None):
if leading_indent is None:
leading_indent = indent
j = '\n' + (' ' * indent)
lines = []
lines: List[str] = []
for l in text.splitlines():
if l:
lines.extend(wrap(l, limit=linesz - indent))
@@ -279,7 +315,7 @@ class PrintHelpForSeq:
wa(prettify(t.strip()), indent=4)
if defval is not None:
wa('Default: {}'.format(defval), indent=4)
if 'choices' in opt:
if opt.get('choices'):
wa('Choices: {}'.format(', '.join(opt['choices'])), indent=4)
a('')
@@ -299,9 +335,9 @@ class PrintHelpForSeq:
print_help_for_seq = PrintHelpForSeq()
def seq_as_rst(seq, usage, message, appname, heading_char='-'):
def seq_as_rst(seq: OptionSpecSeq, usage: Optional[str], message: Optional[str], appname: Optional[str], heading_char: str = '-') -> str:
import textwrap
blocks = []
blocks: List[str] = []
a = blocks.append
usage = '[program-to-run ...]' if usage is None else usage
@@ -338,7 +374,7 @@ def seq_as_rst(seq, usage, message, appname, heading_char='-'):
a(textwrap.indent(prettify_rst(t), ' ' * 4))
if defval is not None:
a(textwrap.indent('Default: :code:`{}`'.format(defval), ' ' * 4))
if 'choices' in opt:
if opt.get('choices'):
a(textwrap.indent('Choices: :code:`{}`'.format(', '.join(sorted(opt['choices']))), ' ' * 4))
a('')
@@ -346,8 +382,30 @@ def seq_as_rst(seq, usage, message, appname, heading_char='-'):
return text
def defval_for_opt(opt):
dv = opt.get('default')
def as_type_stub(seq: OptionSpecSeq, disabled: OptionSpecSeq, class_name: str) -> str:
from itertools import chain
ans: List[str] = ['class {}:'.format(class_name)]
for opt in chain(seq, disabled):
if isinstance(opt, str):
continue
name = opt['dest']
otype = opt['type'] or 'str'
if otype in ('str', 'int', 'float'):
t = otype
elif otype == 'list':
t = 'typing.Sequence[str]'
elif otype in ('choice', 'choices'):
t = 'str'
elif otype.startswith('bool-'):
t = 'bool'
else:
raise ValueError('Unknown CLI option type: {}'.format(otype))
ans.append(' {}: {}'.format(name, t))
return '\n'.join(ans) + '\n\n\n'
def defval_for_opt(opt: OptionDict) -> Any:
dv: Any = opt.get('default')
typ = opt.get('type', '')
if typ.startswith('bool-'):
if dv is None:
@@ -363,11 +421,11 @@ def defval_for_opt(opt):
class Options:
def __init__(self, seq, usage, message, appname):
def __init__(self, seq: OptionSpecSeq, usage: Optional[str], message: Optional[str], appname: Optional[str]):
self.alias_map = {}
self.seq = seq
self.names_map = {}
self.values_map = {}
self.names_map: Dict[str, OptionDict] = {}
self.values_map: Dict[str, Any] = {}
self.usage, self.message, self.appname = usage, message, appname
for opt in seq:
if isinstance(opt, str):
@@ -378,13 +436,13 @@ class Options:
self.names_map[name] = opt
self.values_map[name] = defval_for_opt(opt)
def opt_for_alias(self, alias):
def opt_for_alias(self, alias: str) -> OptionDict:
opt = self.alias_map.get(alias)
if opt is None:
raise SystemExit('Unknown option: {}'.format(emph(alias)))
return opt
def needs_arg(self, alias):
def needs_arg(self, alias: str) -> bool:
if alias in ('-h', '--help'):
print_help_for_seq(self.seq, self.usage, self.message, self.appname or appname)
raise SystemExit(0)
@@ -395,7 +453,7 @@ class Options:
typ = opt.get('type', '')
return not typ.startswith('bool-')
def process_arg(self, alias, val=None):
def process_arg(self, alias: str, val: Any = None) -> None:
opt = self.opt_for_alias(alias)
typ = opt.get('type', '')
name = opt['dest']
@@ -424,23 +482,15 @@ class Options:
self.values_map[name] = val
class Namespace:
def __init__(self, kwargs):
for name, val in kwargs.items():
setattr(self, name, val)
def parse_cmdline(oc, disabled, args=None):
def parse_cmdline(oc: Options, disabled: OptionSpecSeq, ans: Any, args: Optional[List[str]] = None) -> List[str]:
NORMAL, EXPECTING_ARG = 'NORMAL', 'EXPECTING_ARG'
state = NORMAL
if args is None:
args = sys.argv[1:]
args = deque(args)
dargs = deque(sys.argv[1:] if args is None else args)
leftover_args: List[str] = []
current_option = None
while args:
arg = args.popleft()
while dargs:
arg = dargs.popleft()
if state is NORMAL:
if arg.startswith('-'):
if arg == '--':
@@ -458,21 +508,23 @@ def parse_cmdline(oc, disabled, args=None):
continue
oc.process_arg(parts[0], parts[1])
else:
args = [arg] + list(args)
leftover_args = [arg] + list(dargs)
break
else:
elif current_option is not None:
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 key, val in oc.values_map.items():
setattr(ans, key, val)
for opt in disabled:
setattr(ans, opt['dest'], defval_for_opt(opt))
return ans, list(args)
if not isinstance(opt, str):
setattr(ans, opt['dest'], defval_for_opt(opt))
return leftover_args
def options_spec():
def options_spec() -> str:
if not hasattr(options_spec, 'ans'):
OPTIONS = '''
--class
@@ -618,31 +670,49 @@ Print out information about the system and kitty configuration.
type=bool-set
!
'''
options_spec.ans = OPTIONS.format(
setattr(options_spec, 'ans', OPTIONS.format(
appname=appname, config_help=CONFIG_HELP.format(appname=appname, conf_name=appname)
)
return options_spec.ans
))
return getattr(options_spec, 'ans')
def options_for_completion():
def options_for_completion() -> OptionSpecSeq:
raw = '--help -h\ntype=bool-set\nShow help for {appname} command line options\n\n{raw}'.format(
appname=appname, raw=options_spec())
return parse_option_spec(raw)[0]
def option_spec_as_rst(ospec=options_spec, usage=None, message=None, appname=None, heading_char='-'):
def option_spec_as_rst(
ospec: Callable[[], str] = options_spec,
usage: Optional[str] = None, message: Optional[str] = None, appname: Optional[str] = None,
heading_char='-'
) -> str:
options = parse_option_spec(ospec())
seq, disabled = options
oc = Options(seq, usage, message, appname)
return seq_as_rst(oc.seq, oc.usage, oc.message, oc.appname, heading_char=heading_char)
def parse_args(args=None, ospec=options_spec, usage=None, message=None, appname=None):
T = TypeVar('T')
def parse_args(
args: Optional[List[str]] = None,
ospec: Callable[[], str] = options_spec,
usage: Optional[str] = None,
message: Optional[str] = None,
appname: Optional[str] = None,
result_class: Type[T] = None,
) -> Tuple[T, List[str]]:
options = parse_option_spec(ospec())
seq, disabled = options
oc = Options(seq, usage, message, appname)
return parse_cmdline(oc, disabled, args=args)
if result_class is not None:
ans = result_class()
else:
ans = cast(T, CLIOptions())
return ans, parse_cmdline(oc, disabled, ans, args=args)
SYSTEM_CONF = '/etc/xdg/kitty/kitty.conf'
@@ -655,8 +725,8 @@ def print_shortcut(key_sequence, action):
mmap = {m[len('GLFW_MOD_'):].lower(): x for m, x in v.items() if m.startswith('GLFW_MOD_')}
kmap = {k[len('GLFW_KEY_'):].lower(): x for k, x in v.items() if k.startswith('GLFW_KEY_')}
krmap = {v: k for k, v in kmap.items()}
print_shortcut.maps = mmap, krmap
mmap, krmap = print_shortcut.maps
setattr(print_shortcut, 'maps', (mmap, krmap))
mmap, krmap = getattr(print_shortcut, 'maps')
keys = []
for key in key_sequence:
names = []
@@ -667,7 +737,8 @@ def print_shortcut(key_sequence, action):
if key:
if is_native:
from .fast_data_types import GLFW_KEY_UNKNOWN, glfw_get_key_name
names.append(glfw_get_key_name(GLFW_KEY_UNKNOWN, key))
kn = glfw_get_key_name(GLFW_KEY_UNKNOWN, key) or 'Unknown key'
names.append(kn)
else:
names.append(krmap[key])
keys.append('+'.join(names))
@@ -700,12 +771,12 @@ def flatten_sequence_map(m):
return ans
def compare_opts(opts):
def compare_opts(opts: OptionsStub) -> None:
from .config import defaults, load_config
print('\nConfig options different from defaults:')
default_opts = load_config()
changed_opts = [
f for f in sorted(defaults._fields)
f for f in sorted(defaults._fields) # type: ignore
if f not in ('key_definitions', 'keymap', 'sequence_map') and getattr(opts, f) != getattr(defaults, f)
]
field_len = max(map(len, changed_opts)) if changed_opts else 20
@@ -713,9 +784,9 @@ def compare_opts(opts):
for f in changed_opts:
print(title(fmt.format(f)), getattr(opts, f))
final, initial = opts.keymap, default_opts.keymap
final = {(k,): v for k, v in final.items()}
initial = {(k,): v for k, v in initial.items()}
final_, initial_ = opts.keymap, default_opts.keymap
final = {(k,): v for k, v in final_.items()}
initial = {(k,): v for k, v in initial_.items()}
final_s, initial_s = map(flatten_sequence_map, (opts.sequence_map, default_opts.sequence_map))
final.update(final_s)
initial.update(initial_s)