From d2d9752bd10cfb30232a7b48f4df9bf1a5ea81b4 Mon Sep 17 00:00:00 2001 From: Frantisek Tobias Date: Mon, 7 Oct 2024 13:51:46 +0200 Subject: [PATCH 01/16] kresctl: tab-completion: implement suggestions/completion for first argument --- python/knot_resolver/client/command.py | 29 ++++++- python/knot_resolver/client/commands/cache.py | 4 +- .../client/commands/completion.py | 85 ++++++++++--------- 3 files changed, 75 insertions(+), 43 deletions(-) diff --git a/python/knot_resolver/client/command.py b/python/knot_resolver/client/command.py index 76c0f1d0b..c30558449 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 Dict, List, Optional, Tuple, Type, TypeVar +from typing import Any, Dict, List, Optional, Tuple, Type, TypeVar from urllib.parse import quote from knot_resolver.constants import API_SOCK_FILE, CONFIG_FILE @@ -17,6 +17,33 @@ _registered_commands: List[Type["Command"]] = [] +def get_subparsers_words(subparser_actions: List[argparse.Action]) -> CompWords: + words: CompWords = {} + for action in subparser_actions: + if isinstance(action, argparse._SubParsersAction) and action.choices: # noqa: SLF001 + for choice, parser in action.choices.items(): + words[choice] = parser.description + else: + for opt in action.option_strings: + words[opt] = action.help + return words + + +def get_subparser_by_name(name: str, parser_actions: List[argparse.Action]) -> Optional[argparse.ArgumentParser]: + for action in parser_actions: + if isinstance(action, argparse._SubParsersAction): # noqa: SLF001 + if action.choices and name in action.choices: + return action.choices[name] + return None + + +def get_subparser_command(subparser: argparse.ArgumentParser) -> "Command": + defaults: Dict[str, Any] = subparser._defaults # noqa: SLF001 + if "command" in defaults: + return defaults["command"] + raise ValueError(f"missing 'command' default for '{subparser.prog}' parser") + + def register_command(cls: T) -> T: _registered_commands.append(cls) return cls diff --git a/python/knot_resolver/client/commands/cache.py b/python/knot_resolver/client/commands/cache.py index 60417eec5..d11165808 100644 --- a/python/knot_resolver/client/commands/cache.py +++ b/python/knot_resolver/client/commands/cache.py @@ -3,7 +3,7 @@ from enum import Enum from typing import Any, Dict, List, Optional, Tuple, Type -from knot_resolver.client.command import Command, CommandArgs, CompWords, register_command +from knot_resolver.client.command import Command, CommandArgs, CompWords, get_subparsers_words, register_command from knot_resolver.datamodel.cache_schema import CacheClearRPCSchema from knot_resolver.utils.modeling.exceptions import AggregateDataValidationError, DataValidationError from knot_resolver.utils.modeling.parsing import DataFormat, parse_json @@ -99,7 +99,7 @@ def register_args_subparser( @staticmethod def completion(args: List[str], parser: argparse.ArgumentParser) -> CompWords: - return {} + return get_subparsers_words(parser._actions) # noqa: SLF001 def run(self, args: CommandArgs) -> None: if not self.operation: diff --git a/python/knot_resolver/client/commands/completion.py b/python/knot_resolver/client/commands/completion.py index 05fdded82..ee46bc457 100644 --- a/python/knot_resolver/client/commands/completion.py +++ b/python/knot_resolver/client/commands/completion.py @@ -2,7 +2,15 @@ from enum import Enum from typing import List, Tuple, Type -from knot_resolver.client.command import Command, CommandArgs, CompWords, register_command +from knot_resolver.client.command import ( + Command, + CommandArgs, + CompWords, + get_subparser_by_name, + get_subparser_command, + get_subparsers_words, + register_command, +) class Shells(Enum): @@ -25,7 +33,10 @@ def __init__(self, namespace: argparse.Namespace) -> None: def register_args_subparser( subparser: "argparse._SubParsersAction[argparse.ArgumentParser]", ) -> Tuple[argparse.ArgumentParser, "Type[Command]"]: - completion = subparser.add_parser("completion", help="commands auto-completion") + completion = subparser.add_parser( + "completion", + help="commands auto-completion", + ) completion.add_argument( "--space", help="space after last word, returns all possible folowing options", @@ -49,47 +60,41 @@ def register_args_subparser( @staticmethod def completion(args: List[str], parser: argparse.ArgumentParser) -> CompWords: - words: CompWords = {} - # for action in parser._actions: - # for opt in action.option_strings: - # words[opt] = action.help - # return words - return words + return get_subparsers_words(parser._actions) # noqa: SLF001 def run(self, args: CommandArgs) -> None: - pass - # subparsers = args.parser._subparsers - # words: CompWords = {} + subparsers = args.parser._subparsers # noqa: SLF001 + words: CompWords = {} - # if subparsers: - # words = parser_words(subparsers._actions) + if subparsers: + words = get_subparsers_words(subparsers._actions) # noqa: SLF001 - # uargs = iter(self.comp_args) - # for uarg in uargs: - # subparser = subparser_by_name(uarg, subparsers._actions) # pylint: disable=W0212 + uargs = iter(self.comp_args) + for uarg in uargs: + subparser = get_subparser_by_name(uarg, subparsers._actions) # noqa: SLF001 - # if subparser: - # cmd: Command = subparser_command(subparser) - # subparser_args = self.comp_args[self.comp_args.index(uarg) + 1 :] - # if subparser_args: - # words = cmd.completion(subparser_args, subparser) - # break - # elif uarg in ["-s", "--socket"]: - # # if arg is socket config, skip next arg - # next(uargs) - # continue - # elif uarg in words: - # # uarg is walid arg, continue - # continue - # else: - # raise ValueError(f"unknown argument: {uarg}") + if subparser: + cmd: Command = get_subparser_command(subparser) + subparser_args = self.comp_args[self.comp_args.index(uarg) + 1 :] + if subparser_args or self.space: + words = cmd.completion(subparser_args, subparser) + break + if uarg in ["-s", "--socket", "-c", "--config"]: + # if arg is socket config, skip next arg + next(uargs) + continue + if uarg in words: + # uarg is valid (complete) arg, continue + continue + else: + raise ValueError(f"unknown argument: {uarg}") - # # print completion words - # # based on required bash/fish shell format - # if self.shell == Shells.BASH: - # print(" ".join(words)) - # elif self.shell == Shells.FISH: - # # TODO: FISH completion implementation - # pass - # else: - # raise ValueError(f"unexpected value of {Shells}: {self.shell}") + # print completion words + # based on required bash/fish shell format + if self.shell == Shells.BASH: + print(" ".join(words)) + elif self.shell == Shells.FISH: + # TODO: FISH completion implementation + pass + else: + raise ValueError(f"unexpected value of {Shells}: {self.shell}") From ea7a22600b65e7beedd1ad5ba1d467c8758e7e0d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ale=C5=A1=20Mr=C3=A1zek?= Date: Tue, 5 Nov 2024 10:27:49 +0100 Subject: [PATCH 02/16] python: client: completion: use argparse.REMAINDER Use argparse.REMAINDER to tell argparse to accept everything after argument as value for that argument. --- .../client/commands/completion.py | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/python/knot_resolver/client/commands/completion.py b/python/knot_resolver/client/commands/completion.py index ee46bc457..abb7b6128 100644 --- a/python/knot_resolver/client/commands/completion.py +++ b/python/knot_resolver/client/commands/completion.py @@ -24,10 +24,10 @@ def __init__(self, namespace: argparse.Namespace) -> None: super().__init__(namespace) self.shell: Shells = namespace.shell self.space = namespace.space - self.comp_args: List[str] = namespace.comp_args + self.args: List[str] = namespace.args if self.space: - self.comp_args.append("") + self.args.append("") @staticmethod def register_args_subparser( @@ -44,18 +44,14 @@ def register_args_subparser( action="store_true", default=False, ) - completion.add_argument( - "comp_args", - type=str, - help="arguments to complete", - nargs="*", - ) shells_dest = "shell" shells = completion.add_mutually_exclusive_group() shells.add_argument("--bash", action="store_const", dest=shells_dest, const=Shells.BASH, default=Shells.BASH) shells.add_argument("--fish", action="store_const", dest=shells_dest, const=Shells.FISH) + completion.add_argument("--args", help="arguments to complete", nargs=argparse.REMAINDER, default=[]) + return completion, CompletionCommand @staticmethod @@ -69,13 +65,13 @@ def run(self, args: CommandArgs) -> None: if subparsers: words = get_subparsers_words(subparsers._actions) # noqa: SLF001 - uargs = iter(self.comp_args) + uargs = iter(self.args) for uarg in uargs: subparser = get_subparser_by_name(uarg, subparsers._actions) # noqa: SLF001 if subparser: cmd: Command = get_subparser_command(subparser) - subparser_args = self.comp_args[self.comp_args.index(uarg) + 1 :] + subparser_args = self.args[self.args.index(uarg) + 1 :] if subparser_args or self.space: words = cmd.completion(subparser_args, subparser) break @@ -86,8 +82,6 @@ def run(self, args: CommandArgs) -> None: if uarg in words: # uarg is valid (complete) arg, continue continue - else: - raise ValueError(f"unknown argument: {uarg}") # print completion words # based on required bash/fish shell format From 37330d7288126b917d2e3ccab70400705f99c05d Mon Sep 17 00:00:00 2001 From: Frantisek Tobias Date: Fri, 11 Oct 2024 10:36:47 +0200 Subject: [PATCH 03/16] kresctl: tab-completion: auto-complete for all commands except config --- python/knot_resolver/client/commands/convert.py | 4 ++-- python/knot_resolver/client/commands/metrics.py | 4 ++-- python/knot_resolver/client/commands/schema.py | 5 ++--- python/knot_resolver/client/commands/validate.py | 4 ++-- 4 files changed, 8 insertions(+), 9 deletions(-) diff --git a/python/knot_resolver/client/commands/convert.py b/python/knot_resolver/client/commands/convert.py index 412ed334c..b72af758b 100644 --- a/python/knot_resolver/client/commands/convert.py +++ b/python/knot_resolver/client/commands/convert.py @@ -3,7 +3,7 @@ from pathlib import Path from typing import List, Optional, Tuple, Type -from knot_resolver.client.command import Command, CommandArgs, CompWords, register_command +from knot_resolver.client.command import Command, CommandArgs, CompWords, get_subparsers_words, register_command from knot_resolver.datamodel import KresConfig from knot_resolver.datamodel.globals import Context, reset_global_validation_context, set_global_validation_context from knot_resolver.utils.modeling import try_to_parse @@ -52,7 +52,7 @@ def register_args_subparser( @staticmethod def completion(args: List[str], parser: argparse.ArgumentParser) -> CompWords: - return {} + return get_subparsers_words(parser._actions) # noqa: SLF001 def run(self, args: CommandArgs) -> None: with open(self.input_file, "r") as f: diff --git a/python/knot_resolver/client/commands/metrics.py b/python/knot_resolver/client/commands/metrics.py index 058cad8b2..85cff2583 100644 --- a/python/knot_resolver/client/commands/metrics.py +++ b/python/knot_resolver/client/commands/metrics.py @@ -2,7 +2,7 @@ import sys from typing import List, Optional, Tuple, Type -from knot_resolver.client.command import Command, CommandArgs, CompWords, register_command +from knot_resolver.client.command import Command, CommandArgs, CompWords, get_subparsers_words, register_command from knot_resolver.utils.modeling.parsing import DataFormat, parse_json from knot_resolver.utils.requests import request @@ -44,7 +44,7 @@ def register_args_subparser( @staticmethod def completion(args: List[str], parser: argparse.ArgumentParser) -> CompWords: - return {} + return get_subparsers_words(parser._actions) # noqa: SLF001 def run(self, args: CommandArgs) -> None: response = request(args.socket, "GET", "metrics/prometheus" if self.prometheus else "metrics/json") diff --git a/python/knot_resolver/client/commands/schema.py b/python/knot_resolver/client/commands/schema.py index 0c63f398e..fa7465c17 100644 --- a/python/knot_resolver/client/commands/schema.py +++ b/python/knot_resolver/client/commands/schema.py @@ -3,7 +3,7 @@ import sys from typing import List, Optional, Tuple, Type -from knot_resolver.client.command import Command, CommandArgs, CompWords, register_command +from knot_resolver.client.command import Command, CommandArgs, CompWords, get_subparsers_words, register_command from knot_resolver.datamodel import kres_config_json_schema from knot_resolver.utils.requests import request @@ -35,8 +35,7 @@ def register_args_subparser( @staticmethod def completion(args: List[str], parser: argparse.ArgumentParser) -> CompWords: - return {} - # return parser_words(parser._actions) # pylint: disable=W0212 + return get_subparsers_words(parser._actions) # noqa: SLF001 def run(self, args: CommandArgs) -> None: if self.live: diff --git a/python/knot_resolver/client/commands/validate.py b/python/knot_resolver/client/commands/validate.py index f74777481..141a20030 100644 --- a/python/knot_resolver/client/commands/validate.py +++ b/python/knot_resolver/client/commands/validate.py @@ -3,7 +3,7 @@ from pathlib import Path from typing import List, Tuple, Type -from knot_resolver.client.command import Command, CommandArgs, CompWords, register_command +from knot_resolver.client.command import Command, CommandArgs, CompWords, get_subparsers_words, register_command from knot_resolver.datamodel import KresConfig from knot_resolver.datamodel.globals import Context, reset_global_validation_context, set_global_validation_context from knot_resolver.utils.modeling import try_to_parse @@ -41,7 +41,7 @@ def register_args_subparser( @staticmethod def completion(args: List[str], parser: argparse.ArgumentParser) -> CompWords: - return {} + return get_subparsers_words(parser._actions) # noqa: SLF001 def run(self, args: CommandArgs) -> None: if self.input_file: From f984af0096ebe50d866a94ff66aadfe926f18d20 Mon Sep 17 00:00:00 2001 From: Frantisek Tobias Date: Tue, 15 Oct 2024 12:40:23 +0200 Subject: [PATCH 04/16] kresctl: tab-completion: enable config completion --- .../knot_resolver/client/commands/config.py | 109 ++++++++++-------- 1 file changed, 58 insertions(+), 51 deletions(-) diff --git a/python/knot_resolver/client/commands/config.py b/python/knot_resolver/client/commands/config.py index 52df39c41..dbf6ccaa1 100644 --- a/python/knot_resolver/client/commands/config.py +++ b/python/knot_resolver/client/commands/config.py @@ -1,9 +1,10 @@ import argparse import sys from enum import Enum -from typing import List, Literal, Optional, Tuple, Type +from typing import Any, Dict, List, Literal, Optional, Tuple, Type -from knot_resolver.client.command import Command, CommandArgs, CompWords, register_command +from knot_resolver.client.command import Command, CommandArgs, CompWords, get_subparsers_words, register_command +from knot_resolver.datamodel import KresConfig from knot_resolver.utils.modeling.parsing import DataFormat, parse_json, try_to_parse from knot_resolver.utils.requests import request @@ -22,54 +23,53 @@ def operation_to_method(operation: Operations) -> Literal["PUT", "GET", "DELETE" return "GET" -# def _properties_words(props: Dict[str, Any]) -> CompWords: -# words: CompWords = {} -# for name, prop in props.items(): -# words[name] = prop["description"] if "description" in prop else None -# return words - - -# def _path_comp_words(node: str, nodes: List[str], props: Dict[str, Any]) -> CompWords: -# i = nodes.index(node) -# ln = len(nodes[i:]) - -# # if node is last in path, return all possible words on thi level -# if ln == 1: -# return _properties_words(props) -# # if node is valid -# elif node in props: -# node_schema = props[node] - -# if "anyOf" in node_schema: -# for item in node_schema["anyOf"]: -# print(item) - -# elif "type" not in node_schema: -# pass - -# elif node_schema["type"] == "array": -# if ln > 2: -# # skip index for item in array -# return _path_comp_words(nodes[i + 2], nodes, node_schema["items"]["properties"]) -# if "enum" in node_schema["items"]: -# print(node_schema["items"]["enum"]) -# return {"0": "first array item", "-": "last array item"} -# elif node_schema["type"] == "object": -# if "additionalProperties" in node_schema: -# print(node_schema) -# return _path_comp_words(nodes[i + 1], nodes, node_schema["properties"]) -# return {} - -# # arrays/lists must be handled sparately -# if node_schema["type"] == "array": -# if ln > 2: -# # skip index for item in array -# return _path_comp_words(nodes[i + 2], nodes, node_schema["items"]["properties"]) -# return {"0": "first array item", "-": "last array item"} -# return _path_comp_words(nodes[i + 1], nodes, node_schema["properties"]) -# else: -# # if node is not last or valid, value error -# raise ValueError(f"unknown config path node: {node}") +def _properties_words(props: Dict[str, Any]) -> CompWords: + words: CompWords = {} + for name, prop in props.items(): + words[name] = prop["description"] if "description" in prop else None + return words + + +def _path_comp_words(node: str, nodes: List[str], props: Dict[str, Any]) -> CompWords: # noqa: PLR0911, PLR0912 + i = nodes.index(node) + ln = len(nodes[i:]) + + # if node is last in path, return all possible words on thi level + if ln == 1: + return _properties_words(props) + # if node is valid + if node in props: + node_schema = props[node] + + if "anyOf" in node_schema: + for item in node_schema["anyOf"]: + print(item) + + elif "type" not in node_schema: + pass + + elif node_schema["type"] == "array": + if ln > 2: + # skip index for item in array + return _path_comp_words(nodes[i + 2], nodes, node_schema["items"]["properties"]) + if "enum" in node_schema["items"]: + print(node_schema["items"]["enum"]) + return {"0": "first array item", "-": "last array item"} + elif node_schema["type"] == "object": + if "additionalProperties" in node_schema: + print(node_schema) + return _path_comp_words(nodes[i + 1], nodes, node_schema["properties"]) + # return {} + + # arrays/lists must be handled sparately + if node_schema["type"] == "array": + if ln > 2: + # skip index for item in array + return _path_comp_words(nodes[i + 2], nodes, node_schema["items"]["properties"]) + return {"0": "first array item", "-": "last array item"} + return _path_comp_words(nodes[i + 1], nodes, node_schema["properties"]) + # if node is not last or valid, value error + raise ValueError(f"unknown config path node: {node}") @register_command @@ -170,7 +170,14 @@ def register_args_subparser( @staticmethod def completion(args: List[str], parser: argparse.ArgumentParser) -> CompWords: - # words = parser_words(parser._actions) # pylint: disable=W0212 + words = get_subparsers_words(parser._actions) # noqa: SLF001 + if args is None: + return words + + arg = args[-1] + config_path = arg[1:].split("/") if arg.startswith("/") else arg.split("/") + schema_props: Dict[str, Any] = KresConfig.json_schema()["properties"] + return _path_comp_words(config_path[0], config_path, schema_props) # for arg in args: # if arg in words: From 56bf368ac66c632a7497944ee4c3431137b648e3 Mon Sep 17 00:00:00 2001 From: Frantisek Tobias Date: Fri, 11 Oct 2024 09:12:34 +0200 Subject: [PATCH 05/16] utils/shell-completion/client.bash: skip program name in COMP_WORDS --- utils/shell-completion/client.bash | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/utils/shell-completion/client.bash b/utils/shell-completion/client.bash index b3c194197..3a946dc4e 100644 --- a/utils/shell-completion/client.bash +++ b/utils/shell-completion/client.bash @@ -13,9 +13,9 @@ _kresctl_completion() if [[ -z "$cur" ]] then # no word to complete, return all posible options - opts=$(kresctl completion --bash --space "${COMP_WORDS}") + opts=$(kresctl completion --bash --space "${cmd_words[@]:1}") else - opts=$(kresctl completion --bash "${COMP_WORDS}") + opts=$(kresctl completion --bash "${cmd_words[@]:1}") fi # if there is no completion from kresctl From ae416c62217a5421638440996462dd432742a83b Mon Sep 17 00:00:00 2001 From: Frantisek Tobias Date: Tue, 15 Oct 2024 12:41:52 +0200 Subject: [PATCH 06/16] utils/shell-completion/client.bash: compgen escape '--' arguments and minor adjustments --- utils/shell-completion/client.bash | 33 +++++++++++------------------- 1 file changed, 12 insertions(+), 21 deletions(-) diff --git a/utils/shell-completion/client.bash b/utils/shell-completion/client.bash index 3a946dc4e..a1c8290b0 100644 --- a/utils/shell-completion/client.bash +++ b/utils/shell-completion/client.bash @@ -3,31 +3,22 @@ _kresctl_completion() { COMPREPLY=() - local cur prev opts + local words="" + local space_arg="" + local cur="${COMP_WORDS[COMP_CWORD]}" - cur="${COMP_WORDS[COMP_CWORD]}" - prev="${COMP_WORDS[COMP_CWORD-1]}" - - # check if there is a word is empty - # that means there is a space after last non-empty word - if [[ -z "$cur" ]] - then - # no word to complete, return all posible options - opts=$(kresctl completion --bash --space "${cmd_words[@]:1}") - else - opts=$(kresctl completion --bash "${cmd_words[@]:1}") + # if the current word is empty + # we need to inform the kresctl client about it + if [[ -z "$cur" ]]; then + space_arg="--space" fi - # if there is no completion from kresctl - # auto-complete just directories and files - if [[ -z "$opts" ]] - then - COMPREPLY=($(compgen -d -f "${cur}")) - else - COMPREPLY=( $(compgen -W "${opts}" ${cur}) ) - fi + # get words from the kresctl client + words=$(kresctl completion --bash ${space_arg} --args "${COMP_WORDS[@]:1}") + + COMPREPLY=($(compgen -W "${words}" -- "${cur}")) return 0 } -complete -o filenames -o dirnames -F _kresctl_completion kresctl +complete -o filenames -o dirnames -o nosort -F _kresctl_completion kresctl From f21c1add91c57f52240b6d60c69416acf5d596ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ale=C5=A1=20Mr=C3=A1zek?= Date: Fri, 6 Dec 2024 16:06:28 +0100 Subject: [PATCH 07/16] python: client: completion: improved top-level behavior --- python/knot_resolver/client/command.py | 7 +-- .../client/commands/completion.py | 60 +++++++++++++------ 2 files changed, 46 insertions(+), 21 deletions(-) diff --git a/python/knot_resolver/client/command.py b/python/knot_resolver/client/command.py index c30558449..5a0d11a75 100644 --- a/python/knot_resolver/client/command.py +++ b/python/knot_resolver/client/command.py @@ -29,11 +29,10 @@ def get_subparsers_words(subparser_actions: List[argparse.Action]) -> CompWords: return words -def get_subparser_by_name(name: str, parser_actions: List[argparse.Action]) -> Optional[argparse.ArgumentParser]: +def get_action_by_name(name: str, parser_actions: List[argparse.Action]) -> Optional[argparse.Action]: for action in parser_actions: - if isinstance(action, argparse._SubParsersAction): # noqa: SLF001 - if action.choices and name in action.choices: - return action.choices[name] + if (action.choices and name in action.choices) or (action.option_strings and name in action.option_strings): + return action return None diff --git a/python/knot_resolver/client/commands/completion.py b/python/knot_resolver/client/commands/completion.py index abb7b6128..1da8c5164 100644 --- a/python/knot_resolver/client/commands/completion.py +++ b/python/knot_resolver/client/commands/completion.py @@ -1,12 +1,12 @@ import argparse from enum import Enum -from typing import List, Tuple, Type +from typing import List, Optional, Tuple, Type from knot_resolver.client.command import ( Command, CommandArgs, CompWords, - get_subparser_by_name, + get_action_by_name, get_subparser_command, get_subparsers_words, register_command, @@ -58,31 +58,57 @@ def register_args_subparser( def completion(args: List[str], parser: argparse.ArgumentParser) -> CompWords: return get_subparsers_words(parser._actions) # noqa: SLF001 - def run(self, args: CommandArgs) -> None: + def run(self, args: CommandArgs) -> None: # noqa: PLR0912 subparsers = args.parser._subparsers # noqa: SLF001 words: CompWords = {} if subparsers: words = get_subparsers_words(subparsers._actions) # noqa: SLF001 - uargs = iter(self.args) - for uarg in uargs: - subparser = get_subparser_by_name(uarg, subparsers._actions) # noqa: SLF001 + args_iter = iter(self.args) + for arg in args_iter: + action: Optional[argparse.Action] = get_action_by_name(arg, subparsers._actions) # noqa: SLF001 - if subparser: - cmd: Command = get_subparser_command(subparser) - subparser_args = self.args[self.args.index(uarg) + 1 :] - if subparser_args or self.space: - words = cmd.completion(subparser_args, subparser) + # if action is SubParserAction; complete using the command + if isinstance(action, argparse._SubParsersAction) and arg in action.choices: # noqa: SLF001 + # remove from words + for choice in action.choices: + del words[choice] + + subparser = action.choices[arg] + cmd = get_subparser_command(subparser) + + nargs = len(self.args) + index = self.args.index(arg) + 1 + # check that index is not out of args length + if index > nargs: + break + + # complete using the command + words = cmd.completion(self.args[index:], subparser) break - if uarg in ["-s", "--socket", "-c", "--config"]: - # if arg is socket config, skip next arg - next(uargs) - continue - if uarg in words: - # uarg is valid (complete) arg, continue + + # if action is StoreAction; skip number of arguments + if isinstance(action, argparse._StoreAction) and arg in action.option_strings: # noqa: SLF001 + # remove from words + for option_string in action.option_strings: + del words[option_string] + + if action.nargs and isinstance(action.nargs, int): + for _ in range(action.nargs): + next(args_iter) continue + # remove other options from words + if action and action.option_strings: + for option_string in action.option_strings: + del words[option_string] + + # if 'arg' is not found in actions + # there is nothing to complete + if not action: + break + # print completion words # based on required bash/fish shell format if self.shell == Shells.BASH: From 06f06caa559b46931b8be63bffe8bd79baf0b483 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ale=C5=A1=20Mr=C3=A1zek?= Date: Mon, 16 Dec 2024 18:04:47 +0100 Subject: [PATCH 08/16] python: client: command: move the getting of completion words to function - #dirnames# and #filenames# words to indicate that we want complete also files and dirs --- python/knot_resolver/client/command.py | 95 +++++++++++++++---- python/knot_resolver/client/commands/cache.py | 4 +- .../client/commands/completion.py | 67 +------------ .../knot_resolver/client/commands/config.py | 4 +- .../knot_resolver/client/commands/convert.py | 6 +- python/knot_resolver/client/commands/debug.py | 19 ++-- python/knot_resolver/client/commands/help.py | 4 +- .../knot_resolver/client/commands/metrics.py | 4 +- .../knot_resolver/client/commands/schema.py | 4 +- .../knot_resolver/client/commands/validate.py | 4 +- utils/shell-completion/client.bash | 27 +++--- 11 files changed, 123 insertions(+), 115 deletions(-) diff --git a/python/knot_resolver/client/command.py b/python/knot_resolver/client/command.py index 5a0d11a75..e4eddf087 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 Dict, List, Optional, Tuple, Type, TypeVar from urllib.parse import quote from knot_resolver.constants import API_SOCK_FILE, CONFIG_FILE @@ -14,33 +14,96 @@ CompWords = Dict[str, Optional[str]] +COMP_DIRNAMES = "#dirnames#" +COMP_FILENAMES = "#filenames#" + _registered_commands: List[Type["Command"]] = [] -def get_subparsers_words(subparser_actions: List[argparse.Action]) -> CompWords: +def get_parser_action(name: str, parser_actions: List[argparse.Action]) -> Optional[argparse.Action]: + for action in parser_actions: + if (action.choices and name in action.choices) or (action.option_strings and name in action.option_strings): + return action + return None + + +def get_subparser_command(subparser: argparse.ArgumentParser) -> Optional["Command"]: + if "command" in subparser._defaults: # noqa: SLF001 + return subparser._defaults["command"] # noqa: SLF001 + return None + + +def comp_get_actions_words(parser_actions: List[argparse.Action]) -> CompWords: words: CompWords = {} - for action in subparser_actions: + for action in parser_actions: if isinstance(action, argparse._SubParsersAction) and action.choices: # noqa: SLF001 for choice, parser in action.choices.items(): - words[choice] = parser.description - else: + words[choice] = parser.description if isinstance(parser, argparse.ArgumentParser) else None + elif action.option_strings: for opt in action.option_strings: words[opt] = action.help + elif not action.option_strings and action.choices: + for choice in action.choices: + words[choice] = action.help + elif not action.option_strings and not action.choices: + words[COMP_DIRNAMES] = None + words[COMP_FILENAMES] = None return words -def get_action_by_name(name: str, parser_actions: List[argparse.Action]) -> Optional[argparse.Action]: - for action in parser_actions: - if (action.choices and name in action.choices) or (action.option_strings and name in action.option_strings): - return action - return None - +def comp_get_words(args: List[str], parser_actions: List[argparse.Action]) -> CompWords: # noqa: PLR0912 + words: CompWords = comp_get_actions_words(parser_actions) + nargs = len(args) + + skip_arg = False + for i, arg in enumerate(args): + action: Optional[argparse.Action] = get_parser_action(arg, parser_actions) # noqa: SLF001 + + if skip_arg: + skip_arg = False + continue + + if not action: + words = comp_get_actions_words(parser_actions) + continue + + if i + 1 >= nargs: + return words + + # if not action or action is HelpAction or VersionAction + if isinstance(action, (argparse._HelpAction, argparse._VersionAction)): # noqa: SLF001 + words = {} + break + + # if action is StoreTrueAction or StoreFalseAction + if isinstance(action, argparse._StoreConstAction): # noqa: SLF001 + continue + + # if action is StoreAction + if isinstance(action, argparse._StoreAction): # noqa: SLF001 + choices = {} + if action.choices: + for choice in action.choices: + choices[choice] = action.help + else: + choices[COMP_DIRNAMES] = None + choices[COMP_FILENAMES] = None + words = choices + skip_arg = True + continue + + # if action is SubParserAction + if isinstance(action, argparse._SubParsersAction): # noqa: SLF001 + subparser = action.choices[arg] + cmd = get_subparser_command(subparser) + return cmd.completion(args[i + 1 :], subparser) if cmd else {} + + # delete already used args + for arg in args: + if arg in words.keys(): + del words[arg] -def get_subparser_command(subparser: argparse.ArgumentParser) -> "Command": - defaults: Dict[str, Any] = subparser._defaults # noqa: SLF001 - if "command" in defaults: - return defaults["command"] - raise ValueError(f"missing 'command' default for '{subparser.prog}' parser") + return words def register_command(cls: T) -> T: diff --git a/python/knot_resolver/client/commands/cache.py b/python/knot_resolver/client/commands/cache.py index d11165808..66ee77c96 100644 --- a/python/knot_resolver/client/commands/cache.py +++ b/python/knot_resolver/client/commands/cache.py @@ -3,7 +3,7 @@ from enum import Enum from typing import Any, Dict, List, Optional, Tuple, Type -from knot_resolver.client.command import Command, CommandArgs, CompWords, get_subparsers_words, register_command +from knot_resolver.client.command import Command, CommandArgs, CompWords, comp_get_words, register_command from knot_resolver.datamodel.cache_schema import CacheClearRPCSchema from knot_resolver.utils.modeling.exceptions import AggregateDataValidationError, DataValidationError from knot_resolver.utils.modeling.parsing import DataFormat, parse_json @@ -99,7 +99,7 @@ def register_args_subparser( @staticmethod def completion(args: List[str], parser: argparse.ArgumentParser) -> CompWords: - return get_subparsers_words(parser._actions) # noqa: SLF001 + return comp_get_words(args, parser._actions) # noqa: SLF001 def run(self, args: CommandArgs) -> None: if not self.operation: diff --git a/python/knot_resolver/client/commands/completion.py b/python/knot_resolver/client/commands/completion.py index 1da8c5164..f3101c197 100644 --- a/python/knot_resolver/client/commands/completion.py +++ b/python/knot_resolver/client/commands/completion.py @@ -1,14 +1,12 @@ import argparse from enum import Enum -from typing import List, Optional, Tuple, Type +from typing import List, Tuple, Type from knot_resolver.client.command import ( Command, CommandArgs, CompWords, - get_action_by_name, - get_subparser_command, - get_subparsers_words, + comp_get_words, register_command, ) @@ -23,12 +21,8 @@ class CompletionCommand(Command): def __init__(self, namespace: argparse.Namespace) -> None: super().__init__(namespace) self.shell: Shells = namespace.shell - self.space = namespace.space self.args: List[str] = namespace.args - if self.space: - self.args.append("") - @staticmethod def register_args_subparser( subparser: "argparse._SubParsersAction[argparse.ArgumentParser]", @@ -37,13 +31,6 @@ def register_args_subparser( "completion", help="commands auto-completion", ) - completion.add_argument( - "--space", - help="space after last word, returns all possible folowing options", - dest="space", - action="store_true", - default=False, - ) shells_dest = "shell" shells = completion.add_mutually_exclusive_group() @@ -56,58 +43,14 @@ def register_args_subparser( @staticmethod def completion(args: List[str], parser: argparse.ArgumentParser) -> CompWords: - return get_subparsers_words(parser._actions) # noqa: SLF001 + return comp_get_words(args, parser._actions) # noqa: SLF001 def run(self, args: CommandArgs) -> None: # noqa: PLR0912 - subparsers = args.parser._subparsers # noqa: SLF001 words: CompWords = {} + subparsers = args.parser._subparsers # noqa: SLF001 if subparsers: - words = get_subparsers_words(subparsers._actions) # noqa: SLF001 - - args_iter = iter(self.args) - for arg in args_iter: - action: Optional[argparse.Action] = get_action_by_name(arg, subparsers._actions) # noqa: SLF001 - - # if action is SubParserAction; complete using the command - if isinstance(action, argparse._SubParsersAction) and arg in action.choices: # noqa: SLF001 - # remove from words - for choice in action.choices: - del words[choice] - - subparser = action.choices[arg] - cmd = get_subparser_command(subparser) - - nargs = len(self.args) - index = self.args.index(arg) + 1 - # check that index is not out of args length - if index > nargs: - break - - # complete using the command - words = cmd.completion(self.args[index:], subparser) - break - - # if action is StoreAction; skip number of arguments - if isinstance(action, argparse._StoreAction) and arg in action.option_strings: # noqa: SLF001 - # remove from words - for option_string in action.option_strings: - del words[option_string] - - if action.nargs and isinstance(action.nargs, int): - for _ in range(action.nargs): - next(args_iter) - continue - - # remove other options from words - if action and action.option_strings: - for option_string in action.option_strings: - del words[option_string] - - # if 'arg' is not found in actions - # there is nothing to complete - if not action: - break + words = comp_get_words(self.args, subparsers._actions) # noqa: SLF001 # print completion words # based on required bash/fish shell format diff --git a/python/knot_resolver/client/commands/config.py b/python/knot_resolver/client/commands/config.py index dbf6ccaa1..3ebc96ec5 100644 --- a/python/knot_resolver/client/commands/config.py +++ b/python/knot_resolver/client/commands/config.py @@ -3,7 +3,7 @@ from enum import Enum from typing import Any, Dict, List, Literal, Optional, Tuple, Type -from knot_resolver.client.command import Command, CommandArgs, CompWords, get_subparsers_words, register_command +from knot_resolver.client.command import Command, CommandArgs, CompWords, comp_get_words, register_command from knot_resolver.datamodel import KresConfig from knot_resolver.utils.modeling.parsing import DataFormat, parse_json, try_to_parse from knot_resolver.utils.requests import request @@ -170,7 +170,7 @@ def register_args_subparser( @staticmethod def completion(args: List[str], parser: argparse.ArgumentParser) -> CompWords: - words = get_subparsers_words(parser._actions) # noqa: SLF001 + words = comp_get_words(args, parser._actions) # noqa: SLF001 if args is None: return words diff --git a/python/knot_resolver/client/commands/convert.py b/python/knot_resolver/client/commands/convert.py index b72af758b..28a5be957 100644 --- a/python/knot_resolver/client/commands/convert.py +++ b/python/knot_resolver/client/commands/convert.py @@ -3,7 +3,7 @@ from pathlib import Path from typing import List, Optional, Tuple, Type -from knot_resolver.client.command import Command, CommandArgs, CompWords, get_subparsers_words, register_command +from knot_resolver.client.command import Command, CommandArgs, CompWords, comp_get_words, register_command from knot_resolver.datamodel import KresConfig from knot_resolver.datamodel.globals import Context, reset_global_validation_context, set_global_validation_context from knot_resolver.utils.modeling import try_to_parse @@ -39,7 +39,6 @@ def register_args_subparser( type=str, help="File with configuration in YAML or JSON format.", ) - convert.add_argument( "output_file", type=str, @@ -47,12 +46,11 @@ def register_args_subparser( help="Optional, output file for converted configuration in Lua script. If not specified, converted configuration is printed.", default=None, ) - return convert, ConvertCommand @staticmethod def completion(args: List[str], parser: argparse.ArgumentParser) -> CompWords: - return get_subparsers_words(parser._actions) # noqa: SLF001 + return comp_get_words(args, parser._actions) # noqa: SLF001 def run(self, args: CommandArgs) -> None: with open(self.input_file, "r") as f: diff --git a/python/knot_resolver/client/commands/debug.py b/python/knot_resolver/client/commands/debug.py index 5d9a81df0..00853c034 100644 --- a/python/knot_resolver/client/commands/debug.py +++ b/python/knot_resolver/client/commands/debug.py @@ -5,7 +5,7 @@ from pathlib import Path from typing import List, Optional, Tuple, Type -from knot_resolver.client.command import Command, CommandArgs, CompWords, register_command +from knot_resolver.client.command import Command, CommandArgs, CompWords, comp_get_words, register_command from knot_resolver.utils import which from knot_resolver.utils.requests import request @@ -30,13 +30,6 @@ def register_args_subparser( "debug", help="Run GDB on the manager's subprocesses", ) - debug.add_argument( - "proc_type", - help="Optional, the type of process to debug. May be 'kresd' (default), 'gc', or 'all'.", - type=str, - nargs="?", - default="kresd", - ) debug.add_argument( "--sudo", dest="sudo", @@ -56,11 +49,19 @@ def register_args_subparser( action="store_true", default=False, ) + debug.add_argument( + "proc_type", + help="Optional, the type of process to debug. May be 'kresd', 'gc', or 'all'.", + choices=["kresd", "gc", "all"], + type=str, + nargs="?", + default="kresd", + ) return debug, DebugCommand @staticmethod def completion(args: List[str], parser: argparse.ArgumentParser) -> CompWords: - return {} + return comp_get_words(args, parser._actions) # noqa: SLF001 def run(self, args: CommandArgs) -> None: # noqa: PLR0912, PLR0915 if self.gdb is None: diff --git a/python/knot_resolver/client/commands/help.py b/python/knot_resolver/client/commands/help.py index 87306c2ab..1db83fb61 100644 --- a/python/knot_resolver/client/commands/help.py +++ b/python/knot_resolver/client/commands/help.py @@ -1,7 +1,7 @@ import argparse from typing import List, Tuple, Type -from knot_resolver.client.command import Command, CommandArgs, CompWords, register_command +from knot_resolver.client.command import Command, CommandArgs, CompWords, comp_get_words, register_command @register_command @@ -14,7 +14,7 @@ def run(self, args: CommandArgs) -> None: @staticmethod def completion(args: List[str], parser: argparse.ArgumentParser) -> CompWords: - return {} + return comp_get_words(args, parser._actions) # noqa: SLF001 @staticmethod def register_args_subparser( diff --git a/python/knot_resolver/client/commands/metrics.py b/python/knot_resolver/client/commands/metrics.py index 85cff2583..eaf83090d 100644 --- a/python/knot_resolver/client/commands/metrics.py +++ b/python/knot_resolver/client/commands/metrics.py @@ -2,7 +2,7 @@ import sys from typing import List, Optional, Tuple, Type -from knot_resolver.client.command import Command, CommandArgs, CompWords, get_subparsers_words, register_command +from knot_resolver.client.command import Command, CommandArgs, CompWords, comp_get_words, register_command from knot_resolver.utils.modeling.parsing import DataFormat, parse_json from knot_resolver.utils.requests import request @@ -44,7 +44,7 @@ def register_args_subparser( @staticmethod def completion(args: List[str], parser: argparse.ArgumentParser) -> CompWords: - return get_subparsers_words(parser._actions) # noqa: SLF001 + return comp_get_words(args, parser._actions) # noqa: SLF001 def run(self, args: CommandArgs) -> None: response = request(args.socket, "GET", "metrics/prometheus" if self.prometheus else "metrics/json") diff --git a/python/knot_resolver/client/commands/schema.py b/python/knot_resolver/client/commands/schema.py index fa7465c17..fdb65bdee 100644 --- a/python/knot_resolver/client/commands/schema.py +++ b/python/knot_resolver/client/commands/schema.py @@ -3,7 +3,7 @@ import sys from typing import List, Optional, Tuple, Type -from knot_resolver.client.command import Command, CommandArgs, CompWords, get_subparsers_words, register_command +from knot_resolver.client.command import Command, CommandArgs, CompWords, comp_get_words, register_command from knot_resolver.datamodel import kres_config_json_schema from knot_resolver.utils.requests import request @@ -35,7 +35,7 @@ def register_args_subparser( @staticmethod def completion(args: List[str], parser: argparse.ArgumentParser) -> CompWords: - return get_subparsers_words(parser._actions) # noqa: SLF001 + return comp_get_words(args, parser._actions) # noqa: SLF001 def run(self, args: CommandArgs) -> None: if self.live: diff --git a/python/knot_resolver/client/commands/validate.py b/python/knot_resolver/client/commands/validate.py index 141a20030..2347519ee 100644 --- a/python/knot_resolver/client/commands/validate.py +++ b/python/knot_resolver/client/commands/validate.py @@ -3,7 +3,7 @@ from pathlib import Path from typing import List, Tuple, Type -from knot_resolver.client.command import Command, CommandArgs, CompWords, get_subparsers_words, register_command +from knot_resolver.client.command import Command, CommandArgs, CompWords, comp_get_words, register_command from knot_resolver.datamodel import KresConfig from knot_resolver.datamodel.globals import Context, reset_global_validation_context, set_global_validation_context from knot_resolver.utils.modeling import try_to_parse @@ -41,7 +41,7 @@ def register_args_subparser( @staticmethod def completion(args: List[str], parser: argparse.ArgumentParser) -> CompWords: - return get_subparsers_words(parser._actions) # noqa: SLF001 + return comp_get_words(args, parser._actions) # noqa: SLF001 def run(self, args: CommandArgs) -> None: if self.input_file: diff --git a/utils/shell-completion/client.bash b/utils/shell-completion/client.bash index a1c8290b0..0df57f316 100644 --- a/utils/shell-completion/client.bash +++ b/utils/shell-completion/client.bash @@ -1,24 +1,27 @@ -#/usr/bin/env bash +#!/usr/bin/env bash _kresctl_completion() { COMPREPLY=() + local args="" local words="" - local space_arg="" local cur="${COMP_WORDS[COMP_CWORD]}" + local opts=$(kresctl completion --bash --args "${COMP_WORDS[@]:1}") - # if the current word is empty - # we need to inform the kresctl client about it - if [[ -z "$cur" ]]; then - space_arg="--space" + # filter special opts + for opt in $opts + do + if [[ "$opt" == "#dirnames#" ]]; then + args="$args${args:+ }-d" + elif [[ "$opt" == "#filenames#" ]]; then + args="$args${args:+ }-f" + else + words="$words${words:+ }$opt" fi + done - # get words from the kresctl client - words=$(kresctl completion --bash ${space_arg} --args "${COMP_WORDS[@]:1}") - - COMPREPLY=($(compgen -W "${words}" -- "${cur}")) - + COMPREPLY=($(compgen $args -W "${words}" -- "${cur}")) return 0 } -complete -o filenames -o dirnames -o nosort -F _kresctl_completion kresctl +complete -o nosort -F _kresctl_completion kresctl From 901bd946f2a26fe1d786ec43b0771be4e0eeb9c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ale=C5=A1=20Mr=C3=A1zek?= Date: Mon, 16 Dec 2024 18:14:15 +0100 Subject: [PATCH 09/16] python: client: handle the escape '--' argument better because we need it in completion --- python/knot_resolver/client/commands/completion.py | 2 ++ python/knot_resolver/client/commands/debug.py | 2 +- python/knot_resolver/client/main.py | 2 +- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/python/knot_resolver/client/commands/completion.py b/python/knot_resolver/client/commands/completion.py index f3101c197..75f8e1eb0 100644 --- a/python/knot_resolver/client/commands/completion.py +++ b/python/knot_resolver/client/commands/completion.py @@ -22,6 +22,8 @@ def __init__(self, namespace: argparse.Namespace) -> None: super().__init__(namespace) self.shell: Shells = namespace.shell self.args: List[str] = namespace.args + if namespace.extra is not None: + self.args.append("--") @staticmethod def register_args_subparser( diff --git a/python/knot_resolver/client/commands/debug.py b/python/knot_resolver/client/commands/debug.py index 00853c034..71ce673ed 100644 --- a/python/knot_resolver/client/commands/debug.py +++ b/python/knot_resolver/client/commands/debug.py @@ -19,7 +19,7 @@ def __init__(self, namespace: argparse.Namespace) -> None: self.sudo: bool = namespace.sudo self.gdb: str = namespace.gdb self.print_only: bool = namespace.print_only - self.gdb_args: List[str] = namespace.extra + self.gdb_args: List[str] = namespace.extra if namespace.extra is not None else [] super().__init__(namespace) @staticmethod diff --git a/python/knot_resolver/client/main.py b/python/knot_resolver/client/main.py index 461b7fc42..683bc95b0 100644 --- a/python/knot_resolver/client/main.py +++ b/python/knot_resolver/client/main.py @@ -77,7 +77,7 @@ def main() -> None: argv_extra = sys.argv[(pa_index + 1) :] except ValueError: argv_to_parse = sys.argv[1:] - argv_extra = [] + argv_extra = None namespace = parser.parse_args(argv_to_parse) if hasattr(namespace, "extra"): From a03f64f698cf8eae8767ebf5aa39294c9b0f88dd Mon Sep 17 00:00:00 2001 From: Frantisek Tobias Date: Fri, 6 Dec 2024 16:20:34 +0100 Subject: [PATCH 10/16] python: client: add completion for all config layers --- .../knot_resolver/client/commands/config.py | 108 ++++++------------ 1 file changed, 37 insertions(+), 71 deletions(-) diff --git a/python/knot_resolver/client/commands/config.py b/python/knot_resolver/client/commands/config.py index 3ebc96ec5..de25f30aa 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 List, Literal, Optional, Tuple, Type from knot_resolver.client.command import Command, CommandArgs, CompWords, comp_get_words, register_command from knot_resolver.datamodel import KresConfig @@ -23,55 +23,6 @@ def operation_to_method(operation: Operations) -> Literal["PUT", "GET", "DELETE" return "GET" -def _properties_words(props: Dict[str, Any]) -> CompWords: - words: CompWords = {} - for name, prop in props.items(): - words[name] = prop["description"] if "description" in prop else None - return words - - -def _path_comp_words(node: str, nodes: List[str], props: Dict[str, Any]) -> CompWords: # noqa: PLR0911, PLR0912 - i = nodes.index(node) - ln = len(nodes[i:]) - - # if node is last in path, return all possible words on thi level - if ln == 1: - return _properties_words(props) - # if node is valid - if node in props: - node_schema = props[node] - - if "anyOf" in node_schema: - for item in node_schema["anyOf"]: - print(item) - - elif "type" not in node_schema: - pass - - elif node_schema["type"] == "array": - if ln > 2: - # skip index for item in array - return _path_comp_words(nodes[i + 2], nodes, node_schema["items"]["properties"]) - if "enum" in node_schema["items"]: - print(node_schema["items"]["enum"]) - return {"0": "first array item", "-": "last array item"} - elif node_schema["type"] == "object": - if "additionalProperties" in node_schema: - print(node_schema) - return _path_comp_words(nodes[i + 1], nodes, node_schema["properties"]) - # return {} - - # arrays/lists must be handled sparately - if node_schema["type"] == "array": - if ln > 2: - # skip index for item in array - return _path_comp_words(nodes[i + 2], nodes, node_schema["items"]["properties"]) - return {"0": "first array item", "-": "last array item"} - return _path_comp_words(nodes[i + 1], nodes, node_schema["properties"]) - # if node is not last or valid, value error - raise ValueError(f"unknown config path node: {node}") - - @register_command class ConfigCommand(Command): def __init__(self, namespace: argparse.Namespace) -> None: @@ -141,7 +92,7 @@ def register_args_subparser( value_or_file = set_op.add_mutually_exclusive_group() value_or_file.add_argument( "file", - help="Optional, path to file with new configuraion.", + help="Optional, path to file with new configuration.", type=str, nargs="?", ) @@ -165,32 +116,47 @@ def register_args_subparser( type=str, default="", ) - return config, ConfigCommand @staticmethod def completion(args: List[str], parser: argparse.ArgumentParser) -> CompWords: - words = comp_get_words(args, parser._actions) # noqa: SLF001 - if args is None: + nargs = len(args) + + if nargs > 1 and args[-2] in ["-p", "--path"]: + words: CompWords = {} + path = args[-1] + path_nodes = path.split("/") + + prefix = "" + properties = KresConfig.json_schema()["properties"] + is_list = False + for i, node in enumerate(path_nodes): + # first node is empty string + if i == 0: + continue + + if node in properties: + is_list = False + if "properties" in properties[node]: + properties = properties[node]["properties"] + prefix += f"/{node}" + continue + if "items" in properties[node]: + properties = properties[node]["items"]["properties"] + prefix += f"/{node}" + is_list = True + continue + break + if is_list and node.isnumeric(): + prefix += f"/{node}" + continue + + for key in properties.keys(): + words[f"{prefix}/{key}"] = properties[key]["description"] + return words - arg = args[-1] - config_path = arg[1:].split("/") if arg.startswith("/") else arg.split("/") - schema_props: Dict[str, Any] = KresConfig.json_schema()["properties"] - return _path_comp_words(config_path[0], config_path, schema_props) - - # for arg in args: - # if arg in words: - # continue - # elif arg.startswith("-"): - # return words - # elif arg == args[-1]: - # config_path = arg[1:].split("/") if arg.startswith("/") else arg.split("/") - # schema_props: Dict[str, Any] = KresConfig.json_schema()["properties"] - # return _path_comp_words(config_path[0], config_path, schema_props) - # else: - # break - return {} + return comp_get_words(args, parser._actions) # noqa: SLF001 def run(self, args: CommandArgs) -> None: if not self.operation: From a48d032e8e3e1d66e0c9c050c710a410ea8cec7b Mon Sep 17 00:00:00 2001 From: Frantisek Tobias Date: Tue, 10 Dec 2024 08:23:44 +0100 Subject: [PATCH 11/16] kresctl: tab-completion: stop appending space after one config layer is completed --- python/knot_resolver/client/command.py | 42 ++++++++++++++++++- .../knot_resolver/client/commands/config.py | 4 +- utils/shell-completion/client.bash | 4 +- 3 files changed, 47 insertions(+), 3 deletions(-) diff --git a/python/knot_resolver/client/command.py b/python/knot_resolver/client/command.py index e4eddf087..79dd9bec2 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 Dict, List, Optional, Tuple, Type, TypeVar +from typing import Dict, List, Optional, Set, Tuple, Type, TypeVar from urllib.parse import quote from knot_resolver.constants import API_SOCK_FILE, CONFIG_FILE @@ -16,10 +16,50 @@ COMP_DIRNAMES = "#dirnames#" COMP_FILENAMES = "#filenames#" +COMP_NOSPACE = "#nospace#" _registered_commands: List[Type["Command"]] = [] +def get_mutually_exclusive_commands(parser: argparse.ArgumentParser) -> List[Set[str]]: + command_names: List[Set[str]] = [] + for group in parser._mutually_exclusive_groups: # noqa: SLF001 + command_names.append(set()) + for action in group._group_actions: # noqa: SLF001 + 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: # noqa: SLF001 + for choice, parser in action.choices.items(): + if is_unique_and_new(choice, args, exclusive, last): + words[choice] = parser.description + else: + for opt in action.option_strings: + if is_unique_and_new(opt, args, exclusive, last): + words[opt] = action.help + + return words + + def get_parser_action(name: str, parser_actions: List[argparse.Action]) -> Optional[argparse.Action]: for action in parser_actions: if (action.choices and name in action.choices) or (action.option_strings and name in action.option_strings): diff --git a/python/knot_resolver/client/commands/config.py b/python/knot_resolver/client/commands/config.py index de25f30aa..fef3ad79c 100644 --- a/python/knot_resolver/client/commands/config.py +++ b/python/knot_resolver/client/commands/config.py @@ -3,7 +3,7 @@ from enum import Enum from typing import List, Literal, Optional, Tuple, Type -from knot_resolver.client.command import Command, CommandArgs, CompWords, comp_get_words, register_command +from knot_resolver.client.command import COMP_NOSPACE, Command, CommandArgs, CompWords, comp_get_words, register_command from knot_resolver.datamodel import KresConfig from knot_resolver.utils.modeling.parsing import DataFormat, parse_json, try_to_parse from knot_resolver.utils.requests import request @@ -124,6 +124,8 @@ def completion(args: List[str], parser: argparse.ArgumentParser) -> CompWords: if nargs > 1 and args[-2] in ["-p", "--path"]: words: CompWords = {} + words[COMP_NOSPACE] = None + path = args[-1] path_nodes = path.split("/") diff --git a/utils/shell-completion/client.bash b/utils/shell-completion/client.bash index 0df57f316..5cf66723b 100644 --- a/utils/shell-completion/client.bash +++ b/utils/shell-completion/client.bash @@ -1,4 +1,4 @@ -#!/usr/bin/env bash +#/usr/bin/env bash _kresctl_completion() { @@ -15,6 +15,8 @@ _kresctl_completion() args="$args${args:+ }-d" elif [[ "$opt" == "#filenames#" ]]; then args="$args${args:+ }-f" + elif [[ "$opt" == "#nospace#" ]]; then + compopt -o nospace else words="$words${words:+ }$opt" fi From fabf8dd8de3f1707dc167d69bc36ce92cc7047d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ale=C5=A1=20Mr=C3=A1zek?= Date: Fri, 20 Dec 2024 00:33:49 +0100 Subject: [PATCH 12/16] python: client: completion: not to sugest already used args and alternatives to them --- python/knot_resolver/client/command.py | 97 ++++++++----------- python/knot_resolver/client/commands/cache.py | 2 +- .../client/commands/completion.py | 8 +- .../knot_resolver/client/commands/config.py | 2 +- .../knot_resolver/client/commands/convert.py | 2 +- python/knot_resolver/client/commands/debug.py | 2 +- python/knot_resolver/client/commands/help.py | 2 +- .../knot_resolver/client/commands/metrics.py | 2 +- .../knot_resolver/client/commands/schema.py | 2 +- .../knot_resolver/client/commands/validate.py | 2 +- 10 files changed, 53 insertions(+), 68 deletions(-) diff --git a/python/knot_resolver/client/command.py b/python/knot_resolver/client/command.py index 79dd9bec2..3966f8ca9 100644 --- a/python/knot_resolver/client/command.py +++ b/python/knot_resolver/client/command.py @@ -1,5 +1,5 @@ import argparse -from abc import ABC, abstractmethod # pylint: disable=[no-name-in-module] +from abc import ABC, abstractmethod from pathlib import Path from typing import Dict, List, Optional, Set, Tuple, Type, TypeVar from urllib.parse import quote @@ -21,43 +21,17 @@ _registered_commands: List[Type["Command"]] = [] -def get_mutually_exclusive_commands(parser: argparse.ArgumentParser) -> List[Set[str]]: - command_names: List[Set[str]] = [] +def get_mutually_exclusive_args(parser: argparse.ArgumentParser) -> List[Set[str]]: + groups: List[Set[str]] = [] + for group in parser._mutually_exclusive_groups: # noqa: SLF001 - command_names.append(set()) + group_args: Set[str] = set() for action in group._group_actions: # noqa: SLF001 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: # noqa: SLF001 - for choice, parser in action.choices.items(): - if is_unique_and_new(choice, args, exclusive, last): - words[choice] = parser.description - else: - for opt in action.option_strings: - if is_unique_and_new(opt, args, exclusive, last): - words[opt] = action.help - - return words + group_args.update(action.option_strings) + if group_args: + groups.append(group_args) + return groups def get_parser_action(name: str, parser_actions: List[argparse.Action]) -> Optional[argparse.Action]: @@ -91,24 +65,34 @@ def comp_get_actions_words(parser_actions: List[argparse.Action]) -> CompWords: return words -def comp_get_words(args: List[str], parser_actions: List[argparse.Action]) -> CompWords: # noqa: PLR0912 - words: CompWords = comp_get_actions_words(parser_actions) +def comp_get_words(args: List[str], parser: argparse.ArgumentParser) -> CompWords: # noqa: PLR0912 + words: CompWords = comp_get_actions_words(parser._actions) # noqa: SLF001 nargs = len(args) skip_arg = False for i, arg in enumerate(args): - action: Optional[argparse.Action] = get_parser_action(arg, parser_actions) # noqa: SLF001 + action: Optional[argparse.Action] = get_parser_action(arg, parser._actions) # noqa: SLF001 if skip_arg: skip_arg = False continue if not action: - words = comp_get_actions_words(parser_actions) continue if i + 1 >= nargs: - return words + continue + + # remove exclusive arguments from words + for exclusive_args in get_mutually_exclusive_args(parser): + if arg in exclusive_args: + for earg in exclusive_args: + if earg in words.keys(): + del words[earg] + # remove alternative arguments from words + for opt in action.option_strings: + if opt in words.keys(): + del words[opt] # if not action or action is HelpAction or VersionAction if isinstance(action, (argparse._HelpAction, argparse._VersionAction)): # noqa: SLF001 @@ -121,27 +105,28 @@ def comp_get_words(args: List[str], parser_actions: List[argparse.Action]) -> Co # if action is StoreAction if isinstance(action, argparse._StoreAction): # noqa: SLF001 - choices = {} - if action.choices: - for choice in action.choices: - choices[choice] = action.help - else: - choices[COMP_DIRNAMES] = None - choices[COMP_FILENAMES] = None - words = choices + if i + 2 >= nargs: + choices = {} + if action.choices: + for choice in action.choices: + choices[choice] = action.help + else: + choices[COMP_DIRNAMES] = None + choices[COMP_FILENAMES] = None + words = choices skip_arg = True continue # if action is SubParserAction if isinstance(action, argparse._SubParsersAction): # noqa: SLF001 - subparser = action.choices[arg] - cmd = get_subparser_command(subparser) - return cmd.completion(args[i + 1 :], subparser) if cmd else {} - - # delete already used args - for arg in args: - if arg in words.keys(): - del words[arg] + subparser: Optional[argparse.ArgumentParser] = action.choices[arg] if arg in action.choices else None + + command = get_subparser_command(subparser) if subparser else None + if command and subparser: + return command.completion(args[i + 1 :], subparser) + if subparser: + return comp_get_words(args[i + 1 :], subparser) # noqa: SLF001 + return {} return words diff --git a/python/knot_resolver/client/commands/cache.py b/python/knot_resolver/client/commands/cache.py index 66ee77c96..a1bebeedc 100644 --- a/python/knot_resolver/client/commands/cache.py +++ b/python/knot_resolver/client/commands/cache.py @@ -99,7 +99,7 @@ def register_args_subparser( @staticmethod def completion(args: List[str], parser: argparse.ArgumentParser) -> CompWords: - return comp_get_words(args, parser._actions) # noqa: SLF001 + return comp_get_words(args, parser) def run(self, args: CommandArgs) -> None: if not self.operation: diff --git a/python/knot_resolver/client/commands/completion.py b/python/knot_resolver/client/commands/completion.py index 75f8e1eb0..5e3d36288 100644 --- a/python/knot_resolver/client/commands/completion.py +++ b/python/knot_resolver/client/commands/completion.py @@ -45,14 +45,14 @@ def register_args_subparser( @staticmethod def completion(args: List[str], parser: argparse.ArgumentParser) -> CompWords: - return comp_get_words(args, parser._actions) # noqa: SLF001 + return comp_get_words(args, parser) def run(self, args: CommandArgs) -> None: # noqa: PLR0912 words: CompWords = {} - subparsers = args.parser._subparsers # noqa: SLF001 - if subparsers: - words = comp_get_words(self.args, subparsers._actions) # noqa: SLF001 + parser = args.parser + if parser: + words = comp_get_words(self.args, args.parser) # print completion words # based on required bash/fish shell format diff --git a/python/knot_resolver/client/commands/config.py b/python/knot_resolver/client/commands/config.py index fef3ad79c..69f7edfa2 100644 --- a/python/knot_resolver/client/commands/config.py +++ b/python/knot_resolver/client/commands/config.py @@ -158,7 +158,7 @@ def completion(args: List[str], parser: argparse.ArgumentParser) -> CompWords: return words - return comp_get_words(args, parser._actions) # noqa: SLF001 + return comp_get_words(args, parser) def run(self, args: CommandArgs) -> None: if not self.operation: diff --git a/python/knot_resolver/client/commands/convert.py b/python/knot_resolver/client/commands/convert.py index 28a5be957..aab07519c 100644 --- a/python/knot_resolver/client/commands/convert.py +++ b/python/knot_resolver/client/commands/convert.py @@ -50,7 +50,7 @@ def register_args_subparser( @staticmethod def completion(args: List[str], parser: argparse.ArgumentParser) -> CompWords: - return comp_get_words(args, parser._actions) # noqa: SLF001 + return comp_get_words(args, parser) def run(self, args: CommandArgs) -> None: with open(self.input_file, "r") as f: diff --git a/python/knot_resolver/client/commands/debug.py b/python/knot_resolver/client/commands/debug.py index 71ce673ed..14341e9d4 100644 --- a/python/knot_resolver/client/commands/debug.py +++ b/python/knot_resolver/client/commands/debug.py @@ -61,7 +61,7 @@ def register_args_subparser( @staticmethod def completion(args: List[str], parser: argparse.ArgumentParser) -> CompWords: - return comp_get_words(args, parser._actions) # noqa: SLF001 + return comp_get_words(args, parser) def run(self, args: CommandArgs) -> None: # noqa: PLR0912, PLR0915 if self.gdb is None: diff --git a/python/knot_resolver/client/commands/help.py b/python/knot_resolver/client/commands/help.py index 1db83fb61..949420911 100644 --- a/python/knot_resolver/client/commands/help.py +++ b/python/knot_resolver/client/commands/help.py @@ -14,7 +14,7 @@ def run(self, args: CommandArgs) -> None: @staticmethod def completion(args: List[str], parser: argparse.ArgumentParser) -> CompWords: - return comp_get_words(args, parser._actions) # noqa: SLF001 + return comp_get_words(args, parser) @staticmethod def register_args_subparser( diff --git a/python/knot_resolver/client/commands/metrics.py b/python/knot_resolver/client/commands/metrics.py index eaf83090d..57ff91719 100644 --- a/python/knot_resolver/client/commands/metrics.py +++ b/python/knot_resolver/client/commands/metrics.py @@ -44,7 +44,7 @@ def register_args_subparser( @staticmethod def completion(args: List[str], parser: argparse.ArgumentParser) -> CompWords: - return comp_get_words(args, parser._actions) # noqa: SLF001 + return comp_get_words(args, parser) def run(self, args: CommandArgs) -> None: response = request(args.socket, "GET", "metrics/prometheus" if self.prometheus else "metrics/json") diff --git a/python/knot_resolver/client/commands/schema.py b/python/knot_resolver/client/commands/schema.py index fdb65bdee..c5e4dfc44 100644 --- a/python/knot_resolver/client/commands/schema.py +++ b/python/knot_resolver/client/commands/schema.py @@ -35,7 +35,7 @@ def register_args_subparser( @staticmethod def completion(args: List[str], parser: argparse.ArgumentParser) -> CompWords: - return comp_get_words(args, parser._actions) # noqa: SLF001 + return comp_get_words(args, parser) def run(self, args: CommandArgs) -> None: if self.live: diff --git a/python/knot_resolver/client/commands/validate.py b/python/knot_resolver/client/commands/validate.py index 2347519ee..92848b584 100644 --- a/python/knot_resolver/client/commands/validate.py +++ b/python/knot_resolver/client/commands/validate.py @@ -41,7 +41,7 @@ def register_args_subparser( @staticmethod def completion(args: List[str], parser: argparse.ArgumentParser) -> CompWords: - return comp_get_words(args, parser._actions) # noqa: SLF001 + return comp_get_words(args, parser) def run(self, args: CommandArgs) -> None: if self.input_file: From d594b2b5471a7052174686d7699b24ad9bbd21a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ale=C5=A1=20Mr=C3=A1zek?= Date: Fri, 20 Dec 2024 11:12:24 +0100 Subject: [PATCH 13/16] utils/shell-completion/meson.build: install client.bash on a system --- utils/meson.build | 1 + utils/shell-completion/meson.build | 12 ++++++------ 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/utils/meson.build b/utils/meson.build index 8bab5f2d9..e6885a339 100644 --- a/utils/meson.build +++ b/utils/meson.build @@ -4,3 +4,4 @@ build_utils = get_option('utils') != 'disabled' subdir('cache_gc') +subdir('shell-completion') diff --git a/utils/shell-completion/meson.build b/utils/shell-completion/meson.build index 6c35ffe37..7f4d3601c 100644 --- a/utils/shell-completion/meson.build +++ b/utils/shell-completion/meson.build @@ -1,4 +1,4 @@ -# CLI comletion for bash-shell +# CLI completion for bash-shell install_data( sources: 'client.bash', rename: 'kresctl', @@ -6,8 +6,8 @@ install_data( ) # CLI completion for fish-shell -install_data( - sources: 'client.fish', - rename: 'kresctl.fish', - install_dir: completion_dir / 'fish' / 'completions' - ) +# install_data( +# sources: 'client.fish', +# rename: 'kresctl.fish', +# install_dir: completion_dir / 'fish' / 'completions' +# ) From e84f613015a53df123ffd5d0f750ff8c6f285d4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ale=C5=A1=20Mr=C3=A1zek?= Date: Fri, 20 Dec 2024 11:25:34 +0100 Subject: [PATCH 14/16] python: client: completion: config: remove COMP_NOSPACE for last node --- python/knot_resolver/client/commands/config.py | 1 + 1 file changed, 1 insertion(+) diff --git a/python/knot_resolver/client/commands/config.py b/python/knot_resolver/client/commands/config.py index 69f7edfa2..d13d24d95 100644 --- a/python/knot_resolver/client/commands/config.py +++ b/python/knot_resolver/client/commands/config.py @@ -148,6 +148,7 @@ def completion(args: List[str], parser: argparse.ArgumentParser) -> CompWords: prefix += f"/{node}" is_list = True continue + del words[COMP_NOSPACE] break if is_list and node.isnumeric(): prefix += f"/{node}" From 96dcc740cd10bb37de98cf8e3ddb783d4566208a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ale=C5=A1=20Mr=C3=A1zek?= Date: Fri, 20 Dec 2024 14:29:06 +0100 Subject: [PATCH 15/16] NEWS: update --- NEWS | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/NEWS b/NEWS index 00f309234..e1562b6f6 100644 --- a/NEWS +++ b/NEWS @@ -6,7 +6,7 @@ Improvements - avoid multiple log lines when IPv6 isn't available (!1633) - manager: fix startup on Linux without libsystemd (!1608) - auto-reload TLS certificate files (!1626) - +- kresctl: bash command-line TAB completion (!1622) Knot Resolver 6.0.9 (2024-11-11) ================================ From 1528db955ef84ccc3a35d43911e0169f89b787b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Ru=C5=BEi=C4=8Dka?= Date: Fri, 20 Dec 2024 18:47:21 +0100 Subject: [PATCH 16/16] distro: add bash completion to packages --- distro/pkg/deb/knot-resolver6.install | 1 + distro/pkg/rpm/knot-resolver.spec | 1 + 2 files changed, 2 insertions(+) diff --git a/distro/pkg/deb/knot-resolver6.install b/distro/pkg/deb/knot-resolver6.install index 7b9d0c414..068f6c19d 100644 --- a/distro/pkg/deb/knot-resolver6.install +++ b/distro/pkg/deb/knot-resolver6.install @@ -34,3 +34,4 @@ usr/lib/systemd/system/knot-resolver.service usr/lib/tmpfiles.d/knot-resolver.conf usr/sbin/kres-cache-gc usr/sbin/kresd +usr/share/bash-completion/completions/kresctl diff --git a/distro/pkg/rpm/knot-resolver.spec b/distro/pkg/rpm/knot-resolver.spec index 91c1a148b..504ae7ed7 100644 --- a/distro/pkg/rpm/knot-resolver.spec +++ b/distro/pkg/rpm/knot-resolver.spec @@ -296,6 +296,7 @@ getent passwd knot-resolver >/dev/null || useradd -r -g knot-resolver -d %{_sysc %{python3_sitearch}/knot_resolver* %{_mandir}/man8/kresd.8.gz %{_mandir}/man8/kresctl.8.gz +%{_datadir}/bash-completion/completions/kresctl %files devel %{_includedir}/libkres