"""Completion finder which brings together custom options and completion logic.""" from __future__ import annotations import abc import argparse import os import re import typing as t from .argcompletion import ( OptionCompletionFinder, get_comp_type, register_safe_action, warn, ) from .parsers import ( Completion, CompletionError, CompletionSuccess, CompletionUnavailable, DocumentationState, NamespaceParser, Parser, ParserError, ParserMode, ParserState, ) class RegisteredCompletionFinder(OptionCompletionFinder): """ Custom option completion finder for argcomplete which allows completion results to be registered. These registered completions, if provided, are used to filter the final completion results. This works around a known bug: https://github.com/kislyuk/argcomplete/issues/221 """ def __init__(self, *args, **kwargs) -> None: super().__init__(*args, **kwargs) self.registered_completions: t.Optional[list[str]] = None def completer( self, prefix: str, action: argparse.Action, parsed_args: argparse.Namespace, **kwargs, ) -> list[str]: """ Return a list of completions for the specified prefix and action. Use this as the completer function for argcomplete. """ kwargs.clear() del kwargs completions = self.get_completions(prefix, action, parsed_args) if action.nargs and not isinstance(action.nargs, int): # prevent argcomplete from including unrelated arguments in the completion results self.registered_completions = completions return completions @abc.abstractmethod def get_completions( self, prefix: str, action: argparse.Action, parsed_args: argparse.Namespace, ) -> list[str]: """ Return a list of completions for the specified prefix and action. Called by the complete function. """ def quote_completions(self, completions, cword_prequote, last_wordbreak_pos): """Modify completion results before returning them.""" if self.registered_completions is not None: # If one of the completion handlers registered their results, only allow those exact results to be returned. # This prevents argcomplete from adding results from other completers when they are known to be invalid. allowed_completions = set(self.registered_completions) completions = [completion for completion in completions if completion in allowed_completions] return super().quote_completions(completions, cword_prequote, last_wordbreak_pos) class CompositeAction(argparse.Action, metaclass=abc.ABCMeta): """Base class for actions that parse composite arguments.""" documentation_state: dict[t.Type[CompositeAction], DocumentationState] = {} def __init__( self, *args, **kwargs, ): self.definition = self.create_parser() self.documentation_state[type(self)] = documentation_state = DocumentationState() self.definition.document(documentation_state) kwargs.update(dest=self.definition.dest) super().__init__(*args, **kwargs) register_safe_action(type(self)) @abc.abstractmethod def create_parser(self) -> NamespaceParser: """Return a namespace parser to parse the argument associated with this action.""" def __call__( self, parser, namespace, values, option_string=None, ): state = ParserState(mode=ParserMode.PARSE, namespaces=[namespace], remainder=values) try: self.definition.parse(state) except ParserError as ex: error = str(ex) except CompletionError as ex: error = ex.message else: return if get_comp_type(): # FUTURE: It may be possible to enhance error handling by surfacing this error message during downstream completion. return # ignore parse errors during completion to avoid breaking downstream completion raise argparse.ArgumentError(self, error) class CompositeActionCompletionFinder(RegisteredCompletionFinder): """Completion finder with support for composite argument parsing.""" def get_completions( self, prefix: str, action: argparse.Action, parsed_args: argparse.Namespace, ) -> list[str]: """Return a list of completions appropriate for the given prefix and action, taking into account the arguments that have already been parsed.""" assert isinstance(action, CompositeAction) state = ParserState( mode=ParserMode.LIST if self.list_mode else ParserMode.COMPLETE, remainder=prefix, namespaces=[parsed_args], ) answer = complete(action.definition, state) completions = [] if isinstance(answer, CompletionSuccess): self.disable_completion_mangling = answer.preserve completions = answer.completions if isinstance(answer, CompletionError): warn(answer.message) return completions def detect_file_listing(value: str, mode: ParserMode) -> bool: """ Return True if Bash will show a file listing and redraw the prompt, otherwise return False. If there are no list results, a file listing will be shown if the value after the last `=` or `:` character: - is empty - matches a full path - matches a partial path Otherwise Bash will play the bell sound and display nothing. see: https://github.com/kislyuk/argcomplete/issues/328 see: https://github.com/kislyuk/argcomplete/pull/284 """ listing = False if mode == ParserMode.LIST: right = re.split('[=:]', value)[-1] listing = not right or os.path.exists(right) if not listing: directory = os.path.dirname(right) # noinspection PyBroadException try: filenames = os.listdir(directory or '.') except Exception: # pylint: disable=broad-except pass else: listing = any(filename.startswith(right) for filename in filenames) return listing def detect_false_file_completion(value: str, mode: ParserMode) -> bool: """ Return True if Bash will provide an incorrect file completion, otherwise return False. If there are no completion results, a filename will be automatically completed if the value after the last `=` or `:` character: - matches exactly one partial path Otherwise Bash will play the bell sound and display nothing. see: https://github.com/kislyuk/argcomplete/issues/328 see: https://github.com/kislyuk/argcomplete/pull/284 """ completion = False if mode == ParserMode.COMPLETE: completion = True right = re.split('[=:]', value)[-1] directory, prefix = os.path.split(right) # noinspection PyBroadException try: filenames = os.listdir(directory or '.') except Exception: # pylint: disable=broad-except pass else: matches = [filename for filename in filenames if filename.startswith(prefix)] completion = len(matches) == 1 return completion def complete( completer: Parser, state: ParserState, ) -> Completion: """Perform argument completion using the given completer and return the completion result.""" value = state.remainder answer: Completion try: completer.parse(state) raise ParserError('completion expected') except CompletionUnavailable as ex: if detect_file_listing(value, state.mode): # Displaying a warning before the file listing informs the user it is invalid. Bash will redraw the prompt after the list. # If the file listing is not shown, a warning could be helpful, but would introduce noise on the terminal since the prompt is not redrawn. answer = CompletionError(ex.message) elif detect_false_file_completion(value, state.mode): # When the current prefix provides no matches, but matches files a single file on disk, Bash will perform an incorrect completion. # Returning multiple invalid matches instead of no matches will prevent Bash from using its own completion logic in this case. answer = CompletionSuccess( list_mode=True, # abuse list mode to enable preservation of the literal results consumed='', continuation='', matches=['completion', 'invalid'] ) else: answer = ex except Completion as ex: answer = ex return answer