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'):
|
||||
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('}')
|
||||
|
||||
# }}}
|
||||
|
||||
|
||||
116
kitty/cli.py
116
kitty/cli.py
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user