diff --git a/kittens/ask/main.py b/kittens/ask/main.py index a77357662..13206edf6 100644 --- a/kittens/ask/main.py +++ b/kittens/ask/main.py @@ -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]) diff --git a/kittens/clipboard/main.py b/kittens/clipboard/main.py index 0e2d53ca7..777fd1007 100644 --- a/kittens/clipboard/main.py +++ b/kittens/clipboard/main.py @@ -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 diff --git a/kittens/diff/config.py b/kittens/diff/config.py index 7bdbb8d1d..0329ed8d6 100644 --- a/kittens/diff/config.py +++ b/kittens/diff/config.py @@ -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 diff --git a/kittens/diff/config_data.py b/kittens/diff/config_data.py index ee578169c..01460c39f 100644 --- a/kittens/diff/config_data.py +++ b/kittens/diff/config_data.py @@ -3,10 +3,10 @@ # License: GPL v3 Copyright: 2018, Kovid Goyal -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 diff --git a/kittens/diff/main.py b/kittens/diff/main.py index abfa3c166..6f0e741ca 100644 --- a/kittens/diff/main.py +++ b/kittens/diff/main.py @@ -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 diff --git a/kittens/hints/main.py b/kittens/hints/main.py index bb63683de..f09472ca6 100644 --- a/kittens/hints/main.py +++ b/kittens/hints/main.py @@ -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): diff --git a/kittens/icat/main.py b/kittens/icat/main.py index 221d3fdb2..aaefe3eed 100755 --- a/kittens/icat/main.py +++ b/kittens/icat/main.py @@ -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 diff --git a/kittens/panel/main.py b/kittens/panel/main.py index 7e7abe3e9..6ca938692 100644 --- a/kittens/panel/main.py +++ b/kittens/panel/main.py @@ -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): diff --git a/kittens/resize_window/main.py b/kittens/resize_window/main.py index 2bf6a20bf..fbfccb0a3 100644 --- a/kittens/resize_window/main.py +++ b/kittens/resize_window/main.py @@ -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) diff --git a/kittens/show_error/main.py b/kittens/show_error/main.py index 933386c01..8bb7d011a 100644 --- a/kittens/show_error/main.py +++ b/kittens/show_error/main.py @@ -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)) diff --git a/kittens/unicode_input/main.py b/kittens/unicode_input/main.py index 6b2254a5b..ee2d38b61 100644 --- a/kittens/unicode_input/main.py +++ b/kittens/unicode_input/main.py @@ -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): diff --git a/kitty/boss.py b/kitty/boss.py index a2e6117b1..1cb54e9b0 100644 --- a/kitty/boss.py +++ b/kitty/boss.py @@ -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): diff --git a/kitty/cli.py b/kitty/cli.py index 8c0f1fa94..72f279846 100644 --- a/kitty/cli.py +++ b/kitty/cli.py @@ -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) diff --git a/kitty/cli_stub.py b/kitty/cli_stub.py new file mode 100644 index 000000000..15d2b32c7 --- /dev/null +++ b/kitty/cli_stub.py @@ -0,0 +1,64 @@ +#!/usr/bin/env python +# vim:fileencoding=utf-8 +# License: GPLv3 Copyright: 2020, Kovid Goyal + + +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()' + ]) diff --git a/kitty/cli_stub.pyi b/kitty/cli_stub.pyi new file mode 100644 index 000000000..77230f6d7 --- /dev/null +++ b/kitty/cli_stub.pyi @@ -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 + + diff --git a/kitty/cmds.py b/kitty/cmds.py index 15f7385d1..ebcb3a884 100644 --- a/kitty/cmds.py +++ b/kitty/cmds.py @@ -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) diff --git a/kitty/conf/definition.py b/kitty/conf/definition.py index 50ad2d68f..958030b06 100644 --- a/kitty/conf/definition.py +++ b/kitty/conf/definition.py @@ -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) diff --git a/kitty/conf/utils.py b/kitty/conf/utils.py index ba2e61c78..281a4546d 100644 --- a/kitty/conf/utils.py +++ b/kitty/conf/utils.py @@ -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 ) diff --git a/kitty/config.py b/kitty/config.py index b955edeb9..473265740 100644 --- a/kitty/config.py +++ b/kitty/config.py @@ -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) diff --git a/kitty/config_data.py b/kitty/config_data.py index 846fb83f7..5b5e22fe3 100644 --- a/kitty/config_data.py +++ b/kitty/config_data.py @@ -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 diff --git a/kitty/fast_data_types.pyi b/kitty/fast_data_types.pyi index 003a18c64..023825844 100644 --- a/kitty/fast_data_types.pyi +++ b/kitty/fast_data_types.pyi @@ -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 diff --git a/kitty/launch.py b/kitty/launch.py index c24e30dac..492f8b647 100644 --- a/kitty/launch.py +++ b/kitty/launch.py @@ -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) diff --git a/kitty/main.py b/kitty/main.py index a69867244..bc8e8d726 100644 --- a/kitty/main.py +++ b/kitty/main.py @@ -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) diff --git a/kitty/options_stub.py b/kitty/options_stub.py index 5637bd588..47f289fe6 100644 --- a/kitty/options_stub.py +++ b/kitty/options_stub.py @@ -2,8 +2,6 @@ # vim:fileencoding=utf-8 # License: GPLv3 Copyright: 2020, Kovid Goyal -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__': diff --git a/kitty/options_stub.pyi b/kitty/options_stub.pyi index 093dbd5f8..45b73e315 100644 --- a/kitty/options_stub.pyi +++ b/kitty/options_stub.pyi @@ -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 \ No newline at end of file + kitten_alias: str + keymap: KeyMap + sequence_map: typing.Dict[KeySpec, KeyMap] \ No newline at end of file diff --git a/kitty/remote_control.py b/kitty/remote_control.py index 347b988ea..bd8fc4557 100644 --- a/kitty/remote_control.py +++ b/kitty/remote_control.py @@ -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): diff --git a/setup.cfg b/setup.cfg index 76d2e79a6..0ea40cec2 100644 --- a/setup.cfg +++ b/setup.cfg @@ -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