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:
Kovid Goyal
2025-04-26 09:01:54 +05:30
parent 2093fb1310
commit 3f00dc1c9e
4 changed files with 182 additions and 29 deletions

View File

@@ -514,10 +514,13 @@ def kitten_clis() -> None:
with replace_if_needed(f'kittens/{kitten}/cli_generated.go'):
od = []
ser = []
kcd = kitten_cli_docs(kitten)
has_underscore = '_' in kitten
print(f'package {kitten}')
print('import "fmt"')
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('ans := root.AddSubCommand(&cli.Command{')
print(f'Name: "{kitten}",')
@@ -538,6 +541,7 @@ def kitten_clis() -> None:
for opt in gopts:
print(opt.as_option('ans'))
od.append(opt.struct_declaration())
ser.append(opt.as_string_for_commandline())
if ac is not None:
print(''.join(ac.as_go_code('ans.ArgCompleter', ' = ')))
if not kcd:
@@ -550,6 +554,11 @@ def kitten_clis() -> None:
print('type Options struct {')
print('\n'.join(od))
print('}')
print('func (opts Options) AsCommandLine() (ans []string) {')
for x in ser:
print('\t' + x)
print('return')
print('}')
# }}}

View File

@@ -115,7 +115,7 @@ def serialize_as_go_string(x: str) -> str:
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'}
@@ -146,9 +146,13 @@ class GoOption:
def struct_declaration(self) -> str:
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:
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{{
Name: "{serialize_as_go_string(aliases)}",
Type: "{self.type}",
@@ -168,6 +172,22 @@ class GoOption:
ans += f'\nDefault: "{serialize_as_go_string(self.default)}",\n'
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
def sorted_choices(self) -> list[str]:
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)
if k == 'choices':
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)
if current_cmd['default'] is None:
current_cmd['default'] = vals[0]
@@ -740,8 +764,20 @@ def defval_for_opt(opt: OptionDict) -> Any:
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:
do_print = True
def __init__(self, seq: OptionSpecSeq, usage: str | None, message: str | None, appname: str | None):
self.alias_map = {}
self.seq = seq
@@ -757,32 +793,39 @@ class Options:
self.names_map[name] = 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)
if opt is None:
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':
print(version())
if self.do_print:
print(version())
raise SystemExit(0)
def is_bool(self, alias: str) -> bool:
opt = self.alias_map[alias]
typ = opt.get('type', '')
return not typ.startswith('bool-')
return typ.startswith('bool-')
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', '')
name = opt['dest']
nmap = {'float': float, 'int': int}
if typ == 'bool-set':
self.values_map[name] = True
if val is None:
self.values_map[name] = True
else:
self.values_map[name] = to_bool(alias, val)
elif typ == 'bool-reset':
self.values_map[name] = False
if val is None:
self.values_map[name] = False
else:
self.values_map[name] = to_bool(alias, val)
elif typ == 'list':
self.values_map.setdefault(name, [])
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)
leftover_args: list[str] = []
current_option = None
payload: str | None = None
while dargs:
arg = dargs.popleft()
if state is NORMAL:
if arg.startswith('-'):
if arg == '--':
is_long_opt = arg.startswith('--')
if is_long_opt and arg == '--':
leftover_args = list(dargs)
break
parts = arg.split('=', 1)
needs_arg = oc.needs_arg(parts[0])
if not needs_arg:
if len(parts) != 1:
raise SystemExit(f'The {emph(parts[0])} option does not accept arguments')
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])
flag, has_equal, payload = arg.partition('=')
if not has_equal:
payload = None
if is_long_opt:
oc.check_for_standard_flag(flag)
if oc.is_bool(flag):
oc.process_arg(flag, payload)
continue
if not has_equal:
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
continue
else:
leftover_args = [arg] + list(dargs)
break

View File

@@ -6,7 +6,8 @@ from collections.abc import Sequence
class CLIOptions:
pass
def __repr__(self) -> str:
return repr(vars(self))
LaunchCLIOptions = AskCLIOptions = ClipboardCLIOptions = DiffCLIOptions = CLIOptions

View File

@@ -28,6 +28,89 @@ class TestConfParsing(BaseTest):
def test_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):
from kitty.config import defaults, load_config