mirror of
https://github.com/kovidgoyal/kitty
synced 2026-06-08 14:18:26 +02:00
Work on improving CLI parsing
Can now set bool values explicitly with = Handle multi short flag args like -abc Add unit tests for CLI parsing Generate go code to serialize CLI options as a cmdline TODO: Implement setting of bool vals in C and Go parsing code TODO: Help/rst output should somehow indicate this feature
This commit is contained in:
@@ -514,10 +514,13 @@ def kitten_clis() -> None:
|
|||||||
|
|
||||||
with replace_if_needed(f'kittens/{kitten}/cli_generated.go'):
|
with replace_if_needed(f'kittens/{kitten}/cli_generated.go'):
|
||||||
od = []
|
od = []
|
||||||
|
ser = []
|
||||||
kcd = kitten_cli_docs(kitten)
|
kcd = kitten_cli_docs(kitten)
|
||||||
has_underscore = '_' in kitten
|
has_underscore = '_' in kitten
|
||||||
print(f'package {kitten}')
|
print(f'package {kitten}')
|
||||||
|
print('import "fmt"')
|
||||||
print('import "kitty/tools/cli"')
|
print('import "kitty/tools/cli"')
|
||||||
|
print('var _ = fmt.Sprintf')
|
||||||
print('func create_cmd(root *cli.Command, run_func func(*cli.Command, *Options, []string)(int, error)) {')
|
print('func create_cmd(root *cli.Command, run_func func(*cli.Command, *Options, []string)(int, error)) {')
|
||||||
print('ans := root.AddSubCommand(&cli.Command{')
|
print('ans := root.AddSubCommand(&cli.Command{')
|
||||||
print(f'Name: "{kitten}",')
|
print(f'Name: "{kitten}",')
|
||||||
@@ -538,6 +541,7 @@ def kitten_clis() -> None:
|
|||||||
for opt in gopts:
|
for opt in gopts:
|
||||||
print(opt.as_option('ans'))
|
print(opt.as_option('ans'))
|
||||||
od.append(opt.struct_declaration())
|
od.append(opt.struct_declaration())
|
||||||
|
ser.append(opt.as_string_for_commandline())
|
||||||
if ac is not None:
|
if ac is not None:
|
||||||
print(''.join(ac.as_go_code('ans.ArgCompleter', ' = ')))
|
print(''.join(ac.as_go_code('ans.ArgCompleter', ' = ')))
|
||||||
if not kcd:
|
if not kcd:
|
||||||
@@ -550,6 +554,11 @@ def kitten_clis() -> None:
|
|||||||
print('type Options struct {')
|
print('type Options struct {')
|
||||||
print('\n'.join(od))
|
print('\n'.join(od))
|
||||||
print('}')
|
print('}')
|
||||||
|
print('func (opts Options) AsCommandLine() (ans []string) {')
|
||||||
|
for x in ser:
|
||||||
|
print('\t' + x)
|
||||||
|
print('return')
|
||||||
|
print('}')
|
||||||
|
|
||||||
# }}}
|
# }}}
|
||||||
|
|
||||||
|
|||||||
104
kitty/cli.py
104
kitty/cli.py
@@ -115,7 +115,7 @@ def serialize_as_go_string(x: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
go_type_map = {
|
go_type_map = {
|
||||||
'bool-set': 'bool', 'bool-reset': 'bool', 'int': 'int', 'float': 'float64',
|
'bool-set': 'bool', 'bool-reset': 'bool', 'bool-unset': 'bool', 'int': 'int', 'float': 'float64',
|
||||||
'': 'string', 'list': '[]string', 'choices': 'string', 'str': 'string'}
|
'': 'string', 'list': '[]string', 'choices': 'string', 'str': 'string'}
|
||||||
|
|
||||||
|
|
||||||
@@ -146,9 +146,13 @@ class GoOption:
|
|||||||
def struct_declaration(self) -> str:
|
def struct_declaration(self) -> str:
|
||||||
return f'{self.go_var_name} {self.go_type}'
|
return f'{self.go_var_name} {self.go_type}'
|
||||||
|
|
||||||
|
@property
|
||||||
|
def flags(self) -> list[str]:
|
||||||
|
return sorted(self.obj_dict['aliases'])
|
||||||
|
|
||||||
def as_option(self, cmd_name: str = 'cmd', depth: int = 0, group: str = '') -> str:
|
def as_option(self, cmd_name: str = 'cmd', depth: int = 0, group: str = '') -> str:
|
||||||
add = f'AddToGroup("{serialize_as_go_string(group)}", ' if group else 'Add('
|
add = f'AddToGroup("{serialize_as_go_string(group)}", ' if group else 'Add('
|
||||||
aliases = ' '.join(sorted(self.obj_dict['aliases']))
|
aliases = ' '.join(self.flags)
|
||||||
ans = f'''{cmd_name}.{add}cli.OptionSpec{{
|
ans = f'''{cmd_name}.{add}cli.OptionSpec{{
|
||||||
Name: "{serialize_as_go_string(aliases)}",
|
Name: "{serialize_as_go_string(aliases)}",
|
||||||
Type: "{self.type}",
|
Type: "{self.type}",
|
||||||
@@ -168,6 +172,22 @@ class GoOption:
|
|||||||
ans += f'\nDefault: "{serialize_as_go_string(self.default)}",\n'
|
ans += f'\nDefault: "{serialize_as_go_string(self.default)}",\n'
|
||||||
return ans + '})'
|
return ans + '})'
|
||||||
|
|
||||||
|
def as_string_for_commandline(self) -> str:
|
||||||
|
flag = self.flags[0]
|
||||||
|
val = f'opts.{self.go_var_name}'
|
||||||
|
match self.go_type:
|
||||||
|
case 'bool':
|
||||||
|
val = f'fmt.Sprintf(`%#v`, {val})'
|
||||||
|
case 'int':
|
||||||
|
val = f'fmt.Sprintf(`%d`, {val})'
|
||||||
|
case 'string':
|
||||||
|
val = val
|
||||||
|
case 'float64':
|
||||||
|
val = f'fmt.Sprintf(`%f`, {val})'
|
||||||
|
case '[]string':
|
||||||
|
return f'for _, x := range {val} {{ ans = append(ans, `{flag}=` + x) }}'
|
||||||
|
return f'ans = append(ans, `{flag}=` + {val})'
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def sorted_choices(self) -> list[str]:
|
def sorted_choices(self) -> list[str]:
|
||||||
choices = sorted(self.obj_dict['choices'])
|
choices = sorted(self.obj_dict['choices'])
|
||||||
@@ -426,6 +446,10 @@ def parse_option_spec(spec: str | None = None) -> tuple[OptionSpecSeq, OptionSpe
|
|||||||
k, v = m.group(1), m.group(2)
|
k, v = m.group(1), m.group(2)
|
||||||
if k == 'choices':
|
if k == 'choices':
|
||||||
vals = tuple(x.strip() for x in v.split(','))
|
vals = tuple(x.strip() for x in v.split(','))
|
||||||
|
if not current_cmd['type']:
|
||||||
|
current_cmd['type'] = 'choices'
|
||||||
|
if current_cmd['type'] != 'choices':
|
||||||
|
raise ValueError(f'Cannot specify choices for an option of type: {current_cmd["type"]}')
|
||||||
current_cmd['choices'] = frozenset(vals)
|
current_cmd['choices'] = frozenset(vals)
|
||||||
if current_cmd['default'] is None:
|
if current_cmd['default'] is None:
|
||||||
current_cmd['default'] = vals[0]
|
current_cmd['default'] = vals[0]
|
||||||
@@ -740,8 +764,20 @@ def defval_for_opt(opt: OptionDict) -> Any:
|
|||||||
return dv
|
return dv
|
||||||
|
|
||||||
|
|
||||||
|
bool_map = {'y': True, 'yes': True, 'true': True, 'n': False, 'no': False, 'false': False}
|
||||||
|
|
||||||
|
|
||||||
|
def to_bool(alias: str, x: str) -> bool:
|
||||||
|
try:
|
||||||
|
return bool_map[x]
|
||||||
|
except KeyError:
|
||||||
|
raise SystemExit(f'{x} is not a valid value for {alias}. Valid values are y, yes, true, n, no, false only')
|
||||||
|
|
||||||
|
|
||||||
class Options:
|
class Options:
|
||||||
|
|
||||||
|
do_print = True
|
||||||
|
|
||||||
def __init__(self, seq: OptionSpecSeq, usage: str | None, message: str | None, appname: str | None):
|
def __init__(self, seq: OptionSpecSeq, usage: str | None, message: str | None, appname: str | None):
|
||||||
self.alias_map = {}
|
self.alias_map = {}
|
||||||
self.seq = seq
|
self.seq = seq
|
||||||
@@ -757,32 +793,39 @@ class Options:
|
|||||||
self.names_map[name] = opt
|
self.names_map[name] = opt
|
||||||
self.values_map[name] = defval_for_opt(opt)
|
self.values_map[name] = defval_for_opt(opt)
|
||||||
|
|
||||||
def opt_for_alias(self, alias: str) -> OptionDict:
|
def check_for_standard_flag(self, alias: str) -> None:
|
||||||
|
if alias in ('-h', '--help'):
|
||||||
|
if self.do_print:
|
||||||
|
print_help_for_seq(self.seq, self.usage, self.message, self.appname or appname)
|
||||||
|
raise SystemExit(0)
|
||||||
opt = self.alias_map.get(alias)
|
opt = self.alias_map.get(alias)
|
||||||
if opt is None:
|
if opt is None:
|
||||||
raise SystemExit(f'Unknown option: {emph(alias)}')
|
raise SystemExit(f'Unknown option: {emph(alias)}')
|
||||||
return opt
|
|
||||||
|
|
||||||
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)
|
|
||||||
opt = self.opt_for_alias(alias)
|
|
||||||
if opt['dest'] == 'version':
|
if opt['dest'] == 'version':
|
||||||
|
if self.do_print:
|
||||||
print(version())
|
print(version())
|
||||||
raise SystemExit(0)
|
raise SystemExit(0)
|
||||||
|
|
||||||
|
def is_bool(self, alias: str) -> bool:
|
||||||
|
opt = self.alias_map[alias]
|
||||||
typ = opt.get('type', '')
|
typ = opt.get('type', '')
|
||||||
return not typ.startswith('bool-')
|
return typ.startswith('bool-')
|
||||||
|
|
||||||
def process_arg(self, alias: str, val: Any = None) -> None:
|
def process_arg(self, alias: str, val: Any = None) -> None:
|
||||||
opt = self.opt_for_alias(alias)
|
opt = self.alias_map[alias]
|
||||||
typ = opt.get('type', '')
|
typ = opt.get('type', '')
|
||||||
name = opt['dest']
|
name = opt['dest']
|
||||||
nmap = {'float': float, 'int': int}
|
nmap = {'float': float, 'int': int}
|
||||||
if typ == 'bool-set':
|
if typ == 'bool-set':
|
||||||
|
if val is None:
|
||||||
self.values_map[name] = True
|
self.values_map[name] = True
|
||||||
|
else:
|
||||||
|
self.values_map[name] = to_bool(alias, val)
|
||||||
elif typ == 'bool-reset':
|
elif typ == 'bool-reset':
|
||||||
|
if val is None:
|
||||||
self.values_map[name] = False
|
self.values_map[name] = False
|
||||||
|
else:
|
||||||
|
self.values_map[name] = to_bool(alias, val)
|
||||||
elif typ == 'list':
|
elif typ == 'list':
|
||||||
self.values_map.setdefault(name, [])
|
self.values_map.setdefault(name, [])
|
||||||
self.values_map[name].append(val)
|
self.values_map[name].append(val)
|
||||||
@@ -809,26 +852,43 @@ def parse_cmdline(oc: Options, disabled: OptionSpecSeq, ans: Any, args: list[str
|
|||||||
dargs = deque(sys.argv[1:] if args is None else args)
|
dargs = deque(sys.argv[1:] if args is None else args)
|
||||||
leftover_args: list[str] = []
|
leftover_args: list[str] = []
|
||||||
current_option = None
|
current_option = None
|
||||||
|
payload: str | None = None
|
||||||
|
|
||||||
while dargs:
|
while dargs:
|
||||||
arg = dargs.popleft()
|
arg = dargs.popleft()
|
||||||
if state is NORMAL:
|
if state is NORMAL:
|
||||||
if arg.startswith('-'):
|
if arg.startswith('-'):
|
||||||
if arg == '--':
|
is_long_opt = arg.startswith('--')
|
||||||
|
if is_long_opt and arg == '--':
|
||||||
leftover_args = list(dargs)
|
leftover_args = list(dargs)
|
||||||
break
|
break
|
||||||
parts = arg.split('=', 1)
|
flag, has_equal, payload = arg.partition('=')
|
||||||
needs_arg = oc.needs_arg(parts[0])
|
if not has_equal:
|
||||||
if not needs_arg:
|
payload = None
|
||||||
if len(parts) != 1:
|
if is_long_opt:
|
||||||
raise SystemExit(f'The {emph(parts[0])} option does not accept arguments')
|
oc.check_for_standard_flag(flag)
|
||||||
oc.process_arg(parts[0])
|
if oc.is_bool(flag):
|
||||||
|
oc.process_arg(flag, payload)
|
||||||
continue
|
continue
|
||||||
if len(parts) == 1:
|
if not has_equal:
|
||||||
current_option = parts[0]
|
current_option = flag
|
||||||
|
state = EXPECTING_ARG
|
||||||
|
continue
|
||||||
|
oc.process_arg(flag, payload)
|
||||||
|
else:
|
||||||
|
letters = flag[1:]
|
||||||
|
for letter in letters[:-1]:
|
||||||
|
flag = f'-{letter}'
|
||||||
|
oc.check_for_standard_flag(flag)
|
||||||
|
oc.process_arg(flag)
|
||||||
|
flag = f'-{letters[-1]}'
|
||||||
|
oc.check_for_standard_flag(flag)
|
||||||
|
if oc.is_bool(flag) or payload is not None:
|
||||||
|
oc.process_arg(flag, payload)
|
||||||
|
else:
|
||||||
|
current_option = flag
|
||||||
state = EXPECTING_ARG
|
state = EXPECTING_ARG
|
||||||
continue
|
continue
|
||||||
oc.process_arg(parts[0], parts[1])
|
|
||||||
else:
|
else:
|
||||||
leftover_args = [arg] + list(dargs)
|
leftover_args = [arg] + list(dargs)
|
||||||
break
|
break
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ from collections.abc import Sequence
|
|||||||
|
|
||||||
|
|
||||||
class CLIOptions:
|
class CLIOptions:
|
||||||
pass
|
def __repr__(self) -> str:
|
||||||
|
return repr(vars(self))
|
||||||
|
|
||||||
|
|
||||||
LaunchCLIOptions = AskCLIOptions = ClipboardCLIOptions = DiffCLIOptions = CLIOptions
|
LaunchCLIOptions = AskCLIOptions = ClipboardCLIOptions = DiffCLIOptions = CLIOptions
|
||||||
|
|||||||
@@ -28,6 +28,89 @@ class TestConfParsing(BaseTest):
|
|||||||
def test_conf_parsing(self):
|
def test_conf_parsing(self):
|
||||||
conf_parsing(self)
|
conf_parsing(self)
|
||||||
|
|
||||||
|
def test_cli_parsing(self):
|
||||||
|
cli_parsing(self)
|
||||||
|
|
||||||
|
|
||||||
|
def cli_parsing(self):
|
||||||
|
from kitty.cli import CLIOptions, Options, parse_cmdline, parse_option_spec
|
||||||
|
from kitty.utils import shlex_split
|
||||||
|
seq, disabled = parse_option_spec('''\
|
||||||
|
--simple-string -s
|
||||||
|
a simple string
|
||||||
|
|
||||||
|
|
||||||
|
--list -l
|
||||||
|
type=list
|
||||||
|
a list
|
||||||
|
|
||||||
|
|
||||||
|
--choice -c
|
||||||
|
choices=a,b,c
|
||||||
|
some choices
|
||||||
|
|
||||||
|
|
||||||
|
--bool-set -1
|
||||||
|
type=bool-set
|
||||||
|
set a bool
|
||||||
|
|
||||||
|
|
||||||
|
--bool-reset -0
|
||||||
|
type=bool-reset
|
||||||
|
unset a bool
|
||||||
|
|
||||||
|
|
||||||
|
--int -i
|
||||||
|
type=int
|
||||||
|
a integer
|
||||||
|
|
||||||
|
|
||||||
|
--float -f
|
||||||
|
type=float
|
||||||
|
a float
|
||||||
|
|
||||||
|
|
||||||
|
--version -v
|
||||||
|
type=bool-set
|
||||||
|
version
|
||||||
|
''')
|
||||||
|
oc = Options(seq, usage='xxx', message='yyy', appname='test')
|
||||||
|
oc.do_print = False
|
||||||
|
|
||||||
|
def t(args, leftover=(), fails=False, **expected):
|
||||||
|
oc.values_map['list'] = []
|
||||||
|
args = list(shlex_split(args))
|
||||||
|
ans = CLIOptions()
|
||||||
|
if fails:
|
||||||
|
with self.assertRaises(SystemExit):
|
||||||
|
parse_cmdline(oc, disabled, ans, args=args)
|
||||||
|
else:
|
||||||
|
actual_leftover = parse_cmdline(oc, disabled, ans, args=args)
|
||||||
|
self.assertEqual(tuple(leftover), tuple(actual_leftover), f'{args}\n{ans}')
|
||||||
|
for dest, defval in oc.values_map.items():
|
||||||
|
val = expected.get(dest, defval)
|
||||||
|
self.assertEqual(val, getattr(ans, dest), f'Failed to parse {dest} correctly for: {args} \n{ans}')
|
||||||
|
|
||||||
|
t('-1 -h', fails=True)
|
||||||
|
t('-1 --help', fails=True)
|
||||||
|
t('-1 -0v', fails=True)
|
||||||
|
t('-1 -v0', fails=True)
|
||||||
|
t('-1 --version', fails=True)
|
||||||
|
t('-1', bool_set=True)
|
||||||
|
t('-01', bool_reset=False, bool_set=True)
|
||||||
|
t('-01=y', bool_reset=False, bool_set=True)
|
||||||
|
t('-01=n', bool_reset=False, bool_set=False)
|
||||||
|
t('--simple-string xx moo -1', leftover=['moo', '-1'], simple_string='xx')
|
||||||
|
t('--simple-string -0 -- -1', leftover=['-1'], simple_string='-0')
|
||||||
|
t('--simple-string=-0 -- -1', leftover=['-1'], simple_string='-0')
|
||||||
|
t('--simple-string=--help -- -1', leftover=['-1'], simple_string='--help')
|
||||||
|
t('--simple-string --help -- -1', leftover=['-1'], simple_string='--help')
|
||||||
|
t('-1l=a --list=b -c b --list c', bool_set=True, choice='b', list=list('abc'))
|
||||||
|
t('-1s= -l "" --list= x', leftover=['x'], bool_set=True, simple_string='', list=['', ''])
|
||||||
|
t('--choice moo', fails=True)
|
||||||
|
t('-1c moo', fails=True)
|
||||||
|
t('-10c=moo', fails=True)
|
||||||
|
|
||||||
|
|
||||||
def conf_parsing(self):
|
def conf_parsing(self):
|
||||||
from kitty.config import defaults, load_config
|
from kitty.config import defaults, load_config
|
||||||
|
|||||||
Reference in New Issue
Block a user