#!/usr/bin/env python3 import re import subprocess as sp import sys import json import urllib.request as req import jsonschema re_full = re.compile(r'(?P^.*?$)(?P.*?)(^Argument.*?$(?P.*?))?(^Result[^\n,]*?:\s*$(?P.*?))?(^Exampl.*?$(?P.*))?', re.DOTALL | re.MULTILINE) re_argline = re.compile(r'^("?)(?P\w.*?)\1(\s*:.+?,?\s*)?\s+\((?P.*?)\)\s*(?P.*?)\s*$', re.DOTALL) def get_obj_from_dirty_text(full_object: str): lines = full_object.splitlines() lefts = [] i = 0 while i < len(lines): idx = lines[i].find('(') left = lines[i][0:idx].strip() if idx >= 0 else lines[i] left = left.rstrip('.') # handling , ... left = left.strip() left = left.rstrip(',') lefts.append(left) while idx >= 0 and i < len(lines) - 1: idx2 = len(re.match(r'^\s*', lines[i + 1]).group()) if idx2 > idx: lines[i] += lines.pop(i + 1)[idx2 - 1:] else: break i += 1 ret = None try: property_stack = [] object_stack = [] name_stack = [] last_name = None for i in range(0, len(lines)): left = lefts[i] if not left: continue line = lines[i].strip() arg_parsed = re_argline.fullmatch(line) property_refined_type = 'object' if arg_parsed is not None: property_name, property_type, property_desc = arg_parsed.group('name', 'type', 'desc') property_refined_type, property_required, property_child = get_type(property_type, None) if property_refined_type is not 'array' and property_refined_type is not 'object': property_stack[-1][property_name] = { 'type': property_refined_type, 'description': property_desc } else: last_name = property_name elif len(left) > 1: match = re.match(r'^(\[)?"(?P\w.*?)"(\])?.*', left) if match is not None: last_name = match.group('name') if match.group(1) is not None and match.group(3) is not None: left = '[' property_refined_type = 'string' if 'string' not in line: raise NotImplementedError('Not implemented: ' + line) if left.endswith('['): object_stack.append({'type': 'array', 'items': {'type': property_refined_type}}) property_stack.append({}) name_stack.append(last_name) elif left.endswith('{'): object_stack.append({'type': 'object'}) property_stack.append({}) name_stack.append(last_name) elif (left.endswith(']') and '[' not in left) or (left.endswith('}') and '{' not in left): obj = object_stack.pop() prop = property_stack.pop() name = name_stack.pop() if len(prop) > 0: if 'items' in obj: obj['items']['properties'] = prop else: obj['properties'] = prop if len(property_stack) > 0: if 'items' in object_stack[-1]: object_stack[-1]['items']['type'] = obj['type'] if len(prop) > 0: object_stack[-1]['items']['properties'] = prop else: if name is None: raise RuntimeError('Not expected') property_stack[-1][name] = obj else: ret = obj if ret is not None: if i + 1 < len(lines) - 1: print('WARNING: unparsable data...', file=sys.stderr) lines = lines[i+1:] if not lines[0]: lines = lines[1:] nret = get_obj_from_dirty_text("\n".join(lines)) if not nret: nret = get_obj_from_dirty_text("\n".join(lines[1:])) if nret: ret.update(nret) return ret except Exception as e: print('Exception: ' + str(e), file=sys.stderr) print('Unable to cope with: ' + '\n'.join(lines), file=sys.stderr) return None def get_type(arg_type: str, full_line: str): if arg_type is None: return 'string', True, None required = 'required' in arg_type or 'optional' not in arg_type arg_type = arg_type.lower() if 'array' in arg_type: return 'array', required, None if 'numeric' in arg_type or 'number' in arg_type: return 'number', required, None if 'bool' in arg_type: return 'boolean', required, None if 'string' in arg_type: return 'string', required, None if 'object' in arg_type: properties = get_obj_from_dirty_text(full_line) if full_line is not None else None return 'object', required, properties if arg_type.startswith('optional'): return 'optional', required, None if arg_type.startswith('json'): return 'json', required, None print('Unable to derive type from: ' + arg_type, file=sys.stderr) return None, False, None def get_default(arg_refined_type: str, arg_type: str): if 'default=' in arg_type: if 'number' in arg_refined_type: return int(re.match('.*default=([^,)]+)', arg_type).group(1)) if 'string' in arg_refined_type: return re.match('.*default=([^,)]+)', arg_type).group(1) if 'boolean' in arg_refined_type: if 'default=true' in arg_type: return True if 'default=false' in arg_type: return False raise NotImplementedError('Not implemented: ' + arg_type) if 'array' in arg_type: raise NotImplementedError('Not implemented: ' + arg_type) return None def parse_single_argument(line: str): if line: line = line.strip() if not line or line.startswith('None'): return None, None, False arg_parsed = re_argline.fullmatch(line) if arg_parsed is None: if line.startswith('{') or line.startswith('['): return get_obj_from_dirty_text(line), None, True else: print("Unparsable argument: " + line, file=sys.stderr) descriptor = { 'type': 'array' if line.startswith('[') else 'object', 'description': line, } return descriptor, None, True arg_name, arg_type, arg_desc = arg_parsed.group('name', 'type', 'desc') if not arg_type: raise NotImplementedError('Not implemented: ' + arg_type) arg_refined_type, arg_required, arg_properties = get_type(arg_type, arg_desc) if arg_properties is not None: return arg_properties, arg_name, arg_required arg_refined_default = get_default(arg_refined_type, arg_type) arg_desc = re.sub('\s+', ' ', arg_desc.strip()) \ if arg_desc and arg_refined_type is not 'object' and arg_refined_type is not 'array' \ else arg_desc.strip() if arg_desc else '' descriptor = { 'type': arg_refined_type, 'description': arg_desc, } if arg_refined_default is not None: descriptor['default'] = arg_refined_default return descriptor, arg_name, arg_required def parse_params(args: str): arguments = {} requireds = [] if args: for line in re.split('\s*\d+\.\s+', args, re.DOTALL): descriptor, name, required = parse_single_argument(line) if descriptor is None: continue if required: requireds.append(name) arguments[name] = descriptor return arguments, requireds def get_api(section_name: str, command: str, command_help: str): parsed = re_full.fullmatch(command_help) if parsed is None: raise RuntimeError('Unable to resolve help format for ' + command) name, desc, args, resl, exmp = parsed.group('name', 'desc', 'args', 'resl', 'exmp') properties, required = parse_params(args) result_descriptor, result_name, result_required = parse_single_argument(resl) desc = re.sub('\s+', ' ', desc.strip()) if desc else name example_array = exmp.splitlines() if exmp else [] ret = { 'summary': desc, 'description': example_array, 'tags': [section_name], 'params': { 'type': 'object', 'properties': properties, 'required': required }, } if result_descriptor is not None: ret['result'] = result_descriptor return ret def write_api(): if len(sys.argv) < 2: print("Missing required argument: ", file=sys.stderr) sys.exit(1) cli_tool = sys.argv[1] result = sp.run([cli_tool, "help"], stdout=sp.PIPE, universal_newlines=True) commands = result.stdout sections = re.split('^==\s*(.*?)\s*==$', commands, flags=re.MULTILINE) methods = {} for section in sections: if not section: continue lines = section.splitlines() if len(lines) == 1: section_name = lines[0] continue for command in sorted(lines[1:]): if not command: continue command = command.split(' ')[0] result = sp.run([cli_tool, "help", command], stdout=sp.PIPE, universal_newlines=True) methods[command] = get_api(section_name, command, result.stdout) version = sp.run([cli_tool, "--version"], stdout=sp.PIPE, universal_newlines=True) wrapper = { '$schema': 'https://rawgit.com/mzernetsch/jrgen/master/jrgen-spec.schema.json', 'jrgen': '1.1', 'jsonrpc': '1.0', # see https://github.com/bitcoin/bitcoin/pull/12435 'info': { 'title': 'lbrycrd RPC API', 'version': version.stdout.strip(), 'description': [] }, 'definitions': {}, # for items used in $ref further down 'methods': methods, } schema = req.urlopen(wrapper['$schema']).read().decode('utf-8') try: jsonschema.validate(wrapper, schema) except Exception as e: print('From schema validation: ' + str(e), file=sys.stderr) print(json.dumps(wrapper, indent=4)) if __name__ == '__main__': write_api()