diff --git a/tools/fromschema.py b/tools/fromschema.py new file mode 100755 index 000000000..a4d655336 --- /dev/null +++ b/tools/fromschema.py @@ -0,0 +1,247 @@ +#! /usr/bin/env python3 +# Script to turn JSON schema into markdown documentation and replace in-place. +# Released by Rusty Russell under CC0: +# https://creativecommons.org/publicdomain/zero/1.0/ +from argparse import ArgumentParser +import json + + +def json_value(obj): + """Format obj in the JSON style for a value""" + if type(obj) is bool: + if obj: + return '*true*' + return '*false*' + if type(obj) is str: + return '"' + obj + '"' + assert False + + +def outputs(lines): + """Add these lines to the final output""" + print(''.join(lines), end='') + + +def output(line): + """Add this line to the final output""" + print(line, end='') + + +def output_type(properties, is_optional): + typename = properties['type'] + if typename == 'array': + typename += ' of {}s'.format(properties['items']['type']) + if is_optional: + typename += ", optional" + output(" ({})".format(typename)) + + +def output_range(properties): + if 'maximum' and 'minimum' in properties: + output(" ({} to {} inclusive)".format(properties['minimum'], + properties['maximum'])) + elif 'maximum' in properties: + output(" (max {})".format(properties['maximum'])) + elif 'minimum' in properties: + output(" (min {})".format(properties['minimum'])) + + if 'maxLength' and 'minLength' in properties: + if properties['minLength'] == properties['maxLength']: + output(' (always {} characters)'.format(properties['minLength'])) + else: + output(' ({} to {} characters)'.format(properties['minLength'], + properties['maxLength'])) + elif 'maxLength' in properties: + output(' (up to {} characters)'.format(properties['maxLength'])) + elif 'minLength' in properties: + output(' (at least {} characters)'.format(properties['minLength'])) + + if 'enum' in properties: + if len(properties['enum']) == 1: + output(" (always {})".format(json_value(properties['enum'][0]))) + else: + output(' (one of {})'.format(', '.join([json_value(p) for p in properties['enum']]))) + + +def output_member(propname, properties, is_optional, indent, print_type=True): + """Generate description line(s) for this member""" + output(indent + '- **{}**'.format(propname)) + if print_type: + output_type(properties, is_optional) + + if 'description' in properties: + output(": {}".format(properties['description'])) + + output_range(properties) + + if properties['type'] == 'object': + output(':\n') + output_members(properties, indent + ' ') + elif properties['type'] == 'array': + output(':\n') + output_array(properties['items'], indent + ' ') + else: + output('\n') + + +def output_array(items, indent): + """We've already said it's an array of {type}""" + if items['type'] == 'object': + output_members(items, indent) + elif items['type'] == 'array': + output(indent + '- {}:\n'.format(items['description'])) + output_array(items['items'], indent + ' ') + else: + output(indent + '- {}'.format(items['description'])) + output_range(items) + output('\n') + + +def output_members(sub, indent=''): + """Generate lines for these properties""" + warnings = [] + + # Remove deprecated and stub properties, collect warnings + # (Stubs required to keep additionalProperties: false happy) + for p in list(sub['properties'].keys()): + if len(sub['properties'][p]) == 0 or 'deprecated' in sub['properties'][p]: + del sub['properties'][p] + elif p.startswith('warning'): + warnings.append(p) + + # First list always-present properties + for p in sub['properties']: + if p.startswith('warning'): + continue + if p in sub['required']: + output_member(p, sub['properties'][p], False, indent) + + for p in sub['properties']: + if p.startswith('warning'): + continue + if p not in sub['required']: + output_member(p, sub['properties'][p], True, indent) + + if warnings != []: + output(indent + "- the following warnings are possible:\n") + for w in warnings: + output_member(w, sub['properties'][w], False, indent + ' ', print_type=False) + + # Not handled. + assert 'oneOf' not in sub + + # If we have multiple ifs, we have to wrap them in allOf. + if 'allOf' in sub: + ifclauses = sub['allOf'] + elif 'if' in sub: + ifclauses = [sub] + else: + ifclauses = [] + + # We partially handle if, assuming it depends on particular values of prior properties. + for ifclause in ifclauses: + conditions = [] + + # "required" are fields that simply must be present + for r in ifclause['if'].get('required', []): + conditions.append('**{}** is present'.format(r)) + + # "properties" are enums of field values + for tag, vals in ifclause['if'].get('properties', {}).items(): + # Don't have a description field here, it's not used. + assert 'description' not in vals + whichvalues = vals['enum'] + + cond = "**{}** is".format(tag) + if len(whichvalues) == 1: + cond += " {}".format(json_value(whichvalues[0])) + else: + cond += " {} or {}".format(", ".join([json_value(v) for v in whichvalues[:-1]]), + json_value(whichvalues[-1])) + conditions.append(cond) + + sentence = indent + "If " + ", and ".join(conditions) + ":\n" + # Prefix with blank line. + outputs(['\n', sentence]) + + output_members(ifclause['then'], indent + ' ') + + +def generate_from_schema(schema): + """This is not general, but works for us""" + assert schema['type'] == 'object' + + toplevels = [] + warnings = [] + props = schema['properties'] + + # We handle warnings on top-level objects with a separate section, + # so collect them now and remove them + for toplevel in list(props.keys()): + if toplevel.startswith('warning'): + warnings.append((toplevel, props[toplevel]['description'])) + del props[toplevel] + else: + toplevels.append(toplevel) + + # No properties -> empty object. + if toplevels == []: + output('On success, an empty object is returned.\n') + sub = schema + elif len(toplevels) == 1 and props[toplevels[0]]['type'] == 'object': + output('On success, an object containing **{}** is returned. It is an object containing:\n'.format(toplevels[0])) + # Don't have a description field here, it's not used. + assert 'description' not in toplevels[0] + sub = props[toplevels[0]] + elif len(toplevels) == 1 and props[toplevels[0]]['type'] == 'array': + output('On success, an object containing **{}** is returned. It is an array of objects, where each object contains:\n'.format(toplevels[0])) + # Don't have a description field here, it's not used. + assert 'description' not in toplevels[0] + sub = props[toplevels[0]]['items'] + else: + output('On success, an object is returned, containing:\n') + sub = schema + + output_members(sub) + + if warnings: + outputs(['\n', 'The following warnings may also be returned:\n']) + for w, desc in warnings: + output("- **{}**: {}\n".format(w, desc)) + + +def main(schemafile, markdownfile): + start_marker = '[comment]: # (GENERATE-FROM-SCHEMA-START)\n' + end_marker = '[comment]: # (GENERATE-FROM-SCHEMA-END)\n' + + if markdownfile is None: + with open(schemafile, "r") as f: + schema = json.load(f) + generate_from_schema(schema) + return + + with open(markdownfile, "r") as f: + md = f.readlines() + + suppress_output = False + for line in md: + if line == end_marker: + suppress_output = False + + if not suppress_output: + print(line, end='') + + if line == start_marker: + with open(schemafile, "r") as f: + schema = json.load(f) + generate_from_schema(schema) + suppress_output = True + + +if __name__ == "__main__": + parser = ArgumentParser() + parser.add_argument('schemafile', help='The schema file to use') + parser.add_argument('--markdownfile', help='The markdown file to read') + parsed_args = parser.parse_args() + + main(parsed_args.schemafile, parsed_args.markdownfile)