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