On 6/7/23 10:08 AM, Peter Krempa wrote:
The script generates all query strings in same format as used for querying qemu capabilities for a given .replies file.The output can be used to either aid in creation of the query strings as well as to generate diff between the schema, which is useful when adding a new capability dump. The script also validates that all of the schema is supported by our tools so that we can always adapt. The output looks like: $ ./scripts/qapi-schema-diff-gen.py tests/qemucapabilitiesdata/caps_8.0.0_x86_64.replies
I feel like the script could use a better name. *-diff-gen implies that it is generating a diff between two things. But currently it's not generating any diffs. It's actually just generating a list of valid schema query strings from a .replies file.
My suggestion:Name the script something like 'qapi-schema-validate.py' and change the behavior so it only validates by default, but add a -l/--list-queries option to printout a list of all valid query strings.
[...] query-yank query-yank/ret-type/type query-yank/ret-type/type/^block-node query-yank/ret-type/type/^chardev query-yank/ret-type/type/^migration query-yank/ret-type/+block-node query-yank/ret-type/+block-node/node-name query-yank/ret-type/+block-node/node-name/!str query-yank/ret-type/+chardev query-yank/ret-type/+chardev/id query-yank/ret-type/+chardev/id/!str query-yank/ret-type/+migration [...] Signed-off-by: Peter Krempa <[email protected]> --- scripts/meson.build | 1 + scripts/qapi-schema-diff-gen.py | 313 ++++++++++++++++++++++++++++++++ tests/meson.build | 12 ++ 3 files changed, 326 insertions(+) create mode 100755 scripts/qapi-schema-diff-gen.py diff --git a/scripts/meson.build b/scripts/meson.build index 05b71184f1..c216c7e1ff 100644 --- a/scripts/meson.build +++ b/scripts/meson.build @@ -30,6 +30,7 @@ scripts = [ 'meson-timestamp.py', 'mock-noinline.py', 'prohibit-duplicate-header.py', + 'qapi-schema-diff-gen.py', ] foreach name : scripts diff --git a/scripts/qapi-schema-diff-gen.py b/scripts/qapi-schema-diff-gen.py new file mode 100755 index 0000000000..bde130e33c --- /dev/null +++ b/scripts/qapi-schema-diff-gen.py @@ -0,0 +1,313 @@ +#!/usr/bin/env python3 +# +# SPDX-License-Identifier: LGPL-2.1-or-later +# +# A tool to help with creating query strings for querying the QMP schema (as +# returned by 'query-qmp-schema' QMP command). In default mode it generates all +# the possible query strings for a QMP schema in the ".replies" format as +# generated by 'tests/qemucapsprobe.c'. This can be either used by users to +# find the desired schema query string or to see what changed between two +# versions. +# +# In the '--validate' mode the script doesn't output the schema query strings. +# This invokes just the validator that everything in the schema is supported by +# the tool. +# +# Note: Any change to the 'validate_schema' function below to make it accept +# new schema components most likely requires change to either +# 'src/qemu/qemu_qapi.c' or 'tests/testutilsqemuschema.c' to accept the new +# schema components. + +from pathlib import Path +import argparse +import json +import sys + + +# Finds the apropriate call to 'query-qmp-schema' in the '.replies' file and +# returns the JSON blob following the command invocation. +def load_schema_json_list(filename): + found = False + + with open(filename, "r") as fh: + jsonstr = '' + for line in fh: + jsonstr += line + + if line != '}\n': + continue + + if found: + return json.loads(jsonstr)["return"] + + cmdobj = json.loads(jsonstr) + jsonstr = "" + + if isinstance(cmdobj, dict) and cmdobj.get('execute', '') == 'query-qmp-schema': + found = True + + +# Validates that 'entry' (an member of the QMP schema):
s/that // s/an/a/
+# - checks that it's a Dict (imported from a JSON object)
+# - checks that all 'mandatory' fields are present and their types match
+# - checks the types of all 'optional' fields
+# - checks that no unknown fields are present
+def check_keys(entry, mandatory, optional):
+ keys = set(entry.keys())
+
+ for k, t in mandatory:
+ try:
+ keys.remove(k)
+ except KeyError:
+ raise Exception("missing mandatory key '%s' in schema '%s'" % (k,
entry))
+
+ if not isinstance(entry[k], t):
+ raise Exception("key '%s' is not of the expected type '%s' in schema
'%s'" % (k, t, entry))
+
+ for k, t in optional:
+ if k in keys:
+ keys.discard(k)
+
+ if t is not None:
+ if not isinstance(entry[k], t):
+ raise Exception("key '%s' is not of the expected type '%s' in
schema '%s'" % (k, t, entry))
+
+ if len(keys) > 0:
+ raise Exception("unhandled keys '%s' in schema '%s'" %
(','.join(list(keys)), entry))
+
+
+# Validates the optional 'features' and that they consist only of strings
+def check_features_list(entry):
+ for f in entry.get('features', []):
+ if not isinstance(f, str):
+ raise Exception("broken 'features' list in schema entry '%s'" %
entry)
+
+
+# Validate that the passed schema has only supported members. This is useful to
+# stay up to date with any changes to the schema.
+def validate_schema(schemalist):
+ for entry in schemalist:
+ if not isinstance(entry, dict):
+ raise Exception("schema entry '%s' is not a JSON Object (dict)" %
(entry))
+
+ match entry.get('meta-type', None):
+ case 'command':
+ check_keys(entry,
+ mandatory=[('name', str),
+ ('meta-type', str),
+ ('arg-type', str),
+ ('ret-type', str)],
+ optional=[('features', list),
+ ('allow-oob', bool)])
+
+ check_features_list(entry)
+
+ case 'event':
+ check_keys(entry,
+ mandatory=[('name', str),
+ ('meta-type', str),
+ ('arg-type', str)],
+ optional=[('features', list)])
+
+ check_features_list(entry)
+
+ case 'object':
+ check_keys(entry,
+ mandatory=[('name', str),
+ ('meta-type', str),
+ ('members', list)],
+ optional=[('tag', str),
+ ('variants', list),
+ ('features', list)])
+
+ check_features_list(entry)
+
+ for m in entry.get('members', []):
+ check_keys(m,
+ mandatory=[('name', str),
+ ('type', str)],
+ optional=[('default', None),
+ ('features', list)])
+ check_features_list(m)
+
+ for m in entry.get('variants', []):
+ check_keys(m,
+ mandatory=[('case', str),
+ ('type', str)],
+ optional=[])
+
+ case 'array':
+ check_keys(entry,
+ mandatory=[('name', str),
+ ('meta-type', str),
+ ('element-type', str)],
+ optional=[])
+
+ case 'enum':
+ check_keys(entry,
+ mandatory=[('name', str),
+ ('meta-type', str)],
+ optional=[('members', list),
+ ('values', list)])
+
+ for m in entry.get('members', []):
+ check_keys(m,
+ mandatory=[('name', str)],
+ optional=[('features', list)])
+ check_features_list(m)
+
+ case 'alternate':
+ check_keys(entry,
+ mandatory=[('name', str),
+ ('meta-type', str),
+ ('members', list)],
+ optional=[])
+
+ for m in entry.get('members', []):
+ check_keys(m,
+ mandatory=[('type', str)],
+ optional=[])
+ case 'builtin':
+ check_keys(entry,
+ mandatory=[('name', str),
+ ('meta-type', str),
+ ('json-type', str)],
+ optional=[])
+
+ case _:
+ raise Exception("unknown or missing 'meta-type' in schema entry
'%s'" % entry)
+
+
+# Convert a list of QMP schema entries into a dict organized via 'name' member
+def load_schema_json_dict(schemalist):
+ schemadict = {}
+
+ for memb in schemalist:
+ schemadict[memb['name']] = memb
+
+ return schemadict
+
+
+# loads and validates the QMP schema from the .replies file 'filename'
+def load_schema(filename):
+ schemalist = load_schema_json_list(filename)
+
+ if not schemalist:
+ raise Exception("QMP schema not found in '%s'" % (filename))
+
+ validate_schema(schemalist)
+
+ return load_schema_json_dict(schemalist)
+
+
+# Recursively traverse the schema and print out the schema query strings for
+# the corresponding entries. In certain cases the schema references itself,
+# which is handled by passing a 'trace' list which contains the current path
+def iterate_schema(name, cur, trace, schema):
+ obj = schema[name]
+
+ if name in trace:
+ print('%s (recursion)' % cur)
+ return
+
+ trace = trace + [name]
+
+ match obj['meta-type']:
+ case 'command' | 'event':
+ arguments = obj.get('arg-type', None)
+ returns = obj.get('ret-type', None)
+
+ print(name)
+
+ for f in obj.get('features', []):
+ print('%s/$%s' % (cur, f))
+
+ if arguments:
+ iterate_schema(arguments, cur + '/arg-type', trace, schema)
+
+ if returns:
+ iterate_schema(returns, cur + '/ret-type', trace, schema)
+
+ case 'object':
+ members = sorted(obj.get('members', []), key=lambda d: d['name'])
+ variants = sorted(obj.get('variants', []), key=lambda d: d['case'])
+
+ for f in obj.get('features', []):
+ print('%s/$%s' % (cur, f))
+
+ for memb in members:
+ membpath = "%s/%s" % (cur, memb['name'])
+ print(membpath)
+
+ for f in memb.get('features', []):
+ print('%s/$%s' % (membpath, f))
+
+ iterate_schema(memb['type'], membpath, trace, schema)
+
+ for var in variants:
+ varpath = "%s/+%s" % (cur, var['case'])
+ print(varpath)
+ iterate_schema(var['type'], varpath, trace, schema)
+
+ case 'enum':
+ members = sorted(obj.get('members', []), key=lambda d: d['name'])
+
+ for m in members:
+ print('%s/^%s' % (cur, m['name']))
+
+ for f in m.get('features', []):
+ print('%s/^%s/$%s' % (cur, m['name'], f))
+
+ case 'array':
+ iterate_schema(obj['element-type'], cur, trace, schema)
+
+ case 'builtin':
+ print('%s/!%s' % (cur, name))
+
+ case 'alternate':
+ for var in obj['members']:
+ iterate_schema(var['type'], cur, trace, schema)
+
+ case _:
+ raise Exception("unhandled 'meta-type' '%s'" % obj.get('meta-type',
'<missing>'))
+
+
+def process_one_schema(schemafile, validate_only):
+ try:
+ schema = load_schema(schemafile)
+ except Exception as e:
+ raise Exception("Failed to load schema '%s': %s" % (schemafile, e))
+
+ if validate_only:
+ return
+
+ toplevel = []
+
+ for k, v in schema.items():
+ if v['meta-type'] == 'command' or v['meta-type'] == 'event':
+ toplevel.append(k)
+
+ toplevel.sort()
+
+ for c in toplevel:
+ iterate_schema(c, c, [], schema)
+
+
+parser = argparse.ArgumentParser(description='A tool to generate QMP schema
query strins and validator of schema coverage')
typo: strins -> stringsWhat do you mean by 'coverage' here? How is it validating schema coverage? As far as I can tell, it's just validating the schema replies file itself.
I would change this help text to something like "A tool to validate QMP schema .replies files and generate a list of valid schema query strings"
Reviewed-by: Jonathon Jongsma <[email protected]>
+parser.add_argument('--validate', action="store_true", help='only load the
schema and validate it')
+parser.add_argument('--schemadir', default='',
+ help='directory containing .replies files')
+parser.add_argument('schema', nargs='?', help='path to .replies file to use')
+args = parser.parse_args()
+
+if not args.schema and not args.schemadir:
+ parser.print_help()
+ sys.exit(1)
+
+if args.schema:
+ process_one_schema(args.schema, args.validate)
+else:
+ files = Path(args.schemadir).glob('*.replies')
+
+ for file in files:
+ process_one_schema(str(file), args.validate)
diff --git a/tests/meson.build b/tests/meson.build
index 0082446029..25e7ccd312 100644
--- a/tests/meson.build
+++ b/tests/meson.build
@@ -598,6 +598,18 @@ foreach data : tests
test(data['name'], test_bin, env: tests_env, timeout: timeout, depends:
tests_deps)
endforeach
+test(
+ 'qapi-schema-check',
+ python3_prog,
+ args: [
+ qapi_schema_diff_gen_prog.full_path(),
+ '--validate',
+ '--schemadir',
+ meson.project_source_root() / 'tests' / 'qemucapabilitiesdata'
+ ],
+ env: runutf8,
+)
+
# helpers:
# each entry is a dictionary with following items:
