Skip to content

Commit

Permalink
kresctl: tab-completion: stop appending space after one config layer …
Browse files Browse the repository at this point in the history
…is completed
  • Loading branch information
Frantisek Tobias committed Dec 13, 2024
1 parent cf99bd5 commit 4bbb73f
Show file tree
Hide file tree
Showing 3 changed files with 121 additions and 67 deletions.
78 changes: 63 additions & 15 deletions python/knot_resolver/client/command.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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


Expand Down Expand Up @@ -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
71 changes: 44 additions & 27 deletions python/knot_resolver/client/commands/config.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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:
Expand All @@ -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

Expand All @@ -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.",
Expand Down Expand Up @@ -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",
Expand All @@ -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",
Expand All @@ -153,34 +166,38 @@ 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:
if args[-1] in path:
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:
Expand Down
39 changes: 14 additions & 25 deletions utils/shell-completion/client.bash
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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

0 comments on commit 4bbb73f

Please sign in to comment.