diff --git a/python/knot_resolver/client/command.py b/python/knot_resolver/client/command.py index 33f2c2ad9..a34f31dc7 100644 --- a/python/knot_resolver/client/command.py +++ b/python/knot_resolver/client/command.py @@ -1,7 +1,7 @@ import argparse from abc import ABC, abstractmethod # pylint: disable=[no-name-in-module] from pathlib import Path -from typing import Any, Dict, List, Optional, Tuple, Type, TypeVar +from typing import Any, Dict, List, Optional, Set, Tuple, Type, TypeVar from urllib.parse import quote from knot_resolver.constants import API_SOCK_FILE, CONFIG_FILE @@ -17,15 +17,43 @@ _registered_commands: List[Type["Command"]] = [] -def get_subparsers_words(subparser_actions: List[argparse.Action]) -> CompWords: +def get_mutually_exclusive_commands(parser: argparse.ArgumentParser) -> List[Set[str]]: + command_names: List[Set[str]] = [] + for group in parser._mutually_exclusive_groups: # pylint: disable=protected-access + command_names.append(set()) + for action in group._group_actions: # pylint: disable=protected-access + if action.option_strings: + command_names[-1].update(action.option_strings) + return command_names + + +def is_unique_and_new(arg: str, args: Set[str], exclusive: List[Set[str]], last: str) -> bool: + if arg not in args: + for excl in exclusive: + if arg in excl: + for cmd in excl: + if cmd in args: + return False + return True + + return arg == last + + +def get_subparsers_words( + subparser_actions: List[argparse.Action], args: Set[str], exclusive: List[Set[str]], last: str +) -> CompWords: + words: CompWords = {} for action in subparser_actions: if isinstance(action, argparse._SubParsersAction) and action.choices: # pylint: disable=protected-access for choice, parser in action.choices.items(): - words[choice] = parser.description + if is_unique_and_new(choice, args, exclusive, last): + words[choice] = parser.description else: for opt in action.option_strings: - words[opt] = action.help + if is_unique_and_new(opt, args, exclusive, last): + words[opt] = action.help + return words @@ -136,34 +164,54 @@ def run(self, args: CommandArgs) -> None: raise NotImplementedError() @staticmethod - def completion(parser: argparse.ArgumentParser, args: Optional[List[str]] = None, curr_index: int = 0) -> CompWords: + def completion( + parser: argparse.ArgumentParser, + args: Optional[List[str]] = None, + curr_index: int = 0, + argset: Optional[Set[str]] = None, + ) -> CompWords: + if args is None or len(args) == 0: return {} - words: CompWords = get_subparsers_words(parser._actions) # pylint: disable=protected-access + if argset is None: + argset = set(args) - subparsers = parser._subparsers # pylint: disable=protected-access + if "-h" in argset or "--help" in argset: + return {args[-1]: None} if args[-1] in ["-h", "--help"] else {} + exclusive: List[Set[str]] = get_mutually_exclusive_commands(parser) + + words = get_subparsers_words(parser._actions, argset, exclusive, args[-1]) # pylint: disable=protected-access + + subparsers = parser._subparsers # pylint: disable=protected-access if subparsers: while curr_index < len(args): uarg = args[curr_index] - subpar = get_subparser_by_name(uarg, subparsers._actions) # pylint: disable=W0212 - curr_index += 1 + + subpar = get_subparser_by_name(uarg, subparsers._actions) # pylint: disable=protected-access if subpar: cmd = get_subparser_command(subpar) if cmd is None: - return get_subparsers_words(subpar._actions) # pylint: disable=protected-access + exclusive = get_mutually_exclusive_commands(subpar) + + if (curr_index >= len(args) or args[curr_index] == "") and uarg in words: + continue - if len(args) > curr_index: - return cmd.completion(subpar, args, curr_index) + words = get_subparsers_words( + subpar._actions, argset, exclusive, args[-1] # pylint: disable=protected-access + ) - return words + elif len(args) > curr_index: + words = cmd.completion(subpar, args, curr_index, argset) - elif uarg in ["-s", "--socket", "-c", "--config"]: - # following word shall not be a kresctl command, switch to path completion + break + + if uarg in ["-s", "--socket", "-c", "--config"]: if uarg in (args[-1], args[-2]): words = {} + curr_index += 1 return words diff --git a/python/knot_resolver/client/commands/config.py b/python/knot_resolver/client/commands/config.py index cf8f2019e..eee42741f 100644 --- a/python/knot_resolver/client/commands/config.py +++ b/python/knot_resolver/client/commands/config.py @@ -1,7 +1,7 @@ import argparse import sys from enum import Enum -from typing import Any, Dict, List, Literal, Optional, Tuple, Type +from typing import Any, Dict, List, Literal, Optional, Set, Tuple, Type from knot_resolver.client.command import Command, CommandArgs, CompWords, register_command from knot_resolver.datamodel import KresConfig @@ -23,22 +23,15 @@ def operation_to_method(operation: Operations) -> Literal["PUT", "GET", "DELETE" return "GET" -def _properties_words(props: Dict[str, Any], prefix: str) -> CompWords: - words: CompWords = {} - for name in props: - words[prefix + "/" + name] = props[name]["description"] - return words - - def generate_paths(data: Dict[str, Any], prefix: str = "/") -> CompWords: paths = {} if isinstance(data, dict): if "properties" in data.keys(): for key in data["properties"]: - current_path = f"{prefix}{key}/" + current_path = f"{prefix}{key}" - new_paths = generate_paths(data["properties"][key], current_path) + new_paths = generate_paths(data["properties"][key], current_path + "/") if new_paths != {}: paths.update(new_paths) else: @@ -50,8 +43,6 @@ def generate_paths(data: Dict[str, Any], prefix: str = "/") -> CompWords: paths.update(generate_paths(item, prefix)) else: paths.update(generate_paths(data["items"], prefix)) - else: - paths[prefix] = None return paths @@ -78,14 +69,22 @@ def register_args_subparser( get = config_subparsers.add_parser("get", help="Get current configuration from the resolver.") get.set_defaults(operation=Operations.GET, format=DataFormat.YAML) - get.add_argument( + get_path = get.add_mutually_exclusive_group() + get_path.add_argument( "-p", + help=path_help, + action="store", + type=str, + default="", + ) + get_path.add_argument( "--path", help=path_help, action="store", type=str, default="", ) + get.add_argument( "file", help="Optional, path to the file where to save exported configuration data. If not specified, data will be printed.", @@ -113,8 +112,15 @@ def register_args_subparser( set = config_subparsers.add_parser("set", help="Set new configuration for the resolver.") set.set_defaults(operation=Operations.SET) - set.add_argument( + set_path = set.add_mutually_exclusive_group() + set_path.add_argument( "-p", + help=path_help, + action="store", + type=str, + default="", + ) + set_path.add_argument( "--path", help=path_help, action="store", @@ -141,8 +147,15 @@ def register_args_subparser( "delete", help="Delete given configuration property or list item at the given index." ) delete.set_defaults(operation=Operations.DELETE) - delete.add_argument( + delete_path = delete.add_mutually_exclusive_group() + delete_path.add_argument( "-p", + help=path_help, + action="store", + type=str, + default="", + ) + delete_path.add_argument( "--path", help=path_help, action="store", @@ -153,15 +166,17 @@ def register_args_subparser( return config, ConfigCommand @staticmethod - def completion(parser: argparse.ArgumentParser, args: Optional[List[str]] = None, curr_index: int = 0) -> CompWords: - if args is not None and (len(args) - curr_index) > 1 and args[-2] in ["-p", "--path"]: - # if len(args[-1]) < 2: - # new_props = {} - # for prop in props: - # new_props['/' + prop] = props[prop] - # - # return new_props + def completion( + parser: argparse.ArgumentParser, + args: Optional[List[str]] = None, + curr_index: int = 0, + argset: Optional[Set[str]] = None, + ) -> CompWords: + + if args is None or len(args) == 0: + return {} + if args is not None and (len(args) - curr_index) > 1 and args[-2] in {"-p", "--path"}: paths = generate_paths(KresConfig.json_schema()) result = {} for path in paths: @@ -169,18 +184,20 @@ def completion(parser: argparse.ArgumentParser, args: Optional[List[str]] = None a_count = args[-1].count("/") + 1 new_path = "" for c in path: + new_path += c if c == "/": a_count -= 1 if a_count == 0: break - new_path += c - - result[new_path + "/"] = paths[path] + result[new_path] = paths[path] return result - return Command.completion(parser, args, curr_index) + if argset is None: + argset = set(args) + + return Command.completion(parser, args, curr_index, argset) def run(self, args: CommandArgs) -> None: if not self.operation: diff --git a/utils/shell-completion/client.bash b/utils/shell-completion/client.bash index de61c4295..56fa6d9d5 100644 --- a/utils/shell-completion/client.bash +++ b/utils/shell-completion/client.bash @@ -1,39 +1,28 @@ -#/usr/bin/env bash - -_kresctl_filter_double_dash() -{ - local words=("$@") - local new_words=() - local count=0 - - for WORD in "${words[@]}" - do - if [[ "$WORD" != "--" ]] - then - new_words[count]="$WORD" - ((count++)) - fi - done - - printf "%s\n" "${new_words[@]}" -} +#!/usr/bin/env bash _kresctl_completion() { COMPREPLY=() - local cur opts cmp_words + local cur prev opts words_up_to_cursor cur="${COMP_WORDS[COMP_CWORD]}" + prev="${COMP_WORDS[COMP_CWORD-1]}" local line="${COMP_LINE:0:$COMP_POINT}" local words_up_to_cursor=($line) - cmp_words=($(_kresctl_filter_double_dash "${words_up_to_cursor[@]}")) - if [[ -z "$cur" && "$COMP_POINT" -gt 0 && "${line: -1}" == " " ]] then - opts=$(kresctl completion --bash --space --args "${cmp_words[@]}") + opts=$(kresctl completion --bash --space --args "${words_up_to_cursor[@]}") else - opts=$(kresctl completion --bash --args "${cmp_words[@]}") + opts=$(kresctl completion --bash --args "${words_up_to_cursor[@]}") + fi + + # if we're completing a config path do not append a space + # (unless we have reached the bottom) + if [[ "$prev" == "-p" || "$prev" == "--path" ]] \ + && [[ $(echo "$opts" | wc -w) -gt 1 || "${opts: -1}" == '/' ]] + then + compopt -o nospace fi # if there is no completion from kresctl @@ -48,4 +37,4 @@ _kresctl_completion() return 0 } -complete -o filenames -o dirnames -o nosort -F _kresctl_completion kresctl +complete -o filenames -o dirnames -F _kresctl_completion kresctl