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
No known key found for this signature in database
GPG Key ID: 06BC317B515ACE7C
27 changed files with 487 additions and 157 deletions

View File

@ -7,6 +7,7 @@ from contextlib import suppress
from kitty.cli import parse_args
from kitty.constants import cache_dir
from kitty.cli_stub import AskCLIOptions
from ..tui.operations import alternate_screen, styled
from ..tui.handler import result_handler
@ -89,7 +90,7 @@ def main(args):
from kitty.shell import init_readline
msg = 'Ask the user for input'
try:
args, items = parse_args(args[1:], option_text, '', msg, 'kitty ask')
args, items = parse_args(args[1:], option_text, '', msg, 'kitty ask', result_class=AskCLIOptions)
except SystemExit as e:
if e.code != 0:
print(e.args[0])

View File

@ -6,6 +6,7 @@ import os
import sys
from kitty.cli import parse_args
from kitty.cli_stub import ClipboardCLIOptions
from ..tui.handler import Handler
from ..tui.loop import Loop
@ -81,7 +82,7 @@ usage = ''
def main(args):
args, items = parse_args(args[1:], OPTIONS, usage, help_text, 'kitty +kitten clipboard')
args, items = parse_args(args[1:], OPTIONS, usage, help_text, 'kitty +kitten clipboard', result_class=ClipboardCLIOptions)
if items:
raise SystemExit('Unrecognized extra command line arguments')
data = None

View File

@ -12,7 +12,7 @@ from kitty.conf.definition import config_lines
from kitty.constants import config_dir
from kitty.rgb import color_as_sgr
from .config_data import type_map, all_options
from .config_data import type_convert, all_options
defaults = None
@ -89,7 +89,7 @@ def parse_config(lines, check_keys=True):
parse_config_base(
lines,
defaults,
type_map,
type_convert,
special_handling,
ans,
check_keys=check_keys

View File

@ -3,10 +3,10 @@
# License: GPL v3 Copyright: 2018, Kovid Goyal <kovid at kovidgoyal.net>
from functools import partial
# Utils {{{
from functools import partial
from gettext import gettext as _
from typing import Dict, Union
from typing import Any, Dict, Union
from kitty.conf.definition import Option, Shortcut, option_func
from kitty.conf.utils import positive_int, python_string, to_color
@ -120,4 +120,9 @@ k('prev_match', '<', 'scroll_to prev-match', _('Scroll to previous search match'
k('search_forward_simple', 'f', 'start_search substring forward', _('Search forward (no regex)'))
k('search_backward_simple', 'b', 'start_search substring backward', _('Search backward (no regex)'))
type_map = {o.name: o.option_type for o in all_options.values() if isinstance(o, Option)}
def type_convert(name: str, val: Any) -> Any:
o = all_options.get(name)
if isinstance(o, Option):
val = o.option_type(val)
return val

View File

@ -15,6 +15,7 @@ from functools import partial
from gettext import gettext as _
from kitty.cli import CONFIG_HELP, parse_args
from kitty.cli_stub import DiffCLIOptions
from kitty.constants import appname
from kitty.fast_data_types import wcswidth
from kitty.key_encoding import RELEASE, enter_key, key_defs as K
@ -542,7 +543,7 @@ def get_remote_file(path):
def main(args):
warnings.showwarning = showwarning
args, items = parse_args(args[1:], OPTIONS, usage, help_text, 'kitty +kitten diff')
args, items = parse_args(args[1:], OPTIONS, usage, help_text, 'kitty +kitten diff', result_class=DiffCLIOptions)
if len(items) != 2:
raise SystemExit('You must specify exactly two files/directories to compare')
left, right = items

View File

@ -11,6 +11,7 @@ from gettext import gettext as _
from itertools import repeat
from kitty.cli import parse_args
from kitty.cli_stub import HintsCLIOptions
from kitty.fast_data_types import set_clipboard_string
from kitty.key_encoding import key_defs as K, backspace_key, enter_key
from kitty.utils import screen_size_function
@ -508,7 +509,7 @@ usage = ''
def parse_hints_args(args):
return parse_args(args, OPTIONS, usage, help_text, 'kitty +kitten hints')
return parse_args(args, OPTIONS, usage, help_text, 'kitty +kitten hints', result_class=HintsCLIOptions)
def main(args):

View File

@ -14,6 +14,7 @@ from math import ceil
from tempfile import NamedTemporaryFile
from kitty.cli import parse_args
from kitty.cli_stub import IcatCLIOptions
from kitty.constants import appname
from kitty.utils import TTYIO, fit_image, screen_size_function
@ -343,7 +344,7 @@ def process_single_item(item, args, url_pat=None, maybe_dir=True):
def main(args=sys.argv):
args, items = parse_args(args[1:], options_spec, usage, help_text, '{} +kitten icat'.format(appname))
args, items = parse_args(args[1:], options_spec, usage, help_text, '{} +kitten icat'.format(appname), result_class=IcatCLIOptions)
if args.print_window_size:
screen_size_function.ans = None

View File

@ -8,6 +8,7 @@ import subprocess
import sys
from kitty.cli import parse_args
from kitty.cli_stub import PanelCLIOptions
from kitty.constants import is_macos
OPTIONS = r'''
@ -48,7 +49,7 @@ usage = 'program-to-run'
def parse_panel_args(args):
return parse_args(args, OPTIONS, usage, help_text, 'kitty +kitten panel')
return parse_args(args, OPTIONS, usage, help_text, 'kitty +kitten panel', result_class=PanelCLIOptions)
def call_xprop(*cmd, silent=False):

View File

@ -6,6 +6,7 @@
import sys
from kitty.cli import parse_args
from kitty.cli_stub import ResizeCLIOptions
from kitty.cmds import cmap, parse_subcommand_cli
from kitty.constants import version
from kitty.key_encoding import CTRL, RELEASE, key_defs as K
@ -119,7 +120,7 @@ The base vertical increment.
def main(args):
msg = 'Resize the current window'
try:
args, items = parse_args(args[1:], OPTIONS, '', msg, 'resize_window')
args, items = parse_args(args[1:], OPTIONS, '', msg, 'resize_window', result_class=ResizeCLIOptions)
except SystemExit as e:
if e.code != 0:
print(e.args[0], file=sys.stderr)

View File

@ -7,6 +7,7 @@ import sys
from contextlib import suppress
from kitty.cli import parse_args
from kitty.cli_stub import ErrorCLIOptions
from ..tui.operations import styled
@ -19,7 +20,7 @@ The title for the error message.
def real_main(args):
msg = 'Show an error message'
args, items = parse_args(args[1:], OPTIONS, '', msg, 'hints')
args, items = parse_args(args[1:], OPTIONS, '', msg, 'hints', result_class=ErrorCLIOptions)
error_message = sys.stdin.buffer.read().decode('utf-8')
sys.stdin = open(os.ctermid())
print(styled(args.title, fg_intense=True, fg='red', bold=True))

View File

@ -11,6 +11,7 @@ from functools import lru_cache
from gettext import gettext as _
from kitty.cli import parse_args
from kitty.cli_stub import UnicodeCLIOptions
from kitty.config import cached_values_for
from kitty.constants import config_dir
from kitty.fast_data_types import is_emoji_presentation_base, wcswidth
@ -530,7 +531,7 @@ default form specified in the unicode standard for the symbol is used.
def parse_unicode_input_args(args):
return parse_args(args, OPTIONS, usage, help_text, 'kitty +kitten unicode_input')
return parse_args(args, OPTIONS, usage, help_text, 'kitty +kitten unicode_input', result_class=UnicodeCLIOptions)
def main(args):

View File

@ -304,8 +304,9 @@ class Boss:
else:
msg = json.loads(msg)
if isinstance(msg, dict) and msg.get('cmd') == 'new_instance':
from .cli_stub import CLIOptions
startup_id = msg.get('startup_id')
args, rest = parse_args(msg['args'][1:])
args, rest = parse_args(msg['args'][1:], result_class=CLIOptions)
args.args = rest
opts = create_opts(args)
if not os.path.isabs(args.directory):

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)

64
kitty/cli_stub.py Normal file
View File

@ -0,0 +1,64 @@
#!/usr/bin/env python
# vim:fileencoding=utf-8
# License: GPLv3 Copyright: 2020, Kovid Goyal <kovid at kovidgoyal.net>
class CLIOptions:
pass
LaunchCLIOptions = AskCLIOptions = ClipboardCLIOptions = DiffCLIOptions = CLIOptions
HintsCLIOptions = IcatCLIOptions = PanelCLIOptions = ResizeCLIOptions = CLIOptions
ErrorCLIOptions = UnicodeCLIOptions = CLIOptions
def generate_stub() -> None:
from .cli import parse_option_spec, as_type_stub
from .conf.definition import save_type_stub
text = 'import typing\n\n\n'
def do(otext=None, cls: str = 'CLIOptions'):
nonlocal text
text += as_type_stub(*parse_option_spec(otext), class_name=cls)
do()
from .launch import options_spec
do(options_spec(), 'LaunchCLIOptions')
from kittens.ask.main import option_text
do(option_text(), 'AskCLIOptions')
from kittens.clipboard.main import OPTIONS
do(OPTIONS(), 'ClipboardCLIOptions')
from kittens.diff.main import OPTIONS
do(OPTIONS(), 'DiffCLIOptions')
from kittens.hints.main import OPTIONS
do(OPTIONS(), 'HintsCLIOptions')
from kittens.icat.main import OPTIONS
do(OPTIONS, 'IcatCLIOptions')
from kittens.panel.main import OPTIONS
do(OPTIONS(), 'PanelCLIOptions')
from kittens.resize_window.main import OPTIONS
do(OPTIONS(), 'ResizeCLIOptions')
from kittens.show_error.main import OPTIONS
do(OPTIONS(), 'ErrorCLIOptions')
from kittens.unicode_input.main import OPTIONS
do(OPTIONS(), 'UnicodeCLIOptions')
save_type_stub(text, __file__)
if __name__ == '__main__':
import subprocess
subprocess.Popen([
'kitty', '+runpy',
'from kitty.cli_stub import generate_stub; generate_stub()'
])

118
kitty/cli_stub.pyi Normal file
View File

@ -0,0 +1,118 @@
# Update this file by running: python kitty/cli_stub.py
import typing
class CLIOptions:
cls: str
name: str
title: str
config: typing.Sequence[str]
override: typing.Sequence[str]
directory: str
detach: bool
session: str
hold: bool
single_instance: bool
instance_group: str
wait_for_single_instance_window_close: bool
listen_on: str
start_as: str
version: bool
dump_commands: bool
replay_commands: str
dump_bytes: str
debug_gl: bool
debug_keyboard: bool
debug_font_fallback: bool
debug_config: bool
execute: bool
class LaunchCLIOptions:
window_title: str
tab_title: str
type: str
keep_focus: bool
cwd: str
env: typing.Sequence[str]
copy_colors: bool
copy_cmdline: bool
copy_env: bool
location: str
allow_remote_control: bool
stdin_source: str
stdin_add_formatting: bool
stdin_add_line_wrap_markers: bool
marker: str
class AskCLIOptions:
type: str
message: str
name: str
class ClipboardCLIOptions:
get_clipboard: bool
use_primary: bool
wait_for_completion: bool
class DiffCLIOptions:
context: int
config: typing.Sequence[str]
override: typing.Sequence[str]
class HintsCLIOptions:
program: typing.Sequence[str]
type: str
regex: str
linenum_action: str
url_prefixes: str
word_characters: str
minimum_match_length: int
multiple: bool
multiple_joiner: str
add_trailing_space: str
hints_offset: int
alphabet: str
ascending: bool
customize_processing: str
class IcatCLIOptions:
align: str
place: str
scale_up: bool
clear: bool
transfer_mode: str
detect_support: bool
detection_timeout: float
print_window_size: bool
stdin: str
silent: bool
z_index: str
class PanelCLIOptions:
lines: int
columns: int
edge: str
config: typing.Sequence[str]
override: typing.Sequence[str]
class ResizeCLIOptions:
horizontal_increment: int
vertical_increment: int
class ErrorCLIOptions:
title: str
class UnicodeCLIOptions:
emoji_variation: str

View File

@ -9,7 +9,7 @@ from contextlib import suppress
from typing import Any, BinaryIO, Callable, Dict, List, Optional
from .cli import (
Namespace, get_defaults_from_seq, parse_args, parse_option_spec
get_defaults_from_seq, parse_args, parse_option_spec
)
from .config import parse_config, parse_send_text_bytes
from .constants import appname
@ -42,7 +42,7 @@ class UnknownLayout(ValueError):
hide_traceback = True
CommandFunction = Callable[[Namespace, Namespace, List[str]], Optional[Dict[str, Any]]]
CommandFunction = Callable[[Any, Any, List[str]], Optional[Dict[str, Any]]]
cmap: Dict[str, CommandFunction] = {}
@ -906,13 +906,16 @@ def cmd_launch(global_opts, opts, args):
return ans
class GlobalOpts:
pass
def launch(boss, window, payload):
pg = cmd_launch.payload_get
default_opts = parse_launch_args()[0]
opts = {}
opts = GlobalOpts()
for key, default_value in default_opts.__dict__.items():
opts[key] = payload.get(key, default_value)
opts = Namespace(opts)
setattr(opts, key, payload.get(key, default_value))
match = pg(payload, 'match')
if match:
tabs = tuple(boss.match_tabs(match))
@ -1444,8 +1447,12 @@ def cli_params_for(func):
return (func.options_spec or '\n').format, func.argspec, func.desc, '{} @ {}'.format(appname, func.name)
class SubCommandOptions:
pass
def parse_subcommand_cli(func, args):
opts, items = parse_args(args[1:], *cli_params_for(func))
opts, items = parse_args(args[1:], *cli_params_for(func), result_class=SubCommandOptions)
if func.args_count is not None and func.args_count != len(items):
if func.args_count == 0:
raise SystemExit('Unknown extra argument(s) supplied to {}'.format(func.name))
@ -1455,4 +1462,4 @@ def parse_subcommand_cli(func, args):
def display_subcommand_help(func):
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.argspec, func.desc, func.name, result_class=SubCommandOptions)

View File

@ -4,7 +4,9 @@
import re
from functools import partial
from typing import Any, Dict, List, Set, Tuple, Union, get_type_hints, Optional
from typing import (
Any, Dict, Iterator, List, Optional, Set, Tuple, Union, get_type_hints
)
from .utils import to_bool
@ -26,7 +28,7 @@ class Option:
__slots__ = 'name', 'group', 'long_text', 'option_type', 'defval_as_string', 'add_to_default', 'add_to_docs', 'line'
def __init__(self, name, group, defval, option_type, long_text, add_to_default, add_to_docs):
def __init__(self, name: str, group: str, defval: str, option_type: Any, long_text: str, add_to_default: bool, add_to_docs: bool):
self.name, self.group = name, group
self.long_text, self.option_type = long_text.strip(), option_type
self.defval_as_string = defval
@ -158,8 +160,8 @@ def remove_markup(text):
return re.sub(r':([a-zA-Z0-9]+):`(.+?)`', sub, text, flags=re.DOTALL)
def iter_blocks(lines):
current_block = []
def iter_blocks(lines: Iterator[str]):
current_block: List[str] = []
prev_indent = 0
for line in lines:
indent_size = len(line) - len(line.lstrip())
@ -180,9 +182,10 @@ def wrapped_block(lines):
wrapper = getattr(wrapped_block, 'wrapper', None)
if wrapper is None:
import textwrap
wrapper = wrapped_block.wrapper = textwrap.TextWrapper(
wrapper = textwrap.TextWrapper(
initial_indent='#: ', subsequent_indent='#: ', width=70, break_long_words=False
)
setattr(wrapped_block, 'wrapper', wrapper)
for block, indent_size in iter_blocks(lines):
if indent_size > 0:
for line in block:
@ -278,9 +281,9 @@ def as_conf_file(all_options):
else:
count += 1
else:
if start is not None:
if start is not None and count is not None:
map_groups.append((start, count))
start = None
start = count = None
for start, count in map_groups:
r = range(start, start + count)
sz = max(len(ans[i].split(' ', 3)[1]) for i in r)
@ -304,8 +307,13 @@ def config_lines(all_options):
yield sc.line
def as_type_stub(all_options: Dict[str, Union[Option, List[Shortcut]]], special_types: Optional[Dict[str, str]] = None) -> str:
ans = ['import typing\n', '', 'class Options:']
def as_type_stub(
all_options: Dict[str, Union[Option, List[Shortcut]]],
special_types: Optional[Dict[str, str]] = None,
preamble_lines: Union[Tuple[str, ...], List[str], Iterator[str]] = (),
extra_fields: Union[Tuple[Tuple[str, str], ...], List[Tuple[str, str]], Iterator[Tuple[str, str]]] = ()
) -> str:
ans = ['import typing\n'] + list(preamble_lines) + ['', 'class Options:']
imports: Set[Tuple[str, str]] = set()
overrides = special_types or {}
for name, val in all_options.items():
@ -316,4 +324,16 @@ def as_type_stub(all_options: Dict[str, Union[Option, List[Shortcut]]], special_
for mod, name in imports:
ans.insert(0, 'from {} import {}'.format(mod, name))
ans.insert(0, 'import {}'.format(mod))
for field_name, type_def in extra_fields:
ans.append(' {}: {}'.format(field_name, type_def))
return '\n'.join(ans)
def save_type_stub(text: str, fpath: str) -> None:
import os
with open(fpath + 'i', 'w') as f:
print(
'# Update this file by running: python {}'.format(os.path.relpath(os.path.abspath(fpath))),
file=f
)
f.write(text)

View File

@ -73,7 +73,9 @@ def choices(*choices) -> Callable[[str], str]:
def parse_line(
line: str, type_map: Dict[str, Any], special_handling: Callable,
line: str,
type_convert: Callable[[str, Any], Any],
special_handling: Callable,
ans: Dict[str, Any], all_keys: Optional[FrozenSet[str]],
base_path_for_includes: str
) -> None:
@ -93,7 +95,7 @@ def parse_line(
val = os.path.join(base_path_for_includes, val)
try:
with open(val, encoding='utf-8', errors='replace') as include:
_parse(include, type_map, special_handling, ans, all_keys)
_parse(include, type_convert, special_handling, ans, all_keys)
except FileNotFoundError:
log_error(
'Could not find included config file: {}, ignoring'.
@ -108,15 +110,12 @@ def parse_line(
if all_keys is not None and key not in all_keys:
log_error('Ignoring unknown config key: {}'.format(key))
return
tm = type_map.get(key)
if tm is not None:
val = tm(val)
ans[key] = val
ans[key] = type_convert(key, val)
def _parse(
lines: Iterator[str],
type_map: Dict[str, Any],
type_convert: Callable[[str, Any], Any],
special_handling: Callable,
ans: Dict[str, Any],
all_keys: Optional[FrozenSet[str]],
@ -131,7 +130,7 @@ def _parse(
for i, line in enumerate(lines):
try:
parse_line(
line, type_map, special_handling, ans, all_keys,
line, type_convert, special_handling, ans, all_keys,
base_path_for_includes
)
except Exception as e:
@ -143,7 +142,7 @@ def _parse(
def parse_config_base(
lines: Iterator[str],
defaults: Any,
type_map: Dict[str, Any],
type_convert: Callable[[str, Any], Any],
special_handling: Callable,
ans: Dict[str, Any],
check_keys=True,
@ -151,7 +150,7 @@ def parse_config_base(
):
all_keys: Optional[FrozenSet[str]] = defaults._asdict() if check_keys else None
_parse(
lines, type_map, special_handling, ans, all_keys, accumulate_bad_lines
lines, type_convert, special_handling, ans, all_keys, accumulate_bad_lines
)

View File

@ -7,9 +7,9 @@ import os
import re
import sys
from collections import namedtuple
from contextlib import contextmanager
from contextlib import suppress
from contextlib import contextmanager, suppress
from functools import partial
from typing import Type
from . import fast_data_types as defines
from .conf.definition import as_conf_file, config_lines
@ -17,10 +17,11 @@ from .conf.utils import (
init_config, key_func, load_config as _load_config, merge_dicts,
parse_config_base, python_string, to_bool, to_cmdline
)
from .config_data import all_options, parse_mods, type_map
from .config_data import all_options, parse_mods, type_convert
from .constants import cache_dir, defconf, is_macos
from .utils import log_error
from .key_names import get_key_name_lookup, key_name_aliases
from .options_stub import Options as OptionsStub
from .utils import log_error
def parse_shortcut(sc):
@ -575,9 +576,6 @@ def special_handling(key, val, ans):
return True
defaults = None
def option_names_for_completion():
yield from defaults
yield from special_handlers
@ -585,10 +583,15 @@ def option_names_for_completion():
def parse_config(lines, check_keys=True, accumulate_bad_lines=None):
ans = {'symbol_map': {}, 'keymap': {}, 'sequence_map': {}, 'key_definitions': [], 'env': {}, 'kitten_aliases': {}, 'font_features': {}}
if check_keys:
defs = defaults
else:
defs = None
parse_config_base(
lines,
defaults,
type_map,
defs,
type_convert,
special_handling,
ans,
check_keys=check_keys,
@ -602,7 +605,9 @@ def parse_defaults(lines, check_keys=False):
return ans
Options, defaults = init_config(config_lines(all_options), parse_defaults)
xc = init_config(config_lines(all_options), parse_defaults)
Options: Type[OptionsStub] = xc[0]
defaults: OptionsStub = xc[1]
actions = frozenset(all_key_actions) | frozenset(
'run_simple_kitten combine send_text goto_tab goto_layout set_font_size new_tab_with_cwd new_window_with_cwd new_os_window_with_cwd'.
split()
@ -763,7 +768,7 @@ def finalize_keys(opts):
opts.sequence_map = sequence_map
def load_config(*paths, overrides=None, accumulate_bad_lines=None):
def load_config(*paths, overrides=None, accumulate_bad_lines=None) -> OptionsStub:
parser = parse_config
if accumulate_bad_lines is not None:
parser = partial(parse_config, accumulate_bad_lines=accumulate_bad_lines)

View File

@ -6,7 +6,7 @@
import os
from gettext import gettext as _
from typing import (
Dict, FrozenSet, Iterable, List, Optional, Set, Tuple, TypeVar, Union
Any, Dict, FrozenSet, Iterable, List, Optional, Set, Tuple, TypeVar, Union
)
from . import fast_data_types as defines
@ -1432,4 +1432,9 @@ the line (same as pressing the Home key)::
# }}}
# }}}
type_map = {o.name: o.option_type for o in all_options.values() if isinstance(o, Option)}
def type_convert(name: str, val: Any) -> Any:
o = all_options.get(name)
if isinstance(o, Option):
val = o.option_type(val)
return val

View File

@ -1,9 +1,10 @@
from typing import Any, Callable, Dict, List, NewType, Optional, Tuple, Union
from kitty.boss import Boss
from kitty.cli import Namespace
from kitty.options_stub import Options
# Constants {{{
KITTY_VCS_REV: str
ERROR_PREFIX: str
GLSL_VERSION: int
GLFW_IBEAM_CURSOR: int
@ -339,6 +340,10 @@ def redirect_std_streams(devnull: str) -> None:
pass
def glfw_get_key_name(key: int, native_key: int) -> Optional[str]:
pass
StartupCtx = NewType('StartupCtx', int)
Display = NewType('Display', int)
@ -468,7 +473,7 @@ def update_window_visibility(os_window_id: int, tab_id: int, window_id: int, win
def set_options(
opts: Namespace,
opts: Options,
is_wayland: bool = False,
debug_gl: bool = False,
debug_font_fallback: bool = False

View File

@ -4,10 +4,11 @@
from functools import lru_cache
from typing import Dict
from typing import Dict, List, Optional, Sequence, Tuple
from .child import Child
from .cli import parse_args
from .cli_stub import LaunchCLIOptions
from .fast_data_types import set_clipboard_string
from .utils import set_primary_selection
@ -130,16 +131,16 @@ the same as for the :code:`toggle_marker` map action (see :doc:`/marks`).
'''
def parse_launch_args(args=None):
def parse_launch_args(args: Optional[Sequence[str]] = None) -> Tuple[LaunchCLIOptions, List[str]]:
args = list(args or ())
try:
opts, args = parse_args(args=args, ospec=options_spec)
opts, args = parse_args(result_class=LaunchCLIOptions, args=args, ospec=options_spec)
except SystemExit as e:
raise ValueError from e
return opts, args
def get_env(opts, active_child: Child) -> Dict[str, str]:
def get_env(opts: LaunchCLIOptions, active_child: Child) -> Dict[str, str]:
env: Dict[str, str] = {}
if opts.copy_env and active_child:
env.update(active_child.foreground_environ)
@ -150,7 +151,7 @@ def get_env(opts, active_child: Child) -> Dict[str, str]:
return env
def tab_for_window(boss, opts, target_tab=None):
def tab_for_window(boss, opts: LaunchCLIOptions, target_tab=None):
if opts.type == 'tab':
tm = boss.active_tab_manager
tab = tm.new_tab(empty_tab=True, location=opts.location)
@ -168,7 +169,7 @@ def tab_for_window(boss, opts, target_tab=None):
return tab
def launch(boss, opts, args, target_tab=None):
def launch(boss, opts: LaunchCLIOptions, args: List[str], target_tab=None):
active = boss.active_window_for_cwd
active_child = getattr(active, 'child', None)
env = get_env(opts, active_child)

View File

@ -12,6 +12,7 @@ from .borders import load_borders_program
from .boss import Boss
from .child import set_default_env
from .cli import create_opts, parse_args
from .cli_stub import CLIOptions
from .config import cached_values_for, initial_window_size_func
from .constants import (
appname, beam_cursor_data_file, config_dir, glfw_path, is_macos,
@ -280,7 +281,7 @@ def _main():
cwd_ok = False
if not cwd_ok:
os.chdir(os.path.expanduser('~'))
args, rest = parse_args(args=args)
args, rest = parse_args(result_class=CLIOptions, args=args)
args.args = rest
if args.debug_config:
create_opts(args, debug_config=True)

View File

@ -2,8 +2,6 @@
# vim:fileencoding=utf-8
# License: GPLv3 Copyright: 2020, Kovid Goyal <kovid at kovidgoyal.net>
import os
class Options:
pass
@ -11,19 +9,23 @@ class Options:
def generate_stub():
from .config_data import all_options
from .conf.definition import as_type_stub
from .conf.definition import as_type_stub, save_type_stub
text = as_type_stub(
all_options,
special_types={
'symbol_map': 'typing.Dict[typing.Tuple[int, int], str]'
}
)
with open(__file__ + 'i', 'w') as f:
print(
'# Update this file by running: python {}'.format(os.path.relpath(os.path.abspath(__file__))),
file=f
},
preamble_lines=(
'from kitty.config import KeyAction',
'KeySpec = typing.Tuple[int, bool, int]',
'KeyMap = typing.Dict[KeySpec, KeyAction]',
),
extra_fields=(
('keymap', 'KeyMap'),
('sequence_map', 'typing.Dict[KeySpec, KeyMap]'),
)
f.write(text)
)
save_type_stub(text, __file__)
if __name__ == '__main__':

View File

@ -3,6 +3,9 @@ import kitty.rgb
from kitty.rgb import Color
import typing
from kitty.config import KeyAction
KeySpec = typing.Tuple[int, bool, int]
KeyMap = typing.Dict[KeySpec, KeyAction]
class Options:
font_family: str
@ -380,4 +383,6 @@ class Options:
linux_display_server: str
kitty_mod: int
clear_all_shortcuts: bool
kitten_alias: str
kitten_alias: str
keymap: KeyMap
sequence_map: typing.Dict[KeySpec, KeyMap]

View File

@ -138,13 +138,17 @@ cli_msg = (
).format(appname=appname)
class RCOptions:
pass
def parse_rc_args(args):
cmds = (' :green:`{}`\n {}'.format(cmap[c].name, cmap[c].short_desc) for c in all_commands)
msg = cli_msg + (
'\n\n:title:`Commands`:\n{cmds}\n\n'
'You can get help for each individual command by using:\n'
'{appname} @ :italic:`command` -h').format(appname=appname, cmds='\n'.join(cmds))
return parse_args(args[1:], global_options_spec, 'command ...', msg, '{} @'.format(appname))
return parse_args(args[1:], global_options_spec, 'command ...', msg, '{} @'.format(appname), result_class=RCOptions)
def main(args):

View File

@ -15,3 +15,11 @@ multi_line_output = 5
[mypy]
files = kitty,kittens,glfw/glfw.py,*.py
[mypy-kitty.conf.*]
check_untyped_defs = True
follow_imports = silent
[mypy-kitty.cli]
check_untyped_defs = True
follow_imports = silent