From 586a175e6bf2a0b2cdd7aed946f4690f2cb51d24 Mon Sep 17 00:00:00 2001 From: Matthew Woehlke Date: Fri, 3 May 2024 14:23:35 -0400 Subject: [PATCH] Generate JSON schema from documentation Using the structured information extracted from the documentation (see previous commit), build and write a JSON schema. This also introduces a new utility library to convert build the schema description from the set of objects and attributes. --- _extensions/cps.py | 47 +++++++++++++++++++++ _packages/jsb.py | 102 +++++++++++++++++++++++++++++++++++++++++++++ conf.py | 8 ++++ pyproject.toml | 3 +- 4 files changed, 159 insertions(+), 1 deletion(-) create mode 100644 _packages/jsb.py diff --git a/_extensions/cps.py b/_extensions/cps.py index 9235051..cdad821 100644 --- a/_extensions/cps.py +++ b/_extensions/cps.py @@ -1,3 +1,4 @@ +import os import re from dataclasses import dataclass from typing import cast @@ -6,6 +7,8 @@ from docutils.parsers.rst import Directive, directives, roles from docutils.transforms import Transform +from jsb import JsonSchema + from sphinx import addnodes, domains from sphinx.util import logging from sphinx.util.nodes import clean_astext @@ -346,9 +349,53 @@ def add_role(self, name, styles=None, parent=roles.generic_custom_role): def add_code_role(self, name, styles=None, parent=roles.code_role): self.add_role(name, styles, parent) + +# ============================================================================= +def write_schema(app, exception): + if exception is not None: + return + + config = app.env.config + title = f'{config.project} v{config.version}' + + domain = cast(CpsDomain, app.env.get_domain('cps')) + schema = JsonSchema(title, config.schema_id) + + object_attributes = {} + for attribute_set in domain.attributes.values(): + for i, attribute in enumerate(attribute_set.instances): + schema.add_attribute( + attribute_set.name, i, + attribute.typedesc, + attribute.description, + ) + + for context, attribute_ref in attribute_set.context.items(): + attribute = ( + attribute_set.name, + attribute_ref[0], + attribute_set.instances[attribute_ref[0]].required + ) + if context in object_attributes: + object_attributes[context].append(attribute) + else: + object_attributes[context] = [attribute] + + for name, description in domain.objects.items(): + schema.add_object_type(name, description, object_attributes[name]) + + output_path = os.path.join(app.outdir, config.schema_filename) + schema.write(config.schema_root_object, output_path) + # ============================================================================= def setup(app): app.add_domain(CpsDomain) + app.add_config_value('schema_id', '', '', [str]) + app.add_config_value('schema_filename', 'schema.json', '', [str]) + app.add_config_value('schema_root_object', None, '', [str]) # Add custom transform to resolve cross-file references app.add_transform(InternalizeLinks) + + # Add hook to write machine-readable schema description on completion + app.connect('build-finished', write_schema) diff --git a/_packages/jsb.py b/_packages/jsb.py new file mode 100644 index 0000000..91c9f85 --- /dev/null +++ b/_packages/jsb.py @@ -0,0 +1,102 @@ +import json +import re + +# ============================================================================= +class JsonSchema: + # ------------------------------------------------------------------------- + def __init__(self, title, uri): + self.meta = { + '$schema': 'http://json-schema.org/draft-07/schema', + '$id': uri, + 'title': title, + } + self.types = {} + self.object_types = {} + self.attributes = {} + + # ------------------------------------------------------------------------- + def add_type(self, typedesc): + if typedesc in self.types: + # Type already defined; nothing to do + return + + if '|' in typedesc: + types = typedesc.split('|') + for t in types: + self.add_type(t) + + self.types[typedesc] = { + 'oneOf': [ + {'$ref': f'#/definitions/types/{t}'} for t in types + ], + } + + return + + m = re.match(r'^(list|map)[(](.*)[)]$', typedesc) + if m: + outer, inner = m.groups() + self.add_type(inner) + + if outer == 'list': + self.types[typedesc] = { + 'type': 'array', + 'items': {'$ref': f'#/definitions/types/{inner}'}, + } + + else: + self.types[typedesc] = { + 'type': 'object', + 'patternProperties': { + '': {'$ref': f'#/definitions/types/{inner}'}, + }, + } + + elif typedesc in {'string'}: + # Handle simple (non-compound) types + self.types[typedesc] = {'type': typedesc} + + else: + # Anything unknown is assumed to be an object type, which must be + # defined via add_object_type + pass + + # ------------------------------------------------------------------------- + def add_attribute(self, name, instance, typedesc, description): + self.attributes[f'{name}@{instance}'] = { + 'description': description, + '$ref': f'#/definitions/types/{typedesc}', + } + + self.add_type(typedesc) + + # ------------------------------------------------------------------------- + def add_object_type(self, name, description, attributes, strict=False): + self.object_types[name] = { + 'type': 'object', + 'description': description, + 'properties': { + n: {'$ref': f'#/definitions/attributes/{n}@{i}'} + for n, i, r in attributes + }, + 'required': [n for n, i, r in attributes if r], + 'additionalProperties': not strict, + } + + # ------------------------------------------------------------------------- + def _build_schema(self, root_object): + all_types = self.types + all_types.update(self.object_types) + + schema = self.meta + schema['definitions'] = { + 'types': all_types, + 'attributes': self.attributes, + } + schema['$ref'] = f'#/definitions/types/{root_object}' + + return schema + + # ------------------------------------------------------------------------- + def write(self, root_object, path): + json.dump(self._build_schema(root_object), open(path, 'wt')) diff --git a/conf.py b/conf.py index fc43f6e..636beff 100644 --- a/conf.py +++ b/conf.py @@ -28,6 +28,14 @@ highlight_language = 'none' pygments_style = 'sphinx' +# -- Options for JSON schema output --------------------------------------- +schema_filename = 'cps.schema.json' +schema_root_object = 'package' +schema_id = ( + 'https://raw.githubusercontent.com/cps-org/cps/master/' + + schema_filename +) + # -- Options for HTML output ---------------------------------------------- html_style = 'cps.css' html_theme = 'default' diff --git a/pyproject.toml b/pyproject.toml index 2946648..4ee70cd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,8 +9,9 @@ description = "" authors = ["The CPS Project <@>"] readme = "readme.md" packages = [ + { include = "jsb.py", from = "_packages" }, { include = "cps.py", from = "_extensions" }, - { include = "autosectionlabel.py", from = "_extensions" } + { include = "autosectionlabel.py", from = "_extensions" }, ] [tool.poetry.dependencies]