core-lightning/tools/fromschema.py
Rusty Russell dadbdf488c schemas: deprecated is now a range.
Don't assume removal is +6 months, but have a start deprecation/end support range.

Signed-off-by: Rusty Russell <rusty@rustcorp.com.au>
2024-01-26 10:30:22 +10:30

306 lines
10 KiB
Python
Executable File

#! /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
import re
def esc_underscores(s):
"""Backslash-escape underscores outside of backtick-enclosed spans"""
return ''.join(['\\_' if x == '_' else x for x in re.findall(r'[^`_\\]+|`(?:[^`\\]|\\.)*`|\\.|_', s)])
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 '"' + esc_underscores(obj) + '"'
if obj is None:
return '*null*'
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 = esc_underscores(properties['type'])
if typename == 'array':
typename += ' of {}s'.format(esc_underscores(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 fmt_propname(propname):
"""Pretty-print format a property name"""
return '**{}**'.format(esc_underscores(propname))
def output_member(propname, properties, is_optional, indent, print_type=True, prefix=None):
"""Generate description line(s) for this member"""
if prefix is None:
prefix = '- ' + fmt_propname(propname)
output(indent + prefix)
# We make them explicitly note if they don't want a type!
is_untyped = 'untyped' in properties
if not is_untyped and print_type:
output_type(properties, is_optional)
if 'description' in properties:
output(": {}".format(esc_underscores(properties['description'])))
output_range(properties)
if 'deprecated' in properties:
assert isinstance(properties['deprecated'], list)
assert len(properties['deprecated']) == 2
output(" **deprecated in {}, removed after {}**".format(properties['deprecated'][0], properties['deprecated'][1]))
if 'added' in properties:
output(" *(added {})*".format(properties['added']))
if not is_untyped and properties['type'] == 'object':
output(':\n')
output_members(properties, indent + ' ')
elif not is_untyped and 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(esc_underscores(items['description'])))
output_array(items['items'], indent + ' ')
else:
output(indent + '- {}'.format(esc_underscores(items['description'])))
output_range(items)
output('\n')
def has_members(sub):
"""Does this sub have any properties to print?"""
for p in list(sub['properties'].keys()):
if len(sub['properties'][p]) == 0:
continue
if sub['properties'][p].get('deprecated') is True:
continue
return True
return False
def output_members(sub, indent=''):
"""Generate lines for these properties"""
warnings = []
# Remove deprecated: True and stub properties, collect warnings
# (Stubs required to keep additionalProperties: false happy)
# FIXME: It fails for schemas which have only an array type with
# no properties, ex:
# "abcd": {
# "type": "array",
# "items": {
# "type": "whatever",
# "description": "efgh"
# }
# }
# Checkout the schema of `staticbackup`.
for p in list(sub['properties'].keys()):
if len(sub['properties'][p]) == 0 or sub['properties'][p].get('deprecated') is True:
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 'required' in sub and p in sub['required']:
output_member(p, sub['properties'][p], False, indent)
for p in sub['properties']:
if p.startswith('warning'):
continue
if 'required' not in sub or 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(fmt_propname(r) + ' is present')
# "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 = fmt_propname(tag) + " is"
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\n"
if has_members(ifclause['then']):
# 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"""
if schema['type'] != 'object':
# 'stop' returns a single string!
output_member(None, schema, False, '', prefix='On success, returns a single element')
return
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\n'.format(fmt_propname(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' and props[toplevels[0]]['items']['type'] == 'object':
output('On success, an object containing {} is returned. It is an array of objects, where each object contains:\n\n'.format(fmt_propname(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\n')
sub = schema
output_members(sub)
if warnings:
outputs(['\n', 'The following warnings may also be returned:\n\n'])
for w, desc in warnings:
output("- {}: {}\n".format(fmt_propname(w), desc))
# GH markdown rendering gets upset if there isn't a blank line
# between a list and the end comment.
output('\n')
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)