1#! /usr/bin/env python3 2# Script to turn JSON schema into markdown documentation and replace in-place. 3# Released by Rusty Russell under CC0: 4# https://creativecommons.org/publicdomain/zero/1.0/ 5from argparse import ArgumentParser 6import json 7 8 9def json_value(obj): 10 """Format obj in the JSON style for a value""" 11 if type(obj) is bool: 12 if obj: 13 return '*true*' 14 return '*false*' 15 if type(obj) is str: 16 return '"' + obj + '"' 17 if obj is None: 18 return '*null*' 19 assert False 20 21 22def outputs(lines): 23 """Add these lines to the final output""" 24 print(''.join(lines), end='') 25 26 27def output(line): 28 """Add this line to the final output""" 29 print(line, end='') 30 31 32def output_type(properties, is_optional): 33 # FIXME: there's a horrible hack for listpeers' closer which can be NULL 34 if type(properties['type']) is list: 35 typename = properties['type'][0] 36 else: 37 typename = properties['type'] 38 if typename == 'array': 39 typename += ' of {}s'.format(properties['items']['type']) 40 if is_optional: 41 typename += ", optional" 42 output(" ({})".format(typename)) 43 44 45def output_range(properties): 46 if 'maximum' and 'minimum' in properties: 47 output(" ({} to {} inclusive)".format(properties['minimum'], 48 properties['maximum'])) 49 elif 'maximum' in properties: 50 output(" (max {})".format(properties['maximum'])) 51 elif 'minimum' in properties: 52 output(" (min {})".format(properties['minimum'])) 53 54 if 'maxLength' and 'minLength' in properties: 55 if properties['minLength'] == properties['maxLength']: 56 output(' (always {} characters)'.format(properties['minLength'])) 57 else: 58 output(' ({} to {} characters)'.format(properties['minLength'], 59 properties['maxLength'])) 60 elif 'maxLength' in properties: 61 output(' (up to {} characters)'.format(properties['maxLength'])) 62 elif 'minLength' in properties: 63 output(' (at least {} characters)'.format(properties['minLength'])) 64 65 if 'enum' in properties: 66 if len(properties['enum']) == 1: 67 output(" (always {})".format(json_value(properties['enum'][0]))) 68 else: 69 output(' (one of {})'.format(', '.join([json_value(p) for p in properties['enum']]))) 70 71 72def output_member(propname, properties, is_optional, indent, print_type=True, prefix=None): 73 """Generate description line(s) for this member""" 74 75 if prefix is None: 76 prefix = '- **{}**'.format(propname) 77 output(indent + prefix) 78 79 # We make them explicitly note if they don't want a type! 80 is_untyped = 'untyped' in properties 81 82 if not is_untyped and print_type: 83 output_type(properties, is_optional) 84 85 if 'description' in properties: 86 output(": {}".format(properties['description'])) 87 88 output_range(properties) 89 90 if not is_untyped and properties['type'] == 'object': 91 output(':\n') 92 output_members(properties, indent + ' ') 93 elif not is_untyped and properties['type'] == 'array': 94 output(':\n') 95 output_array(properties['items'], indent + ' ') 96 else: 97 output('\n') 98 99 100def output_array(items, indent): 101 """We've already said it's an array of {type}""" 102 if items['type'] == 'object': 103 output_members(items, indent) 104 elif items['type'] == 'array': 105 output(indent + '- {}:\n'.format(items['description'])) 106 output_array(items['items'], indent + ' ') 107 else: 108 output(indent + '- {}'.format(items['description'])) 109 output_range(items) 110 output('\n') 111 112 113def has_members(sub): 114 """Does this sub have any properties to print?""" 115 for p in list(sub['properties'].keys()): 116 if len(sub['properties'][p]) == 0: 117 continue 118 if 'deprecated' in sub['properties'][p]: 119 continue 120 return True 121 return False 122 123 124def output_members(sub, indent=''): 125 """Generate lines for these properties""" 126 warnings = [] 127 128 # Remove deprecated and stub properties, collect warnings 129 # (Stubs required to keep additionalProperties: false happy) 130 for p in list(sub['properties'].keys()): 131 if len(sub['properties'][p]) == 0 or 'deprecated' in sub['properties'][p]: 132 del sub['properties'][p] 133 elif p.startswith('warning'): 134 warnings.append(p) 135 136 # First list always-present properties 137 for p in sub['properties']: 138 if p.startswith('warning'): 139 continue 140 if p in sub['required']: 141 output_member(p, sub['properties'][p], False, indent) 142 143 for p in sub['properties']: 144 if p.startswith('warning'): 145 continue 146 if p not in sub['required']: 147 output_member(p, sub['properties'][p], True, indent) 148 149 if warnings != []: 150 output(indent + "- the following warnings are possible:\n") 151 for w in warnings: 152 output_member(w, sub['properties'][w], False, indent + ' ', print_type=False) 153 154 # Not handled. 155 assert 'oneOf' not in sub 156 157 # If we have multiple ifs, we have to wrap them in allOf. 158 if 'allOf' in sub: 159 ifclauses = sub['allOf'] 160 elif 'if' in sub: 161 ifclauses = [sub] 162 else: 163 ifclauses = [] 164 165 # We partially handle if, assuming it depends on particular values of prior properties. 166 for ifclause in ifclauses: 167 conditions = [] 168 169 # "required" are fields that simply must be present 170 for r in ifclause['if'].get('required', []): 171 conditions.append('**{}** is present'.format(r)) 172 173 # "properties" are enums of field values 174 for tag, vals in ifclause['if'].get('properties', {}).items(): 175 # Don't have a description field here, it's not used. 176 assert 'description' not in vals 177 whichvalues = vals['enum'] 178 179 cond = "**{}** is".format(tag) 180 if len(whichvalues) == 1: 181 cond += " {}".format(json_value(whichvalues[0])) 182 else: 183 cond += " {} or {}".format(", ".join([json_value(v) for v in whichvalues[:-1]]), 184 json_value(whichvalues[-1])) 185 conditions.append(cond) 186 187 sentence = indent + "If " + ", and ".join(conditions) + ":\n" 188 189 if has_members(ifclause['then']): 190 # Prefix with blank line. 191 outputs(['\n', sentence]) 192 193 output_members(ifclause['then'], indent + ' ') 194 195 196def generate_from_schema(schema): 197 """This is not general, but works for us""" 198 if schema['type'] != 'object': 199 # 'stop' returns a single string! 200 output_member(None, schema, False, '', prefix='On success, returns a single element') 201 return 202 203 toplevels = [] 204 warnings = [] 205 props = schema['properties'] 206 207 # We handle warnings on top-level objects with a separate section, 208 # so collect them now and remove them 209 for toplevel in list(props.keys()): 210 if toplevel.startswith('warning'): 211 warnings.append((toplevel, props[toplevel]['description'])) 212 del props[toplevel] 213 else: 214 toplevels.append(toplevel) 215 216 # No properties -> empty object. 217 if toplevels == []: 218 output('On success, an empty object is returned.\n') 219 sub = schema 220 elif len(toplevels) == 1 and props[toplevels[0]]['type'] == 'object': 221 output('On success, an object containing **{}** is returned. It is an object containing:\n'.format(toplevels[0])) 222 # Don't have a description field here, it's not used. 223 assert 'description' not in toplevels[0] 224 sub = props[toplevels[0]] 225 elif len(toplevels) == 1 and props[toplevels[0]]['type'] == 'array': 226 output('On success, an object containing **{}** is returned. It is an array of objects, where each object contains:\n'.format(toplevels[0])) 227 # Don't have a description field here, it's not used. 228 assert 'description' not in toplevels[0] 229 sub = props[toplevels[0]]['items'] 230 else: 231 output('On success, an object is returned, containing:\n') 232 sub = schema 233 234 output_members(sub) 235 236 if warnings: 237 outputs(['\n', 'The following warnings may also be returned:\n']) 238 for w, desc in warnings: 239 output("- **{}**: {}\n".format(w, desc)) 240 241 # GH markdown rendering gets upset if there isn't a blank line 242 # between a list and the end comment. 243 output('\n') 244 245 246def main(schemafile, markdownfile): 247 start_marker = '[comment]: # (GENERATE-FROM-SCHEMA-START)\n' 248 end_marker = '[comment]: # (GENERATE-FROM-SCHEMA-END)\n' 249 250 if markdownfile is None: 251 with open(schemafile, "r") as f: 252 schema = json.load(f) 253 generate_from_schema(schema) 254 return 255 256 with open(markdownfile, "r") as f: 257 md = f.readlines() 258 259 suppress_output = False 260 for line in md: 261 if line == end_marker: 262 suppress_output = False 263 264 if not suppress_output: 265 print(line, end='') 266 267 if line == start_marker: 268 with open(schemafile, "r") as f: 269 schema = json.load(f) 270 generate_from_schema(schema) 271 suppress_output = True 272 273 274if __name__ == "__main__": 275 parser = ArgumentParser() 276 parser.add_argument('schemafile', help='The schema file to use') 277 parser.add_argument('--markdownfile', help='The markdown file to read') 278 parsed_args = parser.parse_args() 279 280 main(parsed_args.schemafile, parsed_args.markdownfile) 281