test: Check RPC argument mapping #10753

Open
wants to merge 1 commit into
from
Jump to file or symbol
Failed to load files and symbols.
+166 −0
Split
View
@@ -46,6 +46,7 @@ before_script:
- if [ "$TRAVIS_EVENT_TYPE" = "pull_request" ]; then contrib/devtools/commit-script-check.sh $TRAVIS_COMMIT_RANGE; fi
- unset CC; unset CXX
- if [ "$CHECK_DOC" = 1 ]; then contrib/devtools/check-doc.py; fi
+ - if [ "$CHECK_DOC" = 1 ]; then contrib/devtools/check-rpc-mappings.py .; fi
- mkdir -p depends/SDKs depends/sdk-sources
- if [ -n "$OSX_SDK" -a ! -f depends/sdk-sources/MacOSX${OSX_SDK}.sdk.tar.gz ]; then curl --location --fail $SDK_URL/MacOSX${OSX_SDK}.sdk.tar.gz -o depends/sdk-sources/MacOSX${OSX_SDK}.sdk.tar.gz; fi
- if [ -n "$OSX_SDK" -a -f depends/sdk-sources/MacOSX${OSX_SDK}.sdk.tar.gz ]; then tar -C depends/SDKs -xf depends/sdk-sources/MacOSX${OSX_SDK}.sdk.tar.gz; fi
@@ -0,0 +1,165 @@
+#!/usr/bin/python3
+'''
+Check RPC argument consistency.
+'''
+import sys, os, re
+from collections import defaultdict
+
+# Source files (relative to root) to scan for dispatch tables
+SOURCES = [
+"src/rpc/server.cpp",
+"src/rpc/blockchain.cpp",
+"src/rpc/mining.cpp",
+"src/rpc/misc.cpp",
+"src/rpc/net.cpp",
+"src/rpc/rawtransaction.cpp",
+"src/wallet/rpcwallet.cpp",
+]
+# Source file (relative to root) containing conversion mapping
+SOURCE_CLIENT = 'src/rpc/client.cpp'
+# Argument names that should be ignored in consistency checks
+IGNORE_DUMMY_ARGS = {'dummy','arg0','arg1','arg2','arg3','arg4','arg5','arg6','arg7','arg8','arg9'}
+
+def parse_string(s):
+ assert(s[0] == '"')
+ assert(s[-1] == '"')
+ return s[1:-1]
+
+def make_string(s):
@jnewbery

jnewbery Jul 10, 2017

Member

nit: unused, please remove

+ return '"%s"' % s
+
+class RPCCommand:
+ def __init__(self, name, args):
+ self.name = name
+ self.args = args
+
+class RPCArgument:
+ def __init__(self, names, idx):
+ self.names = names
+ self.idx = idx
+
+def process_commands(fname):
@jnewbery

jnewbery Jul 10, 2017

Member

I think it'd simplify implementation in main() if process_commands() and process_mappings() both returned a set of tuples (command, index, argname).

+ '''
+ Find and parse dispatch table in implementation file `fname`.
+ '''
+ cmds = []
+ in_rpcs = False
+ with open(fname, "r") as f:
+ for line in f:
+ line = line.rstrip()
+ if not in_rpcs:
+ if re.match("static const CRPCCommand .*\[\] =", line):
+ in_rpcs = True
+ else:
+ if line.startswith('};'):
+ in_rpcs = False
+ elif '{' in line and '"' in line:
+ m = re.search('{ *("[^"]*"), *("[^"]*"), *&([^,]*), *(true|false), *{([^}]*)} *},', line)
+ if not m:
+ print('No match: %s' % line)
+ exit(1)
@practicalswift

practicalswift Jul 6, 2017 edited

Contributor

Nit: Use sys.exit(…)

@practicalswift

practicalswift Jul 6, 2017 edited

Contributor

According to the Python documentation exit(…) should be used in the interactive interpreter shell but not in programs:

The site module (which is imported automatically during startup, except if the -S command-line option is given) adds several constants to the built-in namespace. They are useful for the interactive interpreter shell and should not be used in programs.

Links:

  • sys.exit(…): "Exit from Python."
  • exit(…): "raise SystemExit with the specified exit code. […] should not be used in programs."
@laanwj

laanwj Jul 6, 2017 edited

Owner

Agree then! I never knew that. Assumed they were simply aliasses.

+ name = parse_string(m.group(2))
+ args_str = m.group(5).strip()
+ if args_str:
+ args = [RPCArgument(parse_string(x.strip()).split('|'), idx) for idx,x in enumerate(args_str.split(','))]
+ else:
+ args = []
+ cmds.append(RPCCommand(name, args))
+ assert(not in_rpcs) # If this assertion is hit, something went wrong with parsing the C++ file: update the regexps
@jnewbery

jnewbery Jul 10, 2017

Member

assert is a statement, not a function, so these parens are unnecessary. In fact, this would be better expressed as:

assert not in_rpcs, "Something went wrong with parsing the C++ file: update the regexps"

since that delivers the error text to the user at the point of failure.

+ return cmds
+
+def process_mapping(fname):
+ '''
+ Find and parse conversion table in implementation file `fname`.
+ '''
+ cmds = []
+ in_rpcs = False
+ with open(fname, "r") as f:
+ for line in f:
+ line = line.rstrip()
+ if not in_rpcs:
+ if line == 'static const CRPCConvertParam vRPCConvertParams[] =':
+ in_rpcs = True
+ else:
+ if line.startswith('};'):
+ in_rpcs = False
+ elif '{' in line and '"' in line:
+ m = re.search('{ *("[^"]*"), *([0-9]+) *, *("[^"]*") *},', line)
+ if not m:
+ print('No match: %s' % line)
+ exit(1)
+ name = parse_string(m.group(1))
+ idx = int(m.group(2))
+ argname = parse_string(m.group(3))
+ cmds.append((name, idx, argname))
+ assert(not in_rpcs)
+ return cmds
+
+
+def main():
+ root = sys.argv[1]
+
+ # Get all commands from dispatch tables
+ cmds = []
+ for fname in SOURCES:
+ cmds += process_commands(os.path.join(root, fname))
+
+ cmds_by_name = {}
+ for cmd in cmds:
+ cmds_by_name[cmd.name] = cmd
+
+ # Get current convert mapping for client
+ client = SOURCE_CLIENT
+ mapping_order = process_mapping(os.path.join(root, client))
+ mapping = set(mapping_order)
+
+ print('* Checking consistency between dispatch tables and vRPCConvertParams')
+
+ # Check mapping consistency
+ errors = 0
+ for (cmdname, argidx, argname) in mapping:
+ try:
+ rargnames = cmds_by_name[cmdname].args[argidx].names
+ except IndexError:
+ print('ERROR: %s argument %i (named %s in vRPCConvertParams) is not defined in dispatch table' % (cmdname, argidx, argname))
+ errors += 1
+ continue
+ if argname not in rargnames:
+ print('ERROR: %s argument %i is named %s in vRPCConvertParams but %s in dispatch table' % (cmdname, argidx, argname, rargnames), file=sys.stderr)
+ errors += 1
+
+ # Check for conflicts in vRPCConvertParams conversion
+ # All aliases for an argument must either be present in the
+ # conversion table, or not. Anything in between means an oversight
+ # and some aliases won't work.
+ for cmd in cmds:
+ for arg in cmd.args:
+ convert = [((cmd.name,arg.idx,argname) in mapping) for argname in arg.names]
+ if any(convert) != all(convert):
+ print('ERROR: %s argument %s has conflicts in vRPCConvertParams conversion specifier %s' % (cmd.name, arg.names, convert))
+ errors += 1
+ arg.convert = all(convert)
+
+ # Check for conversion difference by argument name.
+ # It is preferable for API consistency that arguments with the same name
+ # have the same conversion, so bin by argument name.
+ all_methods_by_argname = defaultdict(list)
+ converts_by_argname = defaultdict(list)
+ for cmd in cmds:
+ for arg in cmd.args:
+ for argname in arg.names:
+ all_methods_by_argname[argname].append(cmd.name)
+ converts_by_argname[argname].append(arg.convert)
+
+ for argname,convert in converts_by_argname.items():
+ if all(convert) != any(convert):
+ if argname in IGNORE_DUMMY_ARGS: # these are testing or dummy, don't warn for them
+ continue
+ print('WARNING: conversion mismatch for argument named %s (%s)' %
+ (argname, list(zip(all_methods_by_argname[argname], converts_by_argname[argname]))))
+
+ exit(errors > 0)
+
+if __name__ == '__main__':
+ main()
+