438 lines
15 KiB
Python
438 lines
15 KiB
Python
# -*- coding: utf-8 -*- #
|
|
# Copyright 2017 Google LLC. All Rights Reserved.
|
|
#
|
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
# you may not use this file except in compliance with the License.
|
|
# You may obtain a copy of the License at
|
|
#
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
#
|
|
# Unless required by applicable law or agreed to in writing, software
|
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
# See the License for the specific language governing permissions and
|
|
# limitations under the License.
|
|
|
|
"""The gcloud interactive application."""
|
|
|
|
from __future__ import absolute_import
|
|
from __future__ import division
|
|
from __future__ import print_function
|
|
from __future__ import unicode_literals
|
|
|
|
import os
|
|
import sys
|
|
|
|
from googlecloudsdk.calliope import cli_tree
|
|
|
|
from googlecloudsdk.command_lib.interactive import bindings
|
|
from googlecloudsdk.command_lib.interactive import bindings_vi
|
|
from googlecloudsdk.command_lib.interactive import completer
|
|
from googlecloudsdk.command_lib.interactive import coshell as interactive_coshell
|
|
from googlecloudsdk.command_lib.interactive import debug as interactive_debug
|
|
from googlecloudsdk.command_lib.interactive import layout
|
|
from googlecloudsdk.command_lib.interactive import parser
|
|
from googlecloudsdk.command_lib.interactive import style as interactive_style
|
|
|
|
from googlecloudsdk.command_lib.meta import generate_cli_trees
|
|
|
|
from googlecloudsdk.core import config as core_config
|
|
from googlecloudsdk.core import properties
|
|
from googlecloudsdk.core.configurations import named_configs
|
|
|
|
from prompt_toolkit import application as pt_application
|
|
from prompt_toolkit import auto_suggest
|
|
from prompt_toolkit import buffer as pt_buffer
|
|
from prompt_toolkit import document
|
|
from prompt_toolkit import enums
|
|
from prompt_toolkit import filters
|
|
from prompt_toolkit import history as pt_history
|
|
from prompt_toolkit import interface
|
|
from prompt_toolkit import shortcuts
|
|
from prompt_toolkit import token
|
|
from prompt_toolkit.layout import processors as pt_layout
|
|
|
|
|
|
class CLI(interface.CommandLineInterface):
|
|
"""Extends the prompt CLI object to include our state.
|
|
|
|
Attributes:
|
|
command_count: Command line serial number, incremented on ctrl-c and Run.
|
|
completer: The interactive completer object.
|
|
config: The interactive shell config object.
|
|
coshell: The shell coprocess object.
|
|
debug: The debugging object.
|
|
parser: The interactive parser object.
|
|
root: The root of the static CLI tree that contains all commands, flags,
|
|
positionals and help doc snippets.
|
|
"""
|
|
|
|
def __init__(self, config=None, coshell=None, debug=None, root=None,
|
|
interactive_parser=None, interactive_completer=None,
|
|
application=None, eventloop=None, output=None):
|
|
super(CLI, self).__init__(
|
|
application=application,
|
|
eventloop=eventloop,
|
|
output=output)
|
|
self.command_count = 0
|
|
self.completer = interactive_completer
|
|
self.config = config
|
|
self.coshell = coshell
|
|
self.debug = debug
|
|
self.parser = interactive_parser
|
|
self.root = root
|
|
|
|
def Run(self, text, alternate_screen=False):
|
|
"""Runs the command line in text, optionally in an alternate screen.
|
|
|
|
This should use an alternate screen but I haven't found the incantations
|
|
to get that working. Currently alternate_screen=True clears the default
|
|
screen so full screen commands, like editors and man or help, have a clean
|
|
slate. Otherwise they may overwrite previous output and end up with a
|
|
garbled mess. The downside is that on return the default screen is
|
|
clobbered. Not too bad right now because this is only used as a fallback
|
|
when the real web browser is inaccessible (for example when running in ssh).
|
|
|
|
Args:
|
|
text: The command line string to run.
|
|
alternate_screen: Send output to an alternate screen and restore the
|
|
original screen when done.
|
|
"""
|
|
if alternate_screen:
|
|
self.renderer.erase()
|
|
self.coshell.Run(text)
|
|
if alternate_screen:
|
|
self.renderer.erase(leave_alternate_screen=False, erase_title=False)
|
|
self.renderer.request_absolute_cursor_position()
|
|
self._redraw()
|
|
|
|
# Wraps the interface.CommandLineInterface method.
|
|
def add_buffer(self, name, buf, focus=False):
|
|
"""MONKEYPATCH! Calls the async completer on delete before cursor."""
|
|
super(CLI, self).add_buffer(name, buf, focus)
|
|
|
|
def DeleteBeforeCursor(count=1):
|
|
deleted = buf.patch_real_delete_before_cursor(count=count)
|
|
# This call to the async completer refreshes the completion dropdown as
|
|
# characters are deleted.
|
|
buf.patch_completer_function()
|
|
return deleted
|
|
|
|
# Only needed in complete_while_typing mode, and only need to patch once.
|
|
if (buf.complete_while_typing() and
|
|
buf.delete_before_cursor != DeleteBeforeCursor):
|
|
# The async completer to call.
|
|
buf.patch_completer_function = self._async_completers[name]
|
|
# The real delete_before_cursor, always called.
|
|
buf.patch_real_delete_before_cursor = buf.delete_before_cursor
|
|
# Our monkeypatched delete_before_cursor.
|
|
buf.delete_before_cursor = DeleteBeforeCursor
|
|
|
|
|
|
class Context(pt_layout.Processor):
|
|
"""Input processor that adds context."""
|
|
|
|
@staticmethod
|
|
def apply_transformation(cli, doc, lineno, source_to_display, tokens):
|
|
if not cli.context_was_set and not doc.text:
|
|
cli.context_was_set = True
|
|
cli.current_buffer.set_document(document.Document(cli.config.context))
|
|
return pt_layout.Transformation(
|
|
tokens, display_to_source=lambda i: len(cli.config.context))
|
|
|
|
|
|
def _GetJustifiedTokens(labels, width=80, justify=True):
|
|
"""Returns labels as left- and right-justified tokens."""
|
|
if justify:
|
|
used_width = 0
|
|
label_count = 0
|
|
for label in labels:
|
|
if label is None:
|
|
continue
|
|
label_count += 1
|
|
used_width += len(label)
|
|
|
|
if not label_count:
|
|
return []
|
|
elif label_count > 1:
|
|
separator_width = (width - used_width) // (label_count - 1)
|
|
if separator_width < 1:
|
|
separator_width = 1
|
|
else:
|
|
separator_width = 1
|
|
|
|
separator_remainder = (
|
|
width - used_width - separator_width * (label_count - 1))
|
|
if separator_remainder > 0:
|
|
# Uneven separators widths. Fudge the separatos by this amount for the
|
|
# first separator_remainder separators to favor right justfication. A
|
|
# true nit, but people could be staring at this all day.
|
|
separator_width += 1
|
|
|
|
else:
|
|
separator_remainder = 0
|
|
separator_width = 2
|
|
|
|
tokens = []
|
|
for label in labels:
|
|
if label is None:
|
|
continue
|
|
tokens.append((token.Token.Toolbar.Help, label))
|
|
tokens.append((token.Token.Toolbar.Separator, ' ' * separator_width))
|
|
separator_remainder -= 1
|
|
if separator_remainder == 0:
|
|
# Only do this once for this loop.
|
|
separator_width -= 1
|
|
return tokens[:-1]
|
|
|
|
|
|
def _AddCliTreeKeywordsAndBuiltins(root):
|
|
"""Adds keywords and builtins to the CLI tree root."""
|
|
|
|
# Add the exit builtin to the CLI tree.
|
|
|
|
node = cli_tree.Node(
|
|
command='exit',
|
|
description='Exit the interactive shell.',
|
|
positionals=[
|
|
{
|
|
'default': '0',
|
|
'description': 'The exit status.',
|
|
'name': 'status',
|
|
'nargs': '?',
|
|
'required': False,
|
|
'value': 'STATUS',
|
|
},
|
|
],
|
|
)
|
|
node[parser.LOOKUP_IS_GROUP] = False
|
|
root[parser.LOOKUP_COMMANDS]['exit'] = node
|
|
|
|
# Add special shell keywords that may be followed by commands.
|
|
|
|
for name in ['!', '{', 'do', 'elif', 'else', 'if', 'then', 'time',
|
|
'until', 'while']:
|
|
node = cli_tree.Node(name)
|
|
node[parser.LOOKUP_IS_GROUP] = False
|
|
node[parser.LOOKUP_IS_SPECIAL] = True
|
|
root[parser.LOOKUP_COMMANDS][name] = node
|
|
|
|
# Add misc shell keywords.
|
|
|
|
for name in ['break', 'case', 'continue', 'done', 'esac', 'fi']:
|
|
node = cli_tree.Node(name)
|
|
node[parser.LOOKUP_IS_GROUP] = False
|
|
root[parser.LOOKUP_COMMANDS][name] = node
|
|
|
|
|
|
class Application(object):
|
|
"""The CLI application.
|
|
|
|
Attributes:
|
|
args: The parsed command line arguments.
|
|
config: The interactive shell config object.
|
|
coshell: The shell coprocess object.
|
|
debug: The debugging object.
|
|
key_bindings: The key_bindings object holding the key binding list and
|
|
toggle states.
|
|
key_bindings_registry: The key bindings registry.
|
|
"""
|
|
|
|
def __init__(self, coshell=None, args=None, config=None, debug=None):
|
|
self.args = args
|
|
self.coshell = coshell
|
|
self.config = config
|
|
self.debug = debug
|
|
self.key_bindings = bindings.KeyBindings()
|
|
self.key_bindings_registry = self.key_bindings.MakeRegistry()
|
|
|
|
# Load the default CLI trees. On startup we ignore out of date trees. The
|
|
# alternative is to regenerate them before the first prompt. This could be
|
|
# a noticeable delay for users that accrue a lot of trees. Although ignored
|
|
# at startup, the regen will happen on demand as the individual commands
|
|
# are typed.
|
|
self.root = generate_cli_trees.LoadAll(
|
|
ignore_out_of_date=True, warn_on_exceptions=True)
|
|
|
|
# Add the interactive default CLI tree nodes.
|
|
|
|
_AddCliTreeKeywordsAndBuiltins(self.root)
|
|
|
|
# Make sure that complete_while_typing is disabled when
|
|
# enable_history_search is enabled. (First convert to SimpleFilter, to
|
|
# avoid doing bitwise operations on bool objects.)
|
|
complete_while_typing = shortcuts.to_simple_filter(True)
|
|
enable_history_search = shortcuts.to_simple_filter(False)
|
|
complete_while_typing &= ~enable_history_search
|
|
history_file = os.path.join(core_config.Paths().global_config_dir,
|
|
'shell_history')
|
|
multiline = shortcuts.to_simple_filter(False)
|
|
|
|
# Create the parser.
|
|
interactive_parser = parser.Parser(
|
|
self.root,
|
|
context=config.context,
|
|
hidden=config.hidden)
|
|
|
|
# Create the completer.
|
|
interactive_completer = completer.InteractiveCliCompleter(
|
|
coshell=coshell,
|
|
debug=debug,
|
|
interactive_parser=interactive_parser,
|
|
args=args,
|
|
hidden=config.hidden,
|
|
manpage_generator=config.manpage_generator)
|
|
|
|
# Create the default buffer.
|
|
self.default_buffer = pt_buffer.Buffer(
|
|
enable_history_search=enable_history_search,
|
|
complete_while_typing=complete_while_typing,
|
|
is_multiline=multiline,
|
|
history=pt_history.FileHistory(history_file),
|
|
validator=None,
|
|
completer=interactive_completer,
|
|
auto_suggest=(auto_suggest.AutoSuggestFromHistory()
|
|
if config.suggest else None),
|
|
accept_action=pt_buffer.AcceptAction.RETURN_DOCUMENT,
|
|
)
|
|
|
|
# Create the CLI.
|
|
self.cli = CLI(
|
|
config=config,
|
|
coshell=coshell,
|
|
debug=debug,
|
|
root=self.root,
|
|
interactive_parser=interactive_parser,
|
|
interactive_completer=interactive_completer,
|
|
application=self._CreatePromptApplication(config=config,
|
|
multiline=multiline),
|
|
eventloop=shortcuts.create_eventloop(),
|
|
output=shortcuts.create_output(),
|
|
)
|
|
|
|
# The interactive completer is friends with the CLI.
|
|
interactive_completer.cli = self.cli
|
|
|
|
# Initialize the bindings.
|
|
self.key_bindings.Initialize(self.cli)
|
|
bindings_vi.LoadViBindings(self.key_bindings_registry)
|
|
|
|
def _CreatePromptApplication(self, config, multiline):
|
|
"""Creates a shell prompt Application."""
|
|
|
|
return pt_application.Application(
|
|
layout=layout.CreatePromptLayout(
|
|
config=config,
|
|
extra_input_processors=[Context()],
|
|
get_bottom_status_tokens=self._GetBottomStatusTokens,
|
|
get_bottom_toolbar_tokens=self._GetBottomToolbarTokens,
|
|
get_continuation_tokens=None,
|
|
get_debug_tokens=self._GetDebugTokens,
|
|
get_prompt_tokens=None,
|
|
is_password=False,
|
|
lexer=None,
|
|
multiline=filters.Condition(lambda cli: multiline()),
|
|
show_help=filters.Condition(
|
|
lambda _: self.key_bindings.help_key.toggle),
|
|
wrap_lines=True,
|
|
),
|
|
buffer=self.default_buffer,
|
|
clipboard=None,
|
|
erase_when_done=False,
|
|
get_title=None,
|
|
key_bindings_registry=self.key_bindings_registry,
|
|
mouse_support=False,
|
|
reverse_vi_search_direction=True,
|
|
style=interactive_style.GetDocumentStyle(),
|
|
)
|
|
|
|
def _GetProjectAndAccount(self):
|
|
"""Returns the current (project, account) tuple."""
|
|
if self.config.obfuscate:
|
|
return ('me', 'myself@i')
|
|
if not self.args.IsSpecified('project'):
|
|
named_configs.ActivePropertiesFile().Invalidate()
|
|
project = properties.VALUES.core.project.Get() or '<NO PROJECT SET>'
|
|
account = properties.VALUES.core.account.Get() or '<NO ACCOUNT SET>'
|
|
return (project, account)
|
|
|
|
def _GetBottomStatusTokens(self, cli):
|
|
"""Returns the bottom status tokens based on the key binding state."""
|
|
project, account = self._GetProjectAndAccount()
|
|
return _GetJustifiedTokens(
|
|
['Project:' + project, 'Account:' + account],
|
|
justify=cli.config.justify_bottom_lines,
|
|
width=cli.output.get_size().columns)
|
|
|
|
def _GetBottomToolbarTokens(self, cli):
|
|
"""Returns the bottom toolbar tokens based on the key binding state."""
|
|
tokens = [binding.GetLabel() for binding in self.key_bindings.bindings]
|
|
if not cli.config.bottom_status_line:
|
|
project, account = self._GetProjectAndAccount()
|
|
tokens.append(project)
|
|
tokens.append(account)
|
|
return _GetJustifiedTokens(
|
|
tokens,
|
|
justify=cli.config.justify_bottom_lines,
|
|
width=cli.output.get_size().columns)
|
|
|
|
def _GetDebugTokens(self, cli):
|
|
"""Returns the debug frame tokens."""
|
|
return [(token.Token.Text, c + ' ') for c in cli.debug.contents()]
|
|
|
|
def Prompt(self):
|
|
"""Prompts and returns one command line."""
|
|
self.cli.context_was_set = not self.cli.config.context
|
|
doc = self.cli.run()
|
|
return doc.text if doc else None
|
|
|
|
def SetModes(self):
|
|
"""Called when coshell modes may have changed."""
|
|
if self.coshell.edit_mode == 'emacs':
|
|
self.cli.editing_mode = enums.EditingMode.EMACS
|
|
else:
|
|
self.cli.editing_mode = enums.EditingMode.VI
|
|
|
|
def Run(self, text):
|
|
"""Runs the command(s) in text and waits for them to complete."""
|
|
self.cli.command_count += 1
|
|
status = self.coshell.Run(text)
|
|
if status > 128:
|
|
# command interrupted - print an empty line to clear partial output
|
|
print()
|
|
return status # currently ignored but returned for completeness
|
|
|
|
def Loop(self):
|
|
"""Loops Prompt-Run until ^D exit, or quit."""
|
|
self.coshell.SetModesCallback(self.SetModes)
|
|
while True:
|
|
try:
|
|
text = self.Prompt()
|
|
if text is None:
|
|
break
|
|
self.Run(text) # paradoxically ignored - coshell maintains $?
|
|
except EOFError:
|
|
# ctrl-d
|
|
if not self.coshell.ignore_eof:
|
|
break
|
|
except KeyboardInterrupt:
|
|
# ignore ctrl-c
|
|
pass
|
|
except interactive_coshell.CoshellExitError:
|
|
break
|
|
|
|
|
|
def main(args=None, config=None):
|
|
"""The interactive application loop."""
|
|
coshell = interactive_coshell.Coshell()
|
|
try:
|
|
Application(
|
|
args=args,
|
|
coshell=coshell,
|
|
config=config,
|
|
debug=interactive_debug.Debug(),
|
|
).Loop()
|
|
finally:
|
|
status = coshell.Close()
|
|
sys.exit(status)
|