Skip to content

Commit

Permalink
Merge branch 'kresctl-tab-completion' into 'master'
Browse files Browse the repository at this point in the history
kresctl: implement tab completion

See merge request knot/knot-resolver!1622
  • Loading branch information
alesmrazek committed Dec 23, 2024
2 parents ec0d46f + 1528db9 commit cde3cfd
Show file tree
Hide file tree
Showing 17 changed files with 240 additions and 183 deletions.
2 changes: 1 addition & 1 deletion NEWS
Original file line number Diff line number Diff line change
Expand Up @@ -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)
================================
Expand Down
1 change: 1 addition & 0 deletions distro/pkg/deb/knot-resolver6.install
Original file line number Diff line number Diff line change
Expand Up @@ -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
1 change: 1 addition & 0 deletions distro/pkg/rpm/knot-resolver.spec
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
118 changes: 116 additions & 2 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 abc import ABC, abstractmethod
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
Expand All @@ -14,9 +14,123 @@

CompWords = Dict[str, Optional[str]]

COMP_DIRNAMES = "#dirnames#"
COMP_FILENAMES = "#filenames#"
COMP_NOSPACE = "#nospace#"

_registered_commands: List[Type["Command"]] = []


def get_mutually_exclusive_args(parser: argparse.ArgumentParser) -> List[Set[str]]:
groups: List[Set[str]] = []

for group in parser._mutually_exclusive_groups: # noqa: SLF001
group_args: Set[str] = set()
for action in group._group_actions: # noqa: SLF001
if action.option_strings:
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]:
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 parser_actions:
if isinstance(action, argparse._SubParsersAction) and action.choices: # noqa: SLF001
for choice, parser in action.choices.items():
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 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

if skip_arg:
skip_arg = False
continue

if not action:
continue

if i + 1 >= nargs:
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
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
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: 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


def register_command(cls: T) -> T:
_registered_commands.append(cls)
return cls
Expand Down
4 changes: 2 additions & 2 deletions python/knot_resolver/client/commands/cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -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, 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
Expand Down Expand Up @@ -99,7 +99,7 @@ def register_args_subparser(

@staticmethod
def completion(args: List[str], parser: argparse.ArgumentParser) -> CompWords:
return {}
return comp_get_words(args, parser)

def run(self, args: CommandArgs) -> None:
if not self.operation:
Expand Down
90 changes: 30 additions & 60 deletions python/knot_resolver/client/commands/completion.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,13 @@
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,
comp_get_words,
register_command,
)


class Shells(Enum):
Expand All @@ -15,81 +21,45 @@ class CompletionCommand(Command):
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

if self.space:
self.comp_args.append("")
self.args: List[str] = namespace.args
if namespace.extra is not None:
self.args.append("--")

@staticmethod
def register_args_subparser(
subparser: "argparse._SubParsersAction[argparse.ArgumentParser]",
) -> Tuple[argparse.ArgumentParser, "Type[Command]"]:
completion = subparser.add_parser("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,
)
completion.add_argument(
"comp_args",
type=str,
help="arguments to complete",
nargs="*",
completion = subparser.add_parser(
"completion",
help="commands auto-completion",
)

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
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 comp_get_words(args, parser)

def run(self, args: CommandArgs) -> None:
pass
# subparsers = args.parser._subparsers
# words: CompWords = {}

# if subparsers:
# words = parser_words(subparsers._actions)

# uargs = iter(self.comp_args)
# for uarg in uargs:
# subparser = subparser_by_name(uarg, subparsers._actions) # pylint: disable=W0212
def run(self, args: CommandArgs) -> None: # noqa: PLR0912
words: CompWords = {}

# 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}")
parser = args.parser
if parser:
words = comp_get_words(self.args, args.parser)

# # 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}")
Loading

0 comments on commit cde3cfd

Please sign in to comment.