Work on conversion of args parsing to go code

This commit is contained in:
Kovid Goyal
2022-08-30 00:21:59 +05:30
parent 441e4edfb2
commit 79c8862d4c
32 changed files with 274 additions and 66 deletions

View File

@@ -2,9 +2,10 @@
# License: GPLv3 Copyright: 2020, Kovid Goyal <kovid at kovidgoyal.net>
from contextlib import suppress
from dataclasses import dataclass
from typing import (
TYPE_CHECKING, Any, Callable, Dict, FrozenSet, Iterable, Iterator, List,
NoReturn, Optional, Tuple, Type, Union, cast
NoReturn, Optional, Set, Tuple, Type, Union, cast
)
from kitty.cli import get_defaults_from_seq, parse_args, parse_option_spec
@@ -160,27 +161,98 @@ class AsyncResponder:
send_response_to_client(error=error, peer_id=self.peer_id, window_id=self.window_id, async_id=self.async_id)
@dataclass(frozen=True)
class ArgsHandling:
json_field: str = ''
count: Optional[int] = None
spec: str = ''
completion: Optional[Dict[str, Tuple[str, Union[Callable[[], Iterable[str]], Tuple[str, ...]]]]] = None
value_if_unspecified: Tuple[str, ...] = ()
minimum_count: int = -1
first_rest: Optional[Tuple[str, str]] = None
special_parse: str = ''
@property
def args_count(self) -> Optional[int]:
if not self.spec:
return 0
return self.count
def as_go_code(self, cmd_name: str, field_types: Dict[str, str], handled_fields: Set[str]) -> Iterator[str]:
c = self.args_count
if c == 0:
yield f'if len(args) != 0 {{ return fmt.Errorf("%s", "Unknown extra argument(s) supplied to {cmd_name}") }}'
return
if c is not None:
yield f'if len(args) != {c} {{ return fmt.Errorf("%s", "Must specify exactly {c} argument(s) for {cmd_name}") }}'
if self.value_if_unspecified:
yield 'if len(args) == 0 {'
for x in self.value_if_unspecified:
yield f'args = append(args, "{x}")'
yield '}'
if self.minimum_count > -1:
yield f'if len(args) < {self.minimum_count} {{ return fmt.Errorf("%s", Must specify at least {self.minimum_count} arguments to {cmd_name}) }}'
if self.json_field:
jf = self.json_field
dest = f'payload.{jf.capitalize()}'
jt = field_types[jf]
if self.first_rest:
yield f'payload.{self.first_rest[0].capitalize()} = args[0]'
yield f'payload.{self.first_rest[1].capitalize()} = args[1:]'
handled_fields.add(self.first_rest[0])
handled_fields.add(self.first_rest[1])
return
handled_fields.add(self.json_field)
if self.special_parse:
if self.special_parse.startswith('!'):
yield f'io_data.multiple_payload_generator, err = {self.special_parse[1:]}'
else:
yield f'{dest}, err = {self.special_parse}'
yield 'if err != nil { return err }'
return
if jt == 'list.str':
yield f'{dest} = args'
return
if jt == 'str':
if c == 1:
yield f'{dest} = args[0]'
else:
yield f'{dest} = strings.Join(args, " ")'
return
if jt.startswith('choices.'):
yield f'if len(args) != 1 {{ return fmt.Errorf("%s", "Must specify exactly 1 argument for {cmd_name}") }}'
choices = ", ".join((f'"{x}"' for x in jt.split('.')[1:]))
yield 'switch(args[0]) {'
yield f'case {choices}:\n\t{dest} = args[0]'
yield f'default: return fmt.Errorf("%s is not a valid choice. Allowed values: %s", args[0], `{choices}`)'
yield '}'
return
if jt == 'dict.str':
yield f'{dest} = parse_key_val_args(args)'
raise TypeError(f'Unknown args handling for cmd: {cmd_name}')
class RemoteCommand:
Args = ArgsHandling
name: str = ''
short_desc: str = ''
desc: str = ''
argspec: str = '...'
args: ArgsHandling = ArgsHandling()
options_spec: Optional[str] = None
no_response: bool = False
response_timeout: float = 10. # seconds
string_return_is_error: bool = False
args_count: Optional[int] = None
args_completion: Optional[Dict[str, Tuple[str, Union[Callable[[], Iterable[str]], Tuple[str, ...]]]]] = None
defaults: Optional[Dict[str, Any]] = None
is_asynchronous: bool = False
options_class: Type[RCOptions] = RCOptions
protocol_spec: str = ''
argspec = args_count = args_completion = ArgsHandling()
def __init__(self) -> None:
self.desc = self.desc or self.short_desc
self.name = self.__class__.__module__.split('.')[-1].replace('_', '-')
self.args_count = 0 if not self.argspec else self.args_count
def fatal(self, msg: str) -> NoReturn:
if running_in_kitty():
@@ -259,21 +331,21 @@ class RemoteCommand:
def cli_params_for(command: RemoteCommand) -> Tuple[Callable[[], str], str, str, str]:
return (command.options_spec or '\n').format, command.argspec, command.desc, f'{appname} @ {command.name}'
return (command.options_spec or '\n').format, command.args.spec, command.desc, f'{appname} @ {command.name}'
def parse_subcommand_cli(command: RemoteCommand, args: ArgsType) -> Tuple[Any, ArgsType]:
opts, items = parse_args(args[1:], *cli_params_for(command), result_class=command.options_class)
if command.args_count is not None and command.args_count != len(items):
if command.args_count == 0:
if command.args.args_count is not None and command.args.args_count != len(items):
if command.args.args_count == 0:
raise SystemExit(f'Unknown extra argument(s) supplied to {command.name}')
raise SystemExit(f'Must specify exactly {command.args_count} argument(s) for {command.name}')
raise SystemExit(f'Must specify exactly {command.args.args_count} argument(s) for {command.name}')
return opts, items
def display_subcommand_help(func: RemoteCommand) -> None:
with suppress(SystemExit):
parse_args(['--help'], (func.options_spec or '\n').format, func.argspec, func.desc, func.name)
parse_args(['--help'], (func.options_spec or '\n').format, func.args.spec, func.desc, func.name)
def command_for_name(cmd_name: str) -> RemoteCommand:

View File

@@ -46,7 +46,6 @@ Close the tab of the window this command is run in, rather than the active tab.
type=bool-set
Do not return an error if no tabs are matched to be closed.
'''
argspec = ''
def message_to_kitty(self, global_opts: RCOptions, opts: 'CLIOptions', args: ArgsType) -> PayloadType:
return {'match': opts.match, 'self': opts.self, 'ignore_no_match': opts.ignore_no_match}

View File

@@ -38,7 +38,6 @@ Close the window this command is run in, rather than the active window.
type=bool-set
Do not return an error if no windows are matched to be closed.
'''
argspec = ''
def message_to_kitty(self, global_opts: RCOptions, opts: 'CLIOptions', args: ArgsType) -> PayloadType:
return {'match': opts.match, 'self': opts.self, 'ignore_no_match': opts.ignore_no_match}

View File

@@ -32,7 +32,7 @@ class CreateMarker(RemoteCommand):
type=bool-set
Apply marker to the window this command is run in, rather than the active window.
'''
argspec = 'MARKER SPECIFICATION'
args = RemoteCommand.Args(spec='MARKER SPECIFICATION', json_field='marker_spec', minimum_count=2)
def message_to_kitty(self, global_opts: RCOptions, opts: 'CLIOptions', args: ArgsType) -> PayloadType:
if len(args) < 2:

View File

@@ -30,7 +30,6 @@ class DetachTab(RemoteCommand):
type=bool-set
Detach the tab this command is run in, rather than the active tab.
'''
argspec = ''
def message_to_kitty(self, global_opts: RCOptions, opts: 'CLIOptions', args: ArgsType) -> PayloadType:
return {'match': opts.match, 'target_tab': opts.target_tab, 'self': opts.self}

View File

@@ -36,7 +36,6 @@ class DetachWindow(RemoteCommand):
type=bool-set
Detach the window this command is run in, rather than the active window.
''')
argspec = ''
def message_to_kitty(self, global_opts: RCOptions, opts: 'CLIOptions', args: ArgsType) -> PayloadType:
return {'match': opts.match, 'target_tab': opts.target_tab, 'self': opts.self}

View File

@@ -34,7 +34,7 @@ By default, ligatures are only affected in the active window. This option will
cause ligatures to be changed in all windows.
''' + '\n\n' + MATCH_WINDOW_OPTION + '\n\n' + MATCH_TAB_OPTION.replace('--match -m', '--match-tab -t')
argspec = 'STRATEGY'
args = RemoteCommand.Args(spec='STRATEGY', count=1, json_field='strategy')
def message_to_kitty(self, global_opts: RCOptions, opts: 'CLIOptions', args: ArgsType) -> PayloadType:
if not args:

View File

@@ -12,24 +12,27 @@ from .base import (
class Env(RemoteCommand):
protocol_spec = __doc__ = '''
env+/dict.str: Dictionary of environment variables to values. Empty values cause the variable to be removed.
env+/dict.str: Dictionary of environment variables to values. When a env var ends with = it is removed from the environment.
'''
short_desc = 'Change environment variables seen by future children'
desc = (
'Change the environment variables that will be seen in newly launched windows.'
' Similar to the :opt:`env` option in :file:`kitty.conf`, but affects running kitty instances.'
' Empty values cause the environment variable to be removed.'
' If no = is present, the variable is removed from the environment.'
)
argspec = 'env_var1=val env_var2=val ...'
args = RemoteCommand.Args(spec='env_var1=val env_var2=val ...', minimum_count=1, json_field='env')
def message_to_kitty(self, global_opts: RCOptions, opts: Any, args: ArgsType) -> PayloadType:
if len(args) < 1:
self.fatal('Must specify at least one env var to set')
env = {}
for x in args:
key, val = x.split('=', 1)
env[key] = val
if '=' in x:
key, val = x.split('=', 1)
env[key] = val
else:
env[x + '='] = ''
return {'env': env}
def response_from_kitty(self, boss: Boss, window: Optional[Window], payload_get: PayloadGetType) -> ResponseType:
@@ -38,10 +41,10 @@ class Env(RemoteCommand):
new_env = payload_get('env') or {}
env = default_env().copy()
for k, v in new_env.items():
if v:
env[k] = expandvars(v, env)
else:
if k.endswith('='):
env.pop(k, None)
else:
env[k] = expandvars(v or '', env)
set_default_env(env)
return None

View File

@@ -30,7 +30,6 @@ default=false
Don't wait for a response indicating the success of the action. Note that
using this option means that you will not be notified of failures.
'''
argspec = ''
def message_to_kitty(self, global_opts: RCOptions, opts: 'CLIOptions', args: ArgsType) -> PayloadType:
return {'match': opts.match, 'no_response': opts.no_response}

View File

@@ -23,7 +23,6 @@ class FocusWindow(RemoteCommand):
short_desc = 'Focus the specified window'
desc = 'Focus the specified window, if no window is specified, focus the window this command is run inside.'
argspec = ''
options_spec = MATCH_WINDOW_OPTION + '''\n\n
--no-response
type=bool-set
@@ -33,7 +32,7 @@ the command will exit with a success code.
'''
def message_to_kitty(self, global_opts: RCOptions, opts: 'CLIOptions', args: ArgsType) -> PayloadType:
return {'match': opts.match, 'no_response': opts.no_response}
return {'match': opts.match}
def response_from_kitty(self, boss: Boss, window: Optional[Window], payload_get: PayloadGetType) -> ResponseType:
for window in self.windows_for_match_payload(boss, window, payload_get):

View File

@@ -68,7 +68,6 @@ Clear the selection in the matched window, if any.
type=bool-set
Get text from the window this command is run in, rather than the active window.
'''
argspec = ''
def message_to_kitty(self, global_opts: RCOptions, opts: 'CLIOptions', args: ArgsType) -> PayloadType:
return {

View File

@@ -30,9 +30,7 @@ class GotoLayout(RemoteCommand):
' You can use special match value :code:`all` to set the layout in all tabs.'
)
options_spec = MATCH_TAB_OPTION
argspec = 'LAYOUT_NAME'
args_count = 1
args_completion = {'names': ('Layouts', layout_names)}
args = RemoteCommand.Args(spec='LAYOUT_NAME', count=1, completion={'names': ('Layouts', layout_names)}, json_field='layout')
def message_to_kitty(self, global_opts: RCOptions, opts: 'CLIOptions', args: ArgsType) -> PayloadType:
if len(args) != 1:

View File

@@ -30,7 +30,7 @@ class Kitten(RemoteCommand):
' is printed out to stdout.'
)
options_spec = MATCH_WINDOW_OPTION
argspec = 'kitten_name'
args = RemoteCommand.Args(spec='kitten_name', json_field='kitten', minimum_count=1, first_rest=('kitten', 'args'))
def message_to_kitty(self, global_opts: RCOptions, opts: 'CLIOptions', args: ArgsType) -> PayloadType:
if len(args) < 1:

View File

@@ -67,7 +67,7 @@ type=bool-set
If specified the tab containing the window this command is run in is used
instead of the active tab
''' + '\n\n' + launch_options_spec().replace(':option:`launch', ':option:`kitty @ launch')
argspec = '[CMD ...]'
args = RemoteCommand.Args(spec='[CMD ...]', json_field='args')
def message_to_kitty(self, global_opts: RCOptions, opts: 'CLIOptions', args: ArgsType) -> PayloadType:
ans = {'args': args or []}

View File

@@ -36,8 +36,6 @@ type=bool-set
Show all environment variables in output, not just differing ones.
'''
argspec = ''
def message_to_kitty(self, global_opts: RCOptions, opts: 'CLIOptions', args: ArgsType) -> PayloadType:
return {'all_env_vars': opts.all_env_vars}

View File

@@ -74,7 +74,7 @@ Don't wait for a response giving the id of the newly opened window. Note that
using this option means that you will not be notified of failures and that
the id of the new window will not be printed out.
'''
argspec = '[CMD ...]'
args = RemoteCommand.Args(spec='[CMD ...]', json_field='args')
def message_to_kitty(self, global_opts: RCOptions, opts: 'CLIOptions', args: ArgsType) -> PayloadType:
ans = {'args': args or [], 'type': 'window'}

View File

@@ -26,7 +26,6 @@ class RemoveMarker(RemoteCommand):
type=bool-set
Apply marker to the window this command is run in, rather than the active window.
'''
argspec = ''
def message_to_kitty(self, global_opts: RCOptions, opts: 'CLIOptions', args: ArgsType) -> PayloadType:
return {'match': opts.match, 'self': opts.self}

View File

@@ -71,7 +71,6 @@ default=false
Don't wait for a response indicating the success of the action. Note that
using this option means that you will not be notified of failures.
'''
argspec = ''
def message_to_kitty(self, global_opts: RCOptions, opts: 'CLIOptions', args: ArgsType) -> PayloadType:
return {

View File

@@ -46,7 +46,6 @@ The special value :code:`reset` will reset the layout to its default configurati
type=bool-set
Resize the window this command is run in, rather than the active window.
'''
argspec = ''
string_return_is_error = True
def message_to_kitty(self, global_opts: RCOptions, opts: 'CLIOptions', args: ArgsType) -> PayloadType:

View File

@@ -31,7 +31,6 @@ class ScrollWindow(RemoteCommand):
' will scroll up 2 pages and :code:`0.5p`will scroll down half page. :code:`3u` will *unscroll* by 3 lines, which means that 3 lines will move from the'
' scrollback buffer onto the top of the screen.'
)
argspec = 'SCROLL_AMOUNT'
options_spec = MATCH_WINDOW_OPTION + '''\n
--no-response
type=bool-set
@@ -39,6 +38,7 @@ default=false
Don't wait for a response indicating the success of the action. Note that
using this option means that you will not be notified of failures.
'''
args = RemoteCommand.Args(spec='SCROLL_AMOUNT', count=1, special_parse='parse_scroll_amount(args[0])', json_field='amount')
def message_to_kitty(self, global_opts: RCOptions, opts: 'CLIOptions', args: ArgsType) -> PayloadType:
if len(args) < 1:

View File

@@ -89,8 +89,7 @@ type=bool-set
Restore all colors to the values they had at kitty startup. Note that if you specify
this option, any color arguments are ignored and :option:`kitty @ set-colors --configured` and :option:`kitty @ set-colors --all` are implied.
''' + '\n\n' + MATCH_WINDOW_OPTION + '\n\n' + MATCH_TAB_OPTION.replace('--match -m', '--match-tab -t')
argspec = 'COLOR_OR_FILE ...'
args_completion = {'files': ('CONF files', ('*.conf',))}
args = RemoteCommand.Args(spec='COLOR_OR_FILE ...', completion={'files': ('CONF files', ('*.conf',))})
def message_to_kitty(self, global_opts: RCOptions, opts: 'CLIOptions', args: ArgsType) -> PayloadType:
final_colors: Dict[str, Optional[int]] = {}

View File

@@ -21,7 +21,7 @@ if TYPE_CHECKING:
class SetWindowLogo(RemoteCommand):
protocol_spec = __doc__ = '''
data+/str: Chunk of at most 512 bytes of PNG data, base64 encoded. Must send an empty chunk to indicate end of image. \
data+/str: Chunk of PNG data, base64 encoded no more than 2048 bytes. Must send an empty chunk to indicate end of image. \
Or the special value :code:`-` to indicate image must be removed.
position/str: The logo position as a string, empty string means default
alpha/float: The logo alpha between :code:`0` and :code:`1`. :code:`-1` means use default
@@ -59,9 +59,8 @@ default=false
Don't wait for a response from kitty. This means that even if setting the image
failed, the command will exit with a success code.
'''
argspec = 'PATH_TO_PNG_IMAGE'
args_count = 1
args_completion = {'files': ('PNG Images', ('*.png',))}
args = RemoteCommand.Args(spec='PATH_TO_PNG_IMAGE', count=1, json_field='data', special_parse='!read_window_logo(args[0])', completion={
'files': ('PNG Images', ('*.png',))})
images_in_flight: Dict[str, IO[bytes]] = {}
is_asynchronous = True

View File

@@ -33,7 +33,7 @@ type=bool-set
By default, the title will be permanently changed and programs running in the window will not be able to change it
again. If you want to allow other programs to change it afterwards, use this option.
''' + '\n\n' + MATCH_WINDOW_OPTION
argspec = '[TITLE ...]'
args = RemoteCommand.Args(json_field='title', spec='[TITLE ...]')
def message_to_kitty(self, global_opts: RCOptions, opts: 'CLIOptions', args: ArgsType) -> PayloadType:
ans = {'match': opts.match, 'temporary': opts.temporary}

View File

@@ -15,7 +15,7 @@ if TYPE_CHECKING:
class SignalChild(RemoteCommand):
protocol_spec = __doc__ = '''
signals/list.str: The signals, a list of names, such as :code:`SIGTERM`, :code:`SIGKILL`, :code:`SIGUSR1`, etc.
signals+/list.str: The signals, a list of names, such as :code:`SIGTERM`, :code:`SIGKILL`, :code:`SIGUSR1`, etc.
match/str: Which windows to send the signals to
'''
@@ -35,7 +35,7 @@ default=false
Don't wait for a response indicating the success of the action. Note that
using this option means that you will not be notified of failures.
''' + '\n\n' + MATCH_WINDOW_OPTION
argspec = '[SIGNAL_NAME ...]'
args = RemoteCommand.Args(json_field='signals', spec='[SIGNAL_NAME ...]', value_if_unspecified=('SIGINT',))
def message_to_kitty(self, global_opts: RCOptions, opts: 'CLIOptions', args: ArgsType) -> PayloadType:
# defaults to signal the window this command is run in