Skip to content

Commit

Permalink
test: keeps bitcoin-cli autocomplete in sync
Browse files Browse the repository at this point in the history
Adds a functional test which parses available RPC commands, generates
the associated bitcoin-cli autcomplete file and checks that the current
autocomplete matches the file
An outdated autcomplete file can be updated using the --overwrite parameter

Co-authored-by: pierrenn <[email protected]>
  • Loading branch information
BrandonOdiwuor and pierrenn committed Oct 23, 2024
1 parent fa6bd81 commit 2f4b076
Show file tree
Hide file tree
Showing 4 changed files with 359 additions and 0 deletions.
47 changes: 47 additions & 0 deletions test/functional/data/completion/bitcoin-cli.footer.bash-completion
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@

case "$cur" in
-conf=*)
cur="${cur#*=}"
_filedir
return 0
;;
-datadir=*)
cur="${cur#*=}"
_filedir -d
return 0
;;
-*=*) # prevent nonsense completions
return 0
;;
*)
local helpopts commands

# only parse -help if senseful
if [[ -z "$cur" || "$cur" =~ ^- ]]; then
helpopts=$($bitcoin_cli -help 2>&1 | awk '$1 ~ /^-/ { sub(/=.*/, "="); print $1 }' )
fi

# only parse help if senseful
if [[ -z "$cur" || "$cur" =~ ^[a-z] ]]; then
commands=$(_bitcoin_rpc help 2>/dev/null | awk '$1 ~ /^[a-z]/ { print $1; }')
fi

COMPREPLY=( $( compgen -W "$helpopts $commands" -- "$cur" ) )

# Prevent space if an argument is desired
if [[ $COMPREPLY == *= ]]; then
compopt -o nospace
fi
return 0
;;
esac
} &&
complete -F _bitcoin_cli bitcoin-cli

# Local variables:
# mode: shell-script
# sh-basic-offset: 4
# sh-indent-comment: t
# indent-tabs-mode: nil
# End:
# ex: ts=4 sw=4 et filetype=sh
29 changes: 29 additions & 0 deletions test/functional/data/completion/bitcoin-cli.header.bash-completion
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# Copyright (c) 2012-2024 The Bitcoin Core developers
# Distributed under the MIT software license, see the accompanying
# file COPYING or http://www.opensource.org/licenses/mit-license.php.

# call $bitcoin-cli for RPC
_bitcoin_rpc() {
# determine already specified args necessary for RPC
local rpcargs=()
for i in ${COMP_LINE}; do
case "$i" in
-conf=*|-datadir=*|-regtest|-rpc*|-testnet|-testnet4)
rpcargs=( "${rpcargs[@]}" "$i" )
;;
esac
done
$bitcoin_cli "${rpcargs[@]}" "$@"
}

_bitcoin_cli() {
local cur prev words=() cword
local bitcoin_cli

# save and use original argument to invoke bitcoin-cli for -help, help and RPC
# as bitcoin-cli might not be in $PATH
bitcoin_cli="$1"

COMPREPLY=()
_get_comp_words_by_ref -n = cur prev words cword

1 change: 1 addition & 0 deletions test/functional/test_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,7 @@
'feature_bind_extra.py',
'mempool_resurrect.py',
'wallet_txn_doublespend.py --mineblock',
'tool_cli_bash_completion.py',
'tool_wallet.py --legacy-wallet',
'tool_wallet.py --legacy-wallet --bdbro',
'tool_wallet.py --legacy-wallet --bdbro --swap-bdb-endian',
Expand Down
282 changes: 282 additions & 0 deletions test/functional/tool_cli_bash_completion.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,282 @@
#!/usr/bin/env python3

from os import path
from collections import defaultdict

from test_framework.test_framework import BitcoinTestFramework
from test_framework.util import assert_equal


# bash cli completion file header
COMPLETION_HEADER = """# Dynamic bash programmable completion for bitcoin-cli(1)
# DO NOT EDIT THIS FILE BY HAND -- THIS WILL FAIL THE FUNCTIONAL TEST tool_cli_completion
# This file is auto-generated by the functional test tool_cli_completion.
# If you want to modify this file, modify test/functional/tool_cli_completion.py and re-autogenerate
# this file via the --overwrite test flag.
"""

# option types which are limited to certain values
TYPED_OPTIONS = [
["estimate_mode", {"UNSET", "ECONOMICAL", "CONSERVATIVE"}],
["sighashtype", {"ALL", "NONE", "SINGLE", "ALL|ANYONECANPAY",
"NONE|ANYONECANPAY", "SINGLE|ANYONECANPAY"}]
]


class PossibleArgs():
""" Helper class to store options associated to a command. """
def __init__(self, command):
self.command = command
self.arguments = {}

def set_args(self, position, values):
""" Set the position-th positional argument as having values as possible values. """
if position in self.arguments:
raise AssertionError(f"The positional parameter at position {position} is already defined for command '{self.command}'")

self.arguments[position] = values
return self

def set_bool_args(self, position):
return self.set_args(position, {"true", "false"})

def set_file_args(self, position):
# We consider an empty string as a file value for the sake of simplicity (don't
# have to create an extra level of indirection).
return self.set_args(position, {""})

def set_unknown_args(self, position):
return self.set_args(position, {})

def set_typed_option(self, position, arg_name):
""" Checks if arg_name is a typed option; if it is, sets it and return True. """
for option_type in TYPED_OPTIONS:
if arg_name == option_type[0]:
self.set_args(position, option_type[1])
return True
return False

def has_option(self, position):
return position in self.arguments and len(self.arguments[position]) > 0

def get_num_args(self):
""" Return the max number of positional argument the option accepts. """
pos = list(self.arguments.keys())
if len(pos) == 0:
return 0

return max(pos)

def generate_autocomplete(self, pos):
""" Generate the autocomplete file line relevent to the given position pos. """
if len(self.arguments[pos]) == 0:
raise AssertionError(f"generating undefined arg id {pos} ({self.arguments})")

# handle special file case
if len(self.arguments[pos]) == 1 and len(next(iter(self.arguments[pos]))) == 0:
return "_filedir"

# a set order is undefined, so we order args alphabetically
args = list(self.arguments[pos])
args.sort()

return "COMPREPLY=( $( compgen -W \"" + ' '.join(args) + "\" -- \"$cur\" ) )"

# commands where the option type can only be difficultly derived from the help message
SPECIAL_OPTIONS = [
PossibleArgs("addnode").set_args(2, {"add", "remove", "onetry"}),
PossibleArgs("setban").set_args(2, {"add", "remove"}),
]


def generate_start_complete(cword):
""" Generate the start of an autocomplete block (beware of indentation). """
if cword > 1:
return f""" if ((cword > {cword})); then
case ${{words[cword-{cword}]}} in"""

return " case \"$prev\" in"


def generate_end_complete(cword):
""" Generate the end of an autocomplete block. """
if cword > 1:
return f"\n{' ' * 8}esac\n{' ' * 4}fi\n\n"

return f"\n{' ' * 4}esac\n"


class CliCompletionTest(BitcoinTestFramework):
def set_test_params(self):
self.num_nodes = 1

def skip_test_if_missing_module(self):
self.skip_if_no_cli()
# self.skip_if_no_wallet()
self.skip_if_no_bitcoind_zmq()

def add_options(self, parser):
parser.add_argument(
'--header',
help='Static header part of the bash completion file',
)

parser.add_argument(
'--footer',
help='Static footer part of the bash completion file',
)

parser.add_argument(
'--completion',
help='Location of the current bash completion file',
)

parser.add_argument(
'--overwrite',
default=False,
action='store_true',
help='Force the test to overwrite the file pointer to by the --completion'
'to the newly generated completion file',
)
def parse_single_helper(self, option):
""" Complete the arguments of option via the RPC format command. """

res = self.nodes[0].format(command=option.command, output='args_cli')
if len(res) == 0:
return option

if res.count('\n') > 1:
raise AssertionError(
f"command {option.command} doesn't support format RPC. Should it be a hidden command? "
f"Please call RPCHelpMan::Check when adding a new non-hidden command. Returned: {res}"
)

for idx, argument in enumerate(res.split(",")):
elems = argument.split(":")

if option.set_typed_option(idx+1, elems[0]):
continue

if elems[1] == "boolean":
option.set_bool_args(idx+1)
continue

if elems[1] == "file":
option.set_file_args(idx+1)
continue

if not option.has_option(idx+1):
option.set_unknown_args(idx+1)

return option

def get_command_options(self, command):
""" Returns the corresponding PossibleArgs for the command. """

# verify it's not a special option first
for soption in SPECIAL_OPTIONS:
if command == soption.command:
return self.parse_single_helper(soption)

return self.parse_single_helper(PossibleArgs(command))

def generate_completion_block(self, options):
commands = [o.command for o in options]
self.log.info(f"Generating part of the completion file for options {commands}")

if len(options) == 0:
return ""

generated = ""
max_pos_options = max(options, key=lambda o: o.get_num_args()).get_num_args()
for cword in range(max_pos_options, 0, -1):
this_options = [option for option in options if option.has_option(cword)]
if len(this_options) == 0:
continue

# group options by their arguments value
grouped_options = defaultdict(list)
for option in this_options:
arg = option.generate_autocomplete(cword)
grouped_options[arg].append(option)

# generate the cword block
indent = 12 if cword > 1 else 8
generated += generate_start_complete(cword)
for line, opt_gr in grouped_options.items():
opt_gr.sort(key=lambda o: o.command) # show options alphabetically for clarity
args = '|'.join([o.command for o in opt_gr])
generated += f"\n{' '*indent}{args})\n"
generated += f"{' ' * (indent + 4)}{line}\n{' ' * (indent + 4)}return 0\n{' ' * (indent + 4)};;"
generated += generate_end_complete(cword)

return generated

def generate_completion_file(self, commands):
try:
with open(self.options.header, 'r', encoding='utf-8') as header_file:
header = header_file.read()

with open(self.options.footer, 'r', encoding='utf-8') as footer_file:
footer = footer_file.read()
except Exception as e:
raise AssertionError(
f"Could not read header/footer ({self.options.header} and {self.options.footer}) files. "
f"Tell the test where to find them using the --header/--footer parameters ({e})."
)
return COMPLETION_HEADER + header + commands + footer

def write_completion_file(self, new_file):
try:
with open(self.options.completion, 'w', encoding='utf-8') as completion_file:
completion_file.write(new_file)
except Exception as e:
raise AssertionError(
f"Could not write the autocomplete file to {self.options.completion}. "
f"Tell the test where to find it using the --completion parameters ({e})."
)

def read_completion_file(self):
try:
with open(self.options.completion, 'r', encoding='utf-8') as completion_file:
return completion_file.read()
except Exception as e:
raise AssertionError(
f"Could not read the autocomplete file ({self.options.completion}) file. "
f"Tell the test where to find it using the --completion parameters ({e})."
)


def run_test(self):
# self.config is not available in self.add_options, so complete filepaths here
src_dir = self.config["environment"]["SRCDIR"]
test_data_dir = path.join(src_dir, 'test', 'functional', 'data', 'completion')
if self.options.header is None or len(self.options.header) == 0:
self.options.header = path.join(test_data_dir, 'bitcoin-cli.header.bash-completion')

if self.options.footer is None or len(self.options.footer) == 0:
self.options.footer = path.join(test_data_dir, 'bitcoin-cli.footer.bash-completion')

if self.options.completion is None or len(self.option.completion) == 0:
self.options.completion = path.join(src_dir, 'contrib', 'completions', 'bash', 'bitcoin-cli.bash')

self.log.info('Parsing help commands to get all the command arguments...')
commands = self.nodes[0].help().split("\n")
commands = [c.split(' ')[0] for c in commands if not c.startswith("== ") and len(c) > 0]
commands = [self.get_command_options(c) for c in commands]

self.log.info('Generating new autocompletion file...')
commands = self.generate_completion_block(commands)
new_completion = self.generate_completion_file(commands)

if self.options.overwrite:
self.log.info("Overwriting the completion file...")
self.write_completion_file(new_completion)

self.log.info('Checking if the generated and the original completion files matches...')
completion = self.read_completion_file()
assert_equal(new_completion, completion)

if __name__ == '__main__':
CliCompletionTest(__file__).main()

0 comments on commit 2f4b076

Please sign in to comment.