Files
kitty/kitty/shell.py
T
Kovid Goyal fca0999814 Eureka! Figured out why libedit is breaking in prewarm on macOS via launchd
The prewarm zygote imports the world. shell.py had a top level import
for readline. Which means readline was being imported pre-fork. And of
course as is traditional with Apple libedit is not fork safe. Probably
because it initializes its internal IO routines based on the stdio
handles at time of import which are the handles kitty gets from launchd
2022-08-30 19:35:17 +05:30

266 lines
8.9 KiB
Python

#!/usr/bin/env python3
# License: GPL v3 Copyright: 2018, Kovid Goyal <kovid at kovidgoyal.net>
import os
import shlex
import sys
import traceback
from contextlib import suppress
from functools import lru_cache
from typing import Any, Dict, Generator, Iterable, List, Optional, Tuple
from kittens.tui.operations import set_cursor_shape, set_window_title
from .cli import (
OptionDict, emph, green, italic, parse_option_spec, print_help_for_seq,
title
)
from .cli_stub import RCOptions
from .constants import cache_dir, kitty_face
from .rc.base import (
ParsingOfArgsFailed, RemoteCommand, all_command_names, command_for_name,
display_subcommand_help, parse_subcommand_cli
)
from .remote_control import (
CommandEncrypter, NoEncryption, create_basic_command, do_io
)
from .types import run_once
output_prefix = '\x1b]133;C\x1b\\'
is_libedit = False
@run_once
def match_commands() -> Tuple[str, ...]:
all_commands = tuple(sorted(x.replace('_', '-') for x in all_command_names()))
return tuple(sorted(all_commands + ('exit', 'help', 'quit')))
@run_once
def init_readline() -> None:
import readline
global is_libedit
with suppress(OSError):
readline.read_init_file()
if 'libedit' in readline.__doc__:
readline.parse_and_bind("bind ^I rl_complete")
is_libedit = True
else:
readline.parse_and_bind('tab: complete')
def cmd_names_matching(prefix: str) -> Generator[str, None, None]:
for cmd in match_commands():
if not prefix or cmd.startswith(prefix):
yield cmd + ' '
@lru_cache()
def options_for_cmd(cmd: str) -> Tuple[Tuple[str, ...], Dict[str, OptionDict]]:
alias_map: Dict[str, OptionDict] = {}
try:
func = command_for_name(cmd)
except KeyError:
return (), alias_map
if not func.options_spec:
return (), alias_map
seq, disabled = parse_option_spec(func.options_spec)
ans = []
for opt in seq:
if isinstance(opt, str):
continue
for alias in opt['aliases']:
ans.append(alias)
alias_map[alias] = opt
return tuple(sorted(ans)), alias_map
def options_matching(prefix: str, cmd: str, last_word: str, aliases: Iterable[str], alias_map: Dict[str, OptionDict]) -> Generator[str, None, None]:
for alias in aliases:
if (not prefix or alias.startswith(prefix)) and alias.startswith('--'):
yield alias + ' '
class Completer:
def __init__(self) -> None:
self.matches: List[str] = []
ddir = cache_dir()
os.makedirs(ddir, exist_ok=True)
self.history_path = os.path.join(ddir, 'shell.history')
def complete(self, text: str, state: int) -> Optional[str]:
import readline
if state == 0:
line = readline.get_line_buffer()
cmdline = shlex.split(line)
if len(cmdline) < 2 and not line.endswith(' '):
self.matches = list(cmd_names_matching(text))
else:
self.matches = list(options_matching(text, cmdline[0], cmdline[-1], *options_for_cmd(cmdline[0])))
if state < len(self.matches):
return self.matches[state]
return None
def __enter__(self) -> 'Completer':
import readline
with suppress(Exception):
readline.read_history_file(self.history_path)
readline.set_completer(self.complete)
delims = readline.get_completer_delims()
readline.set_completer_delims(delims.replace('-', ''))
return self
def __exit__(self, *a: Any) -> None:
import readline
readline.write_history_file(self.history_path)
def print_err(*a: Any, **kw: Any) -> None:
kw['file'] = sys.stderr
print(*a, **kw)
def print_help(which: Optional[str] = None) -> None:
if which is None:
print('Control kitty by sending it commands.')
print()
print(title('Commands') + ':')
for cmd in all_command_names():
c = command_for_name(cmd)
print(' ', green(c.name))
print(' ', c.short_desc)
print(' ', green('exit'))
print(' ', 'Exit this shell')
print('\nUse help {} for help on individual commands'.format(italic('command')))
else:
try:
func = command_for_name(which)
except KeyError:
if which == 'exit':
print('Exit this shell')
elif which == 'help':
print('Show help')
else:
print(f'Unknown command: {emph(which)}')
return
display_subcommand_help(func)
def run_cmd(
global_opts: RCOptions, cmd: str, func: RemoteCommand, opts: Any, items: List[str],
encrypter: CommandEncrypter = NoEncryption()
) -> None:
print(end=set_window_title(cmd) + output_prefix, flush=True)
payload = func.message_to_kitty(global_opts, opts, items)
no_response = func.no_response
if hasattr(opts, 'no_response'):
no_response = opts.no_response
send = original_send_cmd = create_basic_command(cmd, payload=payload, is_asynchronous=func.is_asynchronous, no_response=no_response)
response_timeout = func.response_timeout
if hasattr(opts, 'response_timeout'):
response_timeout = opts.response_timeout
response_timeout = encrypter.adjust_response_timeout_for_password(response_timeout)
try:
response = do_io(global_opts.to, send, no_response, response_timeout, encrypter)
except TimeoutError:
original_send_cmd.pop('payload', None)
original_send_cmd['cancel_async'] = True
do_io(global_opts.to, send, True, 10, encrypter)
print_err(f'Timed out after {response_timeout} seconds waiting for response from kitty')
return
if not response.get('ok'):
if response.get('tb'):
print_err(response['tb'])
print_err(response['error'])
return
if 'data' in response:
print(response['data'])
def real_main(global_opts: RCOptions, encrypter: CommandEncrypter = NoEncryption()) -> None:
init_readline()
print_help_for_seq.allow_pager = False
print('Welcome to the kitty shell!')
print('Use {} for assistance or {} to quit'.format(green('help'), green('exit')))
awid = os.environ.pop('KITTY_SHELL_ACTIVE_WINDOW_ID', None)
if awid is not None:
print(f'The ID of the previously active window is: {awid}')
pre_prompt = set_window_title('The kitty shell') + set_cursor_shape('bar')
pre_prompt += f'\x1b]133;A;redraw={0 if is_libedit else 1}\x1b\\'
while True:
try:
print(end=pre_prompt)
try:
scmdline = input(f'{kitty_face} ')
except UnicodeEncodeError:
scmdline = input('kitty> ')
except EOFError:
break
except KeyboardInterrupt:
print()
continue
print(end=set_cursor_shape(), flush=True)
if not scmdline:
continue
try:
cmdline = shlex.split(scmdline)
except Exception:
print_err(f'"{emph(scmdline)}" is invalid. Use "help" to see a list of commands.')
continue
cmd = cmdline[0].lower()
try:
func = command_for_name(cmd)
except KeyError:
if cmd in ('exit', 'quit'):
break
print(end=output_prefix, flush=True)
if cmd == 'help':
print_help(cmdline[1] if len(cmdline) > 1 else None)
continue
print_err(f'"{emph(cmd)}" is an unknown command. Use "help" to see a list of commands.')
continue
try:
opts, items = parse_subcommand_cli(func, cmdline)
except SystemExit as e:
if e.code != 0:
print(end=output_prefix, flush=True)
print_err(e)
print_err('Use "{}" to see how to use this command.'.format(emph(f'help {cmd}')))
continue
except Exception:
print(end=output_prefix, flush=True)
print_err('Unhandled error:')
traceback.print_exc()
continue
else:
try:
run_cmd(global_opts, cmd, func, opts, items, encrypter)
except (SystemExit, ParsingOfArgsFailed) as e:
print(end=output_prefix, flush=True)
print_err(e)
continue
except KeyboardInterrupt:
print(end=output_prefix, flush=True)
print()
continue
except Exception:
print(end=output_prefix, flush=True)
print_err('Unhandled error:')
traceback.print_exc()
continue
def main(global_opts: RCOptions, encrypter: CommandEncrypter = NoEncryption()) -> None:
try:
with Completer():
real_main(global_opts, encrypter)
except Exception:
traceback.print_exc()
input('Press Enter to quit')
raise SystemExit(1)